diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c428d61d5..9201cf4cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,13 +89,49 @@ Most tests require you to [set up a mock server](https://github.com/stoplightio/ ```sh # you will need npm installed -$ npx prism mock path/to/your/openapi.yml +npx prism mock path/to/your/openapi.yml ``` ```sh -$ ./scripts/test +# run all tests +./scripts/test + +# pass in pytest args, eg to show info on skipped tests: +./scripts/test -rs + +# Run tests for only one python version +UV_PYTHON=3.13 ./scripts/test +``` + +### Running pytets + +Assuming you have a uv virtual env set up in .venv, the following are helpful for more granular test running: + +```sh +# Run all tests pytests +.venv/bin/pytest tests/ + +# Run all SDK tests with verbose info +.venv/bin/pytest tests/sdk/ -v + +# Run specific test class +.venv/bin/pytest tests/sdk/test_clients.py::TestAgentClient -v + +# Run specific test method +.venv/bin/pytest tests/sdk/test_clients.py::TestAgentClient::test_create_from_npm -v + +# Run agent smoketests (requires RUNLOOP_API_KEY) +export RUNLOOP_API_KEY=your_key_here +.venv/bin/pytest tests/smoketests/sdk/test_agent.py -v + +# Run tests matching a pattern +.venv/bin/pytest tests/sdk/ -k "agent" -v + +# Run with coverage +.venv/bin/pytest tests/sdk/ --cov=src/runloop_api_client/sdk --cov-report=html ``` + ## Linting and formatting This repository uses [ruff](https://github.com/astral-sh/ruff) and diff --git a/docs/Makefile b/docs/Makefile index d4bb2cbb9..2a753b5f6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,5 +1,11 @@ # Minimal makefile for Sphinx documentation # +# To rebuild the html docs, first make sure you've got the right deps installed via +# uv sync --group docs +# then from the docs directory (this dir) rebuild with +# uv run make html +# Look for generated docs under _build/html. + # You can set these variables from the command line, and also # from the environment for the first two. diff --git a/docs/conf.py b/docs/conf.py index 5e5d3232d..dc74a74c2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,7 +35,25 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "furo" -html_static_path = ["_static"] +# html_static_path = ["_static"] + +# Furo theme options +html_theme_options = { + "navigation_with_keys": True, + "sidebar_hide_name": False, +} + +# Show the global toctree in sidebar +html_sidebars = { + "**": [ + "sidebar/scroll-start.html", + "sidebar/brand.html", + "sidebar/search.html", + "sidebar/navigation.html", + "sidebar/ethical-ads.html", + "sidebar/scroll-end.html", + ] +} # -- Extension configuration ------------------------------------------------- diff --git a/docs/sdk/async/agent.rst b/docs/sdk/async/agent.rst new file mode 100644 index 000000000..724b23bd5 --- /dev/null +++ b/docs/sdk/async/agent.rst @@ -0,0 +1,7 @@ +Agent +====== + +The ``AsyncAgent`` class provides asynchronous methods for managing and interacting with stored Agents. + +.. automodule:: runloop_api_client.sdk.async_agent + :members: diff --git a/docs/sdk/async/index.rst b/docs/sdk/async/index.rst index 0ea16d5e4..2c8a72281 100644 --- a/docs/sdk/async/index.rst +++ b/docs/sdk/async/index.rst @@ -27,4 +27,4 @@ Asynchronous resource classes for working with devboxes, blueprints, snapshots, snapshot storage_object scorer - + agent diff --git a/docs/sdk/index.rst b/docs/sdk/index.rst index 0151a89d3..83958d554 100644 --- a/docs/sdk/index.rst +++ b/docs/sdk/index.rst @@ -9,6 +9,6 @@ The Runloop SDK provides both synchronous and asynchronous interfaces for managi :maxdepth: 2 :caption: SDK Documentation - sync/index async/index + sync/index types diff --git a/docs/sdk/sync/agent.rst b/docs/sdk/sync/agent.rst new file mode 100644 index 000000000..207c4b611 --- /dev/null +++ b/docs/sdk/sync/agent.rst @@ -0,0 +1,7 @@ +Agent +====== + +The ``Agent`` class provides synchronous methods for managing and interacting with stored Agents. + +.. automodule:: runloop_api_client.sdk.agent + :members: diff --git a/docs/sdk/sync/index.rst b/docs/sdk/sync/index.rst index e7d1ca616..063afa4ff 100644 --- a/docs/sdk/sync/index.rst +++ b/docs/sdk/sync/index.rst @@ -27,4 +27,5 @@ Synchronous resource classes for working with devboxes, blueprints, snapshots, a snapshot storage_object scorer + agent diff --git a/scripts/lint b/scripts/lint index aab79d229..983f711d5 100755 --- a/scripts/lint +++ b/scripts/lint @@ -5,7 +5,7 @@ set -e cd "$(dirname "$0")/.." echo "==> Running lints" -uv run ruff check . +uv run ruff check . "$@" echo "==> Making sure it imports" uv run python -c 'import runloop_api_client' diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index 48b5e3103..483f4b711 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -5,8 +5,10 @@ from __future__ import annotations -from .sync import DevboxOps, ScorerOps, RunloopSDK, SnapshotOps, BlueprintOps, StorageObjectOps +from .sync import AgentOps, DevboxOps, ScorerOps, RunloopSDK, SnapshotOps, BlueprintOps, StorageObjectOps +from .agent import Agent from .async_ import ( + AsyncAgentOps, AsyncDevboxOps, AsyncScorerOps, AsyncRunloopSDK, @@ -19,6 +21,7 @@ from .snapshot import Snapshot from .blueprint import Blueprint from .execution import Execution +from .async_agent import AsyncAgent from .async_devbox import AsyncDevbox, AsyncNamedShell from .async_scorer import AsyncScorer from .async_snapshot import AsyncSnapshot @@ -34,6 +37,8 @@ "RunloopSDK", "AsyncRunloopSDK", # Management interfaces + "AgentOps", + "AsyncAgentOps", "DevboxOps", "AsyncDevboxOps", "BlueprintOps", @@ -45,6 +50,8 @@ "StorageObjectOps", "AsyncStorageObjectOps", # Resource classes + "Agent", + "AsyncAgent", "Devbox", "AsyncDevbox", "Execution", diff --git a/src/runloop_api_client/sdk/_types.py b/src/runloop_api_client/sdk/_types.py index 028cb1805..4432c07a5 100644 --- a/src/runloop_api_client/sdk/_types.py +++ b/src/runloop_api_client/sdk/_types.py @@ -5,8 +5,10 @@ from ..lib.polling import PollingConfig from ..types.devboxes import DiskSnapshotListParams, DiskSnapshotUpdateParams from ..types.scenarios import ScorerListParams, ScorerCreateParams, ScorerUpdateParams, ScorerValidateParams +from ..types.agent_list_params import AgentListParams from ..types.devbox_list_params import DevboxListParams from ..types.object_list_params import ObjectListParams +from ..types.agent_create_params import AgentCreateParams from ..types.devbox_create_params import DevboxCreateParams, DevboxBaseCreateParams from ..types.object_create_params import ObjectCreateParams from ..types.blueprint_list_params import BlueprintListParams @@ -157,3 +159,11 @@ class SDKScorerUpdateParams(ScorerUpdateParams, LongRequestOptions): class SDKScorerValidateParams(ScorerValidateParams, LongRequestOptions): pass + + +class SDKAgentCreateParams(AgentCreateParams, LongRequestOptions): + pass + + +class SDKAgentListParams(AgentListParams, BaseRequestOptions): + pass diff --git a/src/runloop_api_client/sdk/agent.py b/src/runloop_api_client/sdk/agent.py new file mode 100644 index 000000000..e0b0e0ec4 --- /dev/null +++ b/src/runloop_api_client/sdk/agent.py @@ -0,0 +1,75 @@ +"""Agent resource class for synchronous operations.""" + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Unpack, override + +from ._types import ( + BaseRequestOptions, +) +from .._client import Runloop +from ..types.agent_view import AgentView + + +class Agent: + """Wrapper around synchronous agent operations. + + This class provides a Pythonic interface for interacting with agents, + including retrieving agent information. + + Example: + >>> agent = runloop.agent.create_from_npm( + ... name="my-agent", + ... package_name="@runloop/example-agent" + ... ) + >>> info = agent.get_info() + >>> print(info.name) + """ + + def __init__( + self, + client: Runloop, + agent_id: str, + agent_view: Optional[AgentView] = None, + ) -> None: + """Initialize the wrapper. + + :param client: Generated Runloop client + :type client: Runloop + :param agent_id: Agent identifier returned by the API + :type agent_id: str + """ + self._client = client + self._id = agent_id + self._agent_view = agent_view + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + """Return the agent identifier. + + :return: Unique agent ID + :rtype: str + """ + return self._id + + def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> AgentView: + """Retrieve the latest agent information. + + :param options: Optional request configuration + :return: Agent details + :rtype: AgentView + """ + if self._agent_view is None: + self._agent_view = self._client.agents.retrieve( + self._id, + **options, + ) + return self._agent_view diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index 23f87d6e9..0825c6b46 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -14,9 +14,11 @@ from ._types import ( LongRequestOptions, + SDKAgentListParams, SDKDevboxListParams, SDKObjectListParams, SDKScorerListParams, + SDKAgentCreateParams, SDKDevboxCreateParams, SDKObjectCreateParams, SDKScorerCreateParams, @@ -28,6 +30,7 @@ from .._types import Timeout, NotGiven, not_given from .._client import DEFAULT_MAX_RETRIES, AsyncRunloop from ._helpers import detect_content_type +from .async_agent import AsyncAgent from .async_devbox import AsyncDevbox from .async_scorer import AsyncScorer from .async_snapshot import AsyncSnapshot @@ -541,6 +544,222 @@ async def list(self, **params: Unpack[SDKScorerListParams]) -> list[AsyncScorer] """ page = await self._client.scenarios.scorers.list(**params) return [AsyncScorer(self._client, item.id) async for item in page] + +class AsyncAgentOps: + """High-level async manager for creating and managing agents. + + Accessed via ``runloop.agent`` from :class:`AsyncRunloopSDK`, provides + coroutines to create, retrieve, and list agents from various sources (npm, pip, git, object storage). + + Example: + >>> runloop = AsyncRunloopSDK() + >>> # Create agent from NPM package + >>> agent = await runloop.agent.create_from_npm( + ... name="my-agent", + ... package_name="@runloop/example-agent" + ... ) + >>> # Create agent from Git repository + >>> agent = await runloop.agent.create_from_git( + ... name="git-agent", + ... repository="https://github.com/user/agent-repo", + ... ref="main" + ... ) + >>> # List all agents + >>> agents = await runloop.agent.list(limit=10) + """ + + def __init__(self, client: AsyncRunloop) -> None: + """Initialize the manager. + + :param client: Generated AsyncRunloop client to wrap + :type client: AsyncRunloop + """ + self._client = client + + async def create( + self, + **params: Unpack[SDKAgentCreateParams], + ) -> AsyncAgent: + """Create a new agent. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for available parameters + :return: Wrapper bound to the newly created agent + :rtype: AsyncAgent + """ + agent_view = await self._client.agents.create( + **params, + ) + return AsyncAgent(self._client, agent_view.id, agent_view) + + async def create_from_npm( + self, + *, + package_name: str, + npm_version: Optional[str] = None, + registry_url: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> AsyncAgent: + """Create an agent from an NPM package. + + :param package_name: NPM package name + :type package_name: str + :param npm_version: NPM version constraint, defaults to None + :type npm_version: Optional[str], optional + :param registry_url: NPM registry URL, defaults to None + :type registry_url: Optional[str], optional + :param agent_setup: Setup commands to run after installation, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: AsyncAgent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_npm(); source is automatically set to npm configuration") + + npm_config: dict = {"package_name": package_name} + if npm_version is not None: + npm_config["npm_version"] = npm_version + if registry_url is not None: + npm_config["registry_url"] = registry_url + if agent_setup is not None: + npm_config["agent_setup"] = agent_setup + + return await self.create( + source={"type": "npm", "npm": npm_config}, + **params, + ) + + async def create_from_pip( + self, + *, + package_name: str, + pip_version: Optional[str] = None, + registry_url: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> AsyncAgent: + """Create an agent from a Pip package. + + :param package_name: Pip package name + :type package_name: str + :param pip_version: Pip version constraint, defaults to None + :type pip_version: Optional[str], optional + :param registry_url: Pip registry URL, defaults to None + :type registry_url: Optional[str], optional + :param agent_setup: Setup commands to run after installation, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: AsyncAgent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_pip(); source is automatically set to pip configuration") + + pip_config: dict = {"package_name": package_name} + if pip_version is not None: + pip_config["pip_version"] = pip_version + if registry_url is not None: + pip_config["registry_url"] = registry_url + if agent_setup is not None: + pip_config["agent_setup"] = agent_setup + + return await self.create( + source={"type": "pip", "pip": pip_config}, + **params, + ) + + async def create_from_git( + self, + *, + repository: str, + ref: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> AsyncAgent: + """Create an agent from a Git repository. + + :param repository: Git repository URL + :type repository: str + :param ref: Optional Git ref (branch/tag/commit), defaults to main/HEAD + :type ref: Optional[str], optional + :param agent_setup: Setup commands to run after cloning, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: AsyncAgent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_git(); source is automatically set to git configuration") + + git_config: dict = {"repository": repository} + if ref is not None: + git_config["ref"] = ref + if agent_setup is not None: + git_config["agent_setup"] = agent_setup + + return await self.create( + source={"type": "git", "git": git_config}, + **params, + ) + + async def create_from_object( + self, + *, + object_id: str, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> AsyncAgent: + """Create an agent from a storage object. + + :param object_id: Storage object ID + :type object_id: str + :param agent_setup: Setup commands to run after unpacking, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: AsyncAgent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_object(); source is automatically set to object configuration") + + object_config: dict = {"object_id": object_id} + if agent_setup is not None: + object_config["agent_setup"] = agent_setup + + return await self.create( + source={"type": "object", "object": object_config}, + **params, + ) + + def from_id(self, agent_id: str) -> AsyncAgent: + """Attach to an existing agent by ID. + + :param agent_id: Existing agent ID + :type agent_id: str + :return: Wrapper bound to the requested agent + :rtype: AsyncAgent + """ + return AsyncAgent(self._client, agent_id) + + async def list( + self, + **params: Unpack[SDKAgentListParams], + ) -> list[AsyncAgent]: + """List agents accessible to the caller. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentListParams` for available parameters + :return: Collection of agent wrappers + :rtype: list[AsyncAgent] + """ + page = await self._client.agents.list( + **params, + ) + return [AsyncAgent(self._client, item.id, item) for item in page.agents] class AsyncRunloopSDK: @@ -552,6 +771,8 @@ class AsyncRunloopSDK: :ivar api: Direct access to the generated async REST API client :vartype api: AsyncRunloop + :ivar agent: High-level async interface for agent management. + :vartype agent: AsyncAgentOps :ivar devbox: High-level async interface for devbox management :vartype devbox: AsyncDevboxOps :ivar blueprint: High-level async interface for blueprint management @@ -572,6 +793,7 @@ class AsyncRunloopSDK: """ api: AsyncRunloop + agent: AsyncAgentOps devbox: AsyncDevboxOps blueprint: AsyncBlueprintOps scorer: AsyncScorerOps @@ -616,6 +838,7 @@ def __init__( http_client=http_client, ) + self.agent = AsyncAgentOps(self.api) self.devbox = AsyncDevboxOps(self.api) self.blueprint = AsyncBlueprintOps(self.api) self.scorer = AsyncScorerOps(self.api) diff --git a/src/runloop_api_client/sdk/async_agent.py b/src/runloop_api_client/sdk/async_agent.py new file mode 100644 index 000000000..eefce2120 --- /dev/null +++ b/src/runloop_api_client/sdk/async_agent.py @@ -0,0 +1,79 @@ +"""Agent resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Unpack, override + +from ._types import ( + BaseRequestOptions, +) +from .._client import AsyncRunloop +from ..types.agent_view import AgentView + + +class AsyncAgent: + """Async wrapper around agent operations. + + This class provides an asynchronous interface for interacting with agents, + including retrieving agent information. + + Example: + >>> agent = await runloop.agent.create_from_npm( + ... name="my-agent", + ... package_name="@runloop/example-agent" + ... ) + >>> info = await agent.get_info() + >>> print(info.name) + """ + + def __init__( + self, + client: AsyncRunloop, + agent_id: str, + agent_view: Optional[AgentView] = None, + ) -> None: + """Initialize the wrapper. + + :param client: Generated AsyncRunloop client + :type client: AsyncRunloop + :param agent_id: Agent identifier returned by the API + :type agent_id: str + """ + self._client = client + self._id = agent_id + self._agent_view = agent_view + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + """Return the agent identifier. + + :return: Unique agent ID + :rtype: str + """ + return self._id + + async def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> AgentView: + """Retrieve the latest agent information. + + :param options: Optional request configuration + :return: Agent details + :rtype: AgentView + """ + return await self._client.agents.retrieve( + self._id, + **options, + ) + if self._agent_view is None: + self._agent_view = self._client.agents.retrieve( + self._id, + **options, + ) + return self._agent_view diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 89080abef..3c6db3b31 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -54,7 +54,7 @@ class AsyncDevbox: management. Example: - >>> devbox = await sdk.devbox.create(name="my-devbox") + >>> devbox = await runloop.devbox.create(name="my-devbox") >>> async with devbox: ... result = await devbox.cmd.exec("echo 'hello'") ... print(await result.stdout()) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index cad396815..22cda6fe3 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -54,7 +54,7 @@ class Devbox: The Devbox class supports context manager protocol for automatic cleanup. Example: - >>> with sdk.devbox.create(name="my-devbox") as devbox: + >>> with runloop.devbox.create(name="my-devbox") as devbox: ... result = devbox.cmd.exec("echo 'hello'") ... print(result.stdout()) # Devbox is automatically shutdown on exit diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 99410c2d0..e4fad064c 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -11,11 +11,14 @@ import httpx +from .agent import Agent from ._types import ( LongRequestOptions, + SDKAgentListParams, SDKDevboxListParams, SDKObjectListParams, SDKScorerListParams, + SDKAgentCreateParams, SDKDevboxCreateParams, SDKObjectCreateParams, SDKScorerCreateParams, @@ -537,6 +540,255 @@ def list(self, **params: Unpack[SDKScorerListParams]) -> list[Scorer]: page = self._client.scenarios.scorers.list(**params) return [Scorer(self._client, item.id) for item in page] + +class AgentOps: + """High-level manager for creating and managing agents. + + Accessed via ``runloop.agent`` from :class:`RunloopSDK`, provides methods to + create, retrieve, and list agents from various sources (npm, pip, git, object storage). + + Example: + >>> runloop = RunloopSDK() + >>> # Create agent from NPM package + >>> agent = runloop.agent.create_from_npm( + ... name="my-agent", + ... package_name="@runloop/example-agent" + ... ) + >>> # Create agent from Git repository + >>> agent = runloop.agent.create_from_git( + ... name="git-agent", + ... repository="https://github.com/user/agent-repo", + ... ref="main" + ... ) + >>> # List all agents + >>> agents = runloop.agent.list(limit=10) + """ + + def __init__(self, client: Runloop) -> None: + """Initialize the manager. + + :param client: Generated Runloop client to wrap + :type client: Runloop + """ + self._client = client + + def create( + self, + **params: Unpack[SDKAgentCreateParams], + ) -> Agent: + """Create a new agent. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for available parameters + :return: Wrapper bound to the newly created agent + :rtype: Agent + """ + agent_view = self._client.agents.create( + **params, + ) + return Agent(self._client, agent_view.id, agent_view) + + def create_from_npm( + self, + *, + package_name: str, + npm_version: Optional[str] = None, + registry_url: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> Agent: + """Create an agent from an NPM package. + + Example: + >>> agent = runloop.agent.create_from_npm( + ... name="my-npm-agent", + ... package_name="@runloop/example-agent", + ... npm_version="^1.0.0" + ... ) + + :param package_name: NPM package name + :type package_name: str + :param npm_version: NPM version constraint, defaults to None + :type npm_version: Optional[str], optional + :param registry_url: NPM registry URL, defaults to None + :type registry_url: Optional[str], optional + :param agent_setup: Setup commands to run after installation, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: Agent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_npm(); source is automatically set to npm configuration") + + npm_config: dict = {"package_name": package_name} + if npm_version is not None: + npm_config["npm_version"] = npm_version + if registry_url is not None: + npm_config["registry_url"] = registry_url + if agent_setup is not None: + npm_config["agent_setup"] = agent_setup + + return self.create( + source={"type": "npm", "npm": npm_config}, + **params, + ) + + def create_from_pip( + self, + *, + package_name: str, + pip_version: Optional[str] = None, + registry_url: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> Agent: + """Create an agent from a Pip package. + + Example: + >>> agent = runloop.agent.create_from_pip( + ... name="my-pip-agent", + ... package_name="runloop-example-agent", + ... pip_version=">=1.0.0" + ... ) + + :param package_name: Pip package name + :type package_name: str + :param pip_version: Pip version constraint, defaults to None + :type pip_version: Optional[str], optional + :param registry_url: Pip registry URL, defaults to None + :type registry_url: Optional[str], optional + :param agent_setup: Setup commands to run after installation, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: Agent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_pip(); source is automatically set to pip configuration") + + pip_config: dict = {"package_name": package_name} + if pip_version is not None: + pip_config["pip_version"] = pip_version + if registry_url is not None: + pip_config["registry_url"] = registry_url + if agent_setup is not None: + pip_config["agent_setup"] = agent_setup + + return self.create( + source={"type": "pip", "pip": pip_config}, + **params, + ) + + def create_from_git( + self, + *, + repository: str, + ref: Optional[str] = None, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> Agent: + """Create an agent from a Git repository. + + Example: + >>> agent = runloop.agent.create_from_git( + ... name="my-git-agent", + ... repository="https://github.com/user/agent-repo", + ... ref="main", + ... agent_setup=["npm install", "npm run build"] + ... ) + + :param repository: Git repository URL + :type repository: str + :param ref: Optional Git ref (branch/tag/commit), defaults to main/HEAD + :type ref: Optional[str], optional + :param agent_setup: Setup commands to run after cloning, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: Agent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_git(); source is automatically set to git configuration") + + git_config: dict = {"repository": repository} + if ref is not None: + git_config["ref"] = ref + if agent_setup is not None: + git_config["agent_setup"] = agent_setup + + return self.create( + source={"type": "git", "git": git_config}, + **params, + ) + + def create_from_object( + self, + *, + object_id: str, + agent_setup: Optional[list[str]] = None, + **params: Unpack[SDKAgentCreateParams], + ) -> Agent: + """Create an agent from a storage object. + + Example: + >>> # First upload agent code as an object + >>> obj = runloop.storage_object.upload_from_dir("./my-agent") + >>> # Then create agent from the object + >>> agent = runloop.agent.create_from_object( + ... name="my-object-agent", + ... object_id=obj.id, + ... agent_setup=["chmod +x setup.sh", "./setup.sh"] + ... ) + + :param object_id: Storage object ID + :type object_id: str + :param agent_setup: Setup commands to run after unpacking, defaults to None + :type agent_setup: Optional[list[str]], optional + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentCreateParams` for additional parameters (excluding 'source') + :return: Wrapper bound to the newly created agent + :rtype: Agent + :raises ValueError: If 'source' is provided in params + """ + if "source" in params: + raise ValueError("Cannot specify 'source' when using create_from_object(); source is automatically set to object configuration") + + object_config: dict = {"object_id": object_id} + if agent_setup is not None: + object_config["agent_setup"] = agent_setup + + return self.create( + source={"type": "object", "object": object_config}, + **params, + ) + + def from_id(self, agent_id: str) -> Agent: + """Attach to an existing agent by ID. + + :param agent_id: Existing agent ID + :type agent_id: str + :return: Wrapper bound to the requested agent + :rtype: Agent + """ + return Agent(self._client, agent_id) + + def list( + self, + **params: Unpack[SDKAgentListParams], + ) -> list[Agent]: + """List agents accessible to the caller. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKAgentListParams` for available parameters + :return: Collection of agent wrappers + :rtype: list[Agent] + """ + page = self._client.agents.list( + **params, + ) + return [Agent(self._client, item.id, item) for item in page.agents] + class RunloopSDK: """High-level synchronous entry point for the Runloop SDK. @@ -547,6 +799,8 @@ class RunloopSDK: :ivar api: Direct access to the generated REST API client :vartype api: Runloop + :ivar agent: High-level interface for agent management. + :vartype agent: AgentOps :ivar devbox: High-level interface for devbox management :vartype devbox: DevboxOps :ivar blueprint: High-level interface for blueprint management @@ -567,6 +821,7 @@ class RunloopSDK: """ api: Runloop + agent: AgentOps devbox: DevboxOps blueprint: BlueprintOps scorer: ScorerOps @@ -611,6 +866,7 @@ def __init__( http_client=http_client, ) + self.agent = AgentOps(self.api) self.devbox = DevboxOps(self.api) self.blueprint = BlueprintOps(self.api) self.scorer = ScorerOps(self.api) diff --git a/src/runloop_api_client/types/scoring_function.py b/src/runloop_api_client/types/scoring_function.py index c49ff61e4..ba4aea9e1 100644 --- a/src/runloop_api_client/types/scoring_function.py +++ b/src/runloop_api_client/types/scoring_function.py @@ -120,7 +120,7 @@ class ScorerTestBasedScoringFunction(BaseModel): class ScoringFunction(BaseModel): name: str - """Name of scoring function. Names must only contain [a-zA-Z0-9_-].""" + """Name of scoring function. Names must only contain ``[a-zA-Z0-9_-]``.""" scorer: Scorer """The scoring function to use for evaluating this scenario. diff --git a/src/runloop_api_client/types/scoring_function_param.py b/src/runloop_api_client/types/scoring_function_param.py index e2c9bc253..f9b6b26c7 100644 --- a/src/runloop_api_client/types/scoring_function_param.py +++ b/src/runloop_api_client/types/scoring_function_param.py @@ -116,7 +116,7 @@ class ScorerTestBasedScoringFunction(TypedDict, total=False): class ScoringFunctionParam(TypedDict, total=False): name: Required[str] - """Name of scoring function. Names must only contain [a-zA-Z0-9_-].""" + """Name of scoring function. Names must only contain ``[a-zA-Z0-9_-]``.""" scorer: Required[Scorer] """The scoring function to use for evaluating this scenario. diff --git a/tests/sdk/conftest.py b/tests/sdk/conftest.py index b61a93301..6abf02684 100644 --- a/tests/sdk/conftest.py +++ b/tests/sdk/conftest.py @@ -21,6 +21,7 @@ "blueprint": "bp_123", "object": "obj_123", "scorer": "scorer_123", + "agent": "agent_123", } # Test URL constants @@ -96,6 +97,17 @@ class MockScorerView: type: str = "test_scorer" +@dataclass +class MockAgentView: + """Mock AgentView for testing.""" + + id: str = "agent_123" + name: str = "test-agent" + create_time_ms: int = 1234567890000 + is_public: bool = False + source: Any = None + + def create_mock_httpx_client(methods: dict[str, Any] | None = None) -> AsyncMock: """ Create a mock httpx.AsyncClient with proper context manager setup. @@ -186,6 +198,12 @@ def scorer_view() -> MockScorerView: return MockScorerView() +@pytest.fixture +def agent_view() -> MockAgentView: + """Create a mock AgentView.""" + return MockAgentView() + + @pytest.fixture def mock_httpx_response() -> Mock: """Create a mock httpx.Response.""" diff --git a/tests/sdk/test_agent.py b/tests/sdk/test_agent.py new file mode 100644 index 000000000..7580b44d8 --- /dev/null +++ b/tests/sdk/test_agent.py @@ -0,0 +1,43 @@ +"""Comprehensive tests for sync Agent class.""" + +from __future__ import annotations + +from unittest.mock import Mock + +from tests.sdk.conftest import MockAgentView +from runloop_api_client.sdk import Agent + + +class TestAgent: + """Tests for Agent class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test Agent initialization.""" + agent = Agent(mock_client, "agent_123") + assert agent.id == "agent_123" + + def test_repr(self, mock_client: Mock) -> None: + """Test Agent string representation.""" + agent = Agent(mock_client, "agent_123") + assert repr(agent) == "" + + def test_get_info(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test get_info method.""" + mock_client.agents.retrieve.return_value = agent_view + + agent = Agent(mock_client, "agent_123") + result = agent.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == agent_view + mock_client.agents.retrieve.assert_called_once_with( + "agent_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) diff --git a/tests/sdk/test_async_agent.py b/tests/sdk/test_async_agent.py new file mode 100644 index 000000000..a2bb9496c --- /dev/null +++ b/tests/sdk/test_async_agent.py @@ -0,0 +1,46 @@ +"""Comprehensive tests for async AsyncAgent class.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from tests.sdk.conftest import MockAgentView +from runloop_api_client.sdk import AsyncAgent + + +class TestAsyncAgent: + """Tests for AsyncAgent class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncAgent initialization.""" + agent = AsyncAgent(mock_async_client, "agent_123") + assert agent.id == "agent_123" + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncAgent string representation.""" + agent = AsyncAgent(mock_async_client, "agent_123") + assert repr(agent) == "" + + @pytest.mark.asyncio + async def test_get_info(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test get_info method.""" + mock_async_client.agents.retrieve = AsyncMock(return_value=agent_view) + + agent = AsyncAgent(mock_async_client, "agent_123") + result = await agent.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == agent_view + mock_async_client.agents.retrieve.assert_called_once_with( + "agent_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) diff --git a/tests/sdk/test_async_ops.py b/tests/sdk/test_async_ops.py index 340d86647..8a455cd68 100644 --- a/tests/sdk/test_async_ops.py +++ b/tests/sdk/test_async_ops.py @@ -11,6 +11,7 @@ import pytest from tests.sdk.conftest import ( + MockAgentView, MockDevboxView, MockObjectView, MockScorerView, @@ -18,8 +19,16 @@ MockBlueprintView, create_mock_httpx_response, ) -from runloop_api_client.sdk import AsyncDevbox, AsyncScorer, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject +from runloop_api_client.sdk import ( + AsyncAgent, + AsyncDevbox, + AsyncScorer, + AsyncSnapshot, + AsyncBlueprint, + AsyncStorageObject, +) from runloop_api_client.sdk.async_ import ( + AsyncAgentOps, AsyncDevboxOps, AsyncScorerOps, AsyncRunloopSDK, @@ -721,6 +730,342 @@ async def async_iter(): assert scorers[1].id == "scorer_002" mock_async_client.scenarios.scorers.list.assert_awaited_once() + +class TestAsyncAgentClient: + """Tests for AsyncAgentClient class.""" + + @pytest.mark.asyncio + async def test_create(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test create method.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create( + name="test-agent", + metadata={"key": "value"}, + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_called_once() + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncAgentOps(mock_async_client) + agent = client.from_id("agent_123") + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + + @pytest.mark.asyncio + async def test_list(self, mock_async_client: AsyncMock) -> None: + """Test list method.""" + # Create three agent views with different data + agent_view_1 = MockAgentView( + id="agent_001", + name="first-agent", + create_time_ms=1234567890000, + is_public=False, + source=None, + ) + agent_view_2 = MockAgentView( + id="agent_002", + name="second-agent", + create_time_ms=1234567891000, + is_public=True, + source={"type": "git", "git": {"repository": "https://github.com/example/repo"}}, + ) + agent_view_3 = MockAgentView( + id="agent_003", + name="third-agent", + create_time_ms=1234567892000, + is_public=False, + source={"type": "npm", "npm": {"package_name": "example-package"}}, + ) + + page = SimpleNamespace(agents=[agent_view_1, agent_view_2, agent_view_3]) + mock_async_client.agents.list = AsyncMock(return_value=page) + + # Mock retrieve to return the corresponding agent_view when called + async def mock_retrieve(agent_id, **_unused_kwargs): + if agent_id == "agent_001": + return agent_view_1 + elif agent_id == "agent_002": + return agent_view_2 + elif agent_id == "agent_003": + return agent_view_3 + return None + + mock_async_client.agents.retrieve = AsyncMock(side_effect=mock_retrieve) + + client = AsyncAgentOps(mock_async_client) + agents = await client.list( + limit=10, + starting_after="agent_000", + ) + + # Verify we got three agents + assert len(agents) == 3 + assert all(isinstance(agent, AsyncAgent) for agent in agents) + + # Verify the agent IDs + assert agents[0].id == "agent_001" + assert agents[1].id == "agent_002" + assert agents[2].id == "agent_003" + + # Test that get_info() retrieves the AgentView for the first agent + info = await agents[0].get_info() + assert info.id == "agent_001" + assert info.name == "first-agent" + assert info.create_time_ms == 1234567890000 + assert info.is_public is False + assert info.source is None + + # Test that get_info() retrieves the AgentView for the second agent + info = await agents[1].get_info() + assert info.id == "agent_002" + assert info.name == "second-agent" + assert info.create_time_ms == 1234567891000 + assert info.is_public is True + assert info.source == {"type": "git", "git": {"repository": "https://github.com/example/repo"}} + + # Test that get_info() retrieves the AgentView for the third agent + info = await agents[2].get_info() + assert info.id == "agent_003" + assert info.name == "third-agent" + assert info.create_time_ms == 1234567892000 + assert info.is_public is False + assert info.source == {"type": "npm", "npm": {"package_name": "example-package"}} + + # Verify that agents.retrieve was called three times (once for each get_info) + assert mock_async_client.agents.retrieve.call_count == 3 + + mock_async_client.agents.list.assert_called_once() + + @pytest.mark.asyncio + async def test_create_from_npm(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test create_from_npm factory method.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_npm( + name="test-agent", + package_name="@runloop/example-agent", + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_npm_with_all_options( + self, mock_async_client: AsyncMock, agent_view: MockAgentView + ) -> None: + """Test create_from_npm factory method with all optional parameters.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_npm( + name="test-agent", + package_name="@runloop/example-agent", + npm_version="1.2.3", + registry_url="https://registry.example.com", + agent_setup=["npm install", "npm run setup"], + extra_headers={"X-Custom": "header"}, + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + "npm_version": "1.2.3", + "registry_url": "https://registry.example.com", + "agent_setup": ["npm install", "npm run setup"], + }, + }, + name="test-agent", + extra_headers={"X-Custom": "header"}, + ) + + @pytest.mark.asyncio + async def test_create_from_npm_raises_when_source_provided(self, mock_async_client: AsyncMock) -> None: + """Test create_from_npm raises ValueError when source is provided in params.""" + client = AsyncAgentOps(mock_async_client) + + with pytest.raises(ValueError, match="Cannot specify 'source' when using create_from_npm"): + await client.create_from_npm( + name="test-agent", + package_name="@runloop/example-agent", + source={"type": "git", "git": {"repository": "https://github.com/example/repo"}}, + ) + + @pytest.mark.asyncio + async def test_create_from_pip(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test create_from_pip factory method.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_pip( + name="test-agent", + package_name="runloop-example-agent", + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "pip", + "pip": { + "package_name": "runloop-example-agent", + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_pip_with_all_options( + self, mock_async_client: AsyncMock, agent_view: MockAgentView + ) -> None: + """Test create_from_pip factory method with all optional parameters.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_pip( + name="test-agent", + package_name="runloop-example-agent", + pip_version="1.2.3", + registry_url="https://pypi.example.com", + agent_setup=["pip install extra-deps"], + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "pip", + "pip": { + "package_name": "runloop-example-agent", + "pip_version": "1.2.3", + "registry_url": "https://pypi.example.com", + "agent_setup": ["pip install extra-deps"], + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_git(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test create_from_git factory method.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_git( + name="test-agent", + repository="https://github.com/example/agent-repo", + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "git", + "git": { + "repository": "https://github.com/example/agent-repo", + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_git_with_all_options( + self, mock_async_client: AsyncMock, agent_view: MockAgentView + ) -> None: + """Test create_from_git factory method with all optional parameters.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_git( + name="test-agent", + repository="https://github.com/example/agent-repo", + ref="develop", + agent_setup=["npm install", "npm run build"], + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "git", + "git": { + "repository": "https://github.com/example/agent-repo", + "ref": "develop", + "agent_setup": ["npm install", "npm run build"], + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_object(self, mock_async_client: AsyncMock, agent_view: MockAgentView) -> None: + """Test create_from_object factory method.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_object( + name="test-agent", + object_id="obj_123", + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "object", + "object": { + "object_id": "obj_123", + }, + }, + name="test-agent", + ) + + @pytest.mark.asyncio + async def test_create_from_object_with_agent_setup( + self, mock_async_client: AsyncMock, agent_view: MockAgentView + ) -> None: + """Test create_from_object factory method with agent_setup.""" + mock_async_client.agents.create = AsyncMock(return_value=agent_view) + + client = AsyncAgentOps(mock_async_client) + agent = await client.create_from_object( + name="test-agent", + object_id="obj_123", + agent_setup=["chmod +x setup.sh", "./setup.sh"], + ) + + assert isinstance(agent, AsyncAgent) + assert agent.id == "agent_123" + mock_async_client.agents.create.assert_awaited_once_with( + source={ + "type": "object", + "object": { + "object_id": "obj_123", + "agent_setup": ["chmod +x setup.sh", "./setup.sh"], + }, + }, + name="test-agent", + ) + class TestAsyncRunloopSDK: """Tests for AsyncRunloopSDK class.""" @@ -729,6 +1074,7 @@ def test_init(self) -> None: """Test AsyncRunloopSDK initialization.""" sdk = AsyncRunloopSDK(bearer_token="test-token") assert sdk.api is not None + assert isinstance(sdk.agent, AsyncAgentOps) assert isinstance(sdk.devbox, AsyncDevboxOps) assert isinstance(sdk.scorer, AsyncScorerOps) assert isinstance(sdk.snapshot, AsyncSnapshotOps) diff --git a/tests/sdk/test_ops.py b/tests/sdk/test_ops.py index 83c9117f4..cf3a7216d 100644 --- a/tests/sdk/test_ops.py +++ b/tests/sdk/test_ops.py @@ -9,6 +9,7 @@ import pytest from tests.sdk.conftest import ( + MockAgentView, MockDevboxView, MockObjectView, MockScorerView, @@ -16,8 +17,9 @@ MockBlueprintView, create_mock_httpx_response, ) -from runloop_api_client.sdk import Devbox, Scorer, Snapshot, Blueprint, StorageObject +from runloop_api_client.sdk import Agent, Devbox, Scorer, Snapshot, Blueprint, StorageObject from runloop_api_client.sdk.sync import ( + AgentOps, DevboxOps, ScorerOps, RunloopSDK, @@ -653,6 +655,311 @@ def test_list_multiple(self, mock_client: Mock) -> None: mock_client.scenarios.scorers.list.assert_called_once() +class TestAgentClient: + """Tests for AgentClient class.""" + + def test_create(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create method.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create( + name="test-agent", + metadata={"key": "value"}, + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once() + + def test_from_id(self, mock_client: Mock) -> None: + """Test from_id method.""" + client = AgentOps(mock_client) + agent = client.from_id("agent_123") + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + + def test_list(self, mock_client: Mock) -> None: + """Test list method.""" + # Create three agent views with different data + agent_view_1 = MockAgentView( + id="agent_001", + name="first-agent", + create_time_ms=1234567890000, + is_public=False, + source=None, + ) + agent_view_2 = MockAgentView( + id="agent_002", + name="second-agent", + create_time_ms=1234567891000, + is_public=True, + source={"type": "git", "git": {"repository": "https://github.com/example/repo"}}, + ) + agent_view_3 = MockAgentView( + id="agent_003", + name="third-agent", + create_time_ms=1234567892000, + is_public=False, + source={"type": "npm", "npm": {"package_name": "example-package"}}, + ) + + page = SimpleNamespace(agents=[agent_view_1, agent_view_2, agent_view_3]) + mock_client.agents.list.return_value = page + + client = AgentOps(mock_client) + agents = client.list( + limit=10, + starting_after="agent_000", + ) + + # Verify we got three agents + assert len(agents) == 3 + assert all(isinstance(agent, Agent) for agent in agents) + + # Verify the agent IDs + assert agents[0].id == "agent_001" + assert agents[1].id == "agent_002" + assert agents[2].id == "agent_003" + + # Test that get_info() retrieves the cached AgentView for the first agent + info = agents[0].get_info() + assert info.id == "agent_001" + assert info.name == "first-agent" + assert info.create_time_ms == 1234567890000 + assert info.is_public is False + assert info.source is None + + # Test that get_info() retrieves the cached AgentView for the second agent + info = agents[1].get_info() + assert info.id == "agent_002" + assert info.name == "second-agent" + assert info.create_time_ms == 1234567891000 + assert info.is_public is True + assert info.source == {"type": "git", "git": {"repository": "https://github.com/example/repo"}} + + # Test that get_info() retrieves the cached AgentView for the third agent + info = agents[2].get_info() + assert info.id == "agent_003" + assert info.name == "third-agent" + assert info.create_time_ms == 1234567892000 + assert info.is_public is False + assert info.source == {"type": "npm", "npm": {"package_name": "example-package"}} + + # Verify that agents.retrieve was NOT called (because we're using cached data) + mock_client.agents.retrieve.assert_not_called() + + mock_client.agents.list.assert_called_once() + + def test_create_from_npm(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_npm factory method.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_npm( + name="test-agent", + package_name="@runloop/example-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + }, + }, + name="test-agent", + ) + + def test_create_from_npm_with_all_options(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_npm factory method with all optional parameters.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_npm( + package_name="@runloop/example-agent", + npm_version="1.2.3", + registry_url="https://registry.example.com", + agent_setup=["npm install", "npm run setup"], + name="test-agent", + extra_headers={"X-Custom": "header"}, + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + "npm_version": "1.2.3", + "registry_url": "https://registry.example.com", + "agent_setup": ["npm install", "npm run setup"], + }, + }, + name="test-agent", + extra_headers={"X-Custom": "header"}, + ) + + def test_create_from_npm_raises_when_source_provided(self, mock_client: Mock) -> None: + """Test create_from_npm raises ValueError when source is provided in params.""" + client = AgentOps(mock_client) + + with pytest.raises(ValueError, match="Cannot specify 'source' when using create_from_npm"): + client.create_from_npm( + package_name="@runloop/example-agent", + name="test-agent", + source={"type": "git", "git": {"repository": "https://github.com/example/repo"}}, + ) + + def test_create_from_pip(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_pip factory method.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_pip( + package_name="runloop-example-agent", + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "pip", + "pip": { + "package_name": "runloop-example-agent", + }, + }, + name="test-agent", + ) + + def test_create_from_pip_with_all_options(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_pip factory method with all optional parameters.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_pip( + package_name="runloop-example-agent", + pip_version="1.2.3", + registry_url="https://pypi.example.com", + agent_setup=["pip install extra-deps"], + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "pip", + "pip": { + "package_name": "runloop-example-agent", + "pip_version": "1.2.3", + "registry_url": "https://pypi.example.com", + "agent_setup": ["pip install extra-deps"], + }, + }, + name="test-agent", + ) + + def test_create_from_git(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_git factory method.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_git( + repository="https://github.com/example/agent-repo", + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "git", + "git": { + "repository": "https://github.com/example/agent-repo", + }, + }, + name="test-agent", + ) + + def test_create_from_git_with_all_options(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_git factory method with all optional parameters.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_git( + repository="https://github.com/example/agent-repo", + ref="develop", + agent_setup=["npm install", "npm run build"], + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "git", + "git": { + "repository": "https://github.com/example/agent-repo", + "ref": "develop", + "agent_setup": ["npm install", "npm run build"], + }, + }, + name="test-agent", + ) + + def test_create_from_object(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_object factory method.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_object( + object_id="obj_123", + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "object", + "object": { + "object_id": "obj_123", + }, + }, + name="test-agent", + ) + + def test_create_from_object_with_agent_setup(self, mock_client: Mock, agent_view: MockAgentView) -> None: + """Test create_from_object factory method with agent_setup.""" + mock_client.agents.create.return_value = agent_view + + client = AgentOps(mock_client) + agent = client.create_from_object( + object_id="obj_123", + agent_setup=["chmod +x setup.sh", "./setup.sh"], + name="test-agent", + ) + + assert isinstance(agent, Agent) + assert agent.id == "agent_123" + mock_client.agents.create.assert_called_once_with( + source={ + "type": "object", + "object": { + "object_id": "obj_123", + "agent_setup": ["chmod +x setup.sh", "./setup.sh"], + }, + }, + name="test-agent", + ) + + class TestRunloopSDK: """Tests for RunloopSDK class.""" @@ -660,6 +967,7 @@ def test_init(self) -> None: """Test RunloopSDK initialization.""" sdk = RunloopSDK(bearer_token="test-token") assert sdk.api is not None + assert isinstance(sdk.agent, AgentOps) assert isinstance(sdk.devbox, DevboxOps) assert isinstance(sdk.scorer, ScorerOps) assert isinstance(sdk.snapshot, SnapshotOps) diff --git a/tests/smoketests/sdk/README.md b/tests/smoketests/sdk/README.md index 66e6c9dc4..65e42d5e9 100644 --- a/tests/smoketests/sdk/README.md +++ b/tests/smoketests/sdk/README.md @@ -1,12 +1,12 @@ # SDK End-to-End Smoke Tests -Comprehensive end-to-end tests for the object-oriented Python SDK (`runloop_api_client.sdk`). These tests run against the real Runloop API to validate critical workflows including devboxes, blueprints, snapshots, and storage objects. +Comprehensive end-to-end tests for the object-oriented Python SDK (`runloop_api_client.sdk`). These tests run against the real Runloop API to validate critical workflows including agents, devboxes, blueprints, snapshots, and storage objects. ## Overview The Python SDK provides both synchronous and asynchronous interfaces: -- **Synchronous SDK**: `RunloopSDK` with `Devbox`, `Blueprint`, `Snapshot`, `StorageObject` -- **Asynchronous SDK**: `AsyncRunloopSDK` with `AsyncDevbox`, `AsyncBlueprint`, `AsyncSnapshot`, `AsyncStorageObject` +- **Synchronous SDK**: `RunloopSDK` with `Agent`, `Devbox`, `Blueprint`, `Snapshot`, `StorageObject` +- **Asynchronous SDK**: `AsyncRunloopSDK` with `AsyncAgent`, `AsyncDevbox`, `AsyncBlueprint`, `AsyncSnapshot`, `AsyncStorageObject` These tests ensure both interfaces work correctly in real-world scenarios. @@ -15,6 +15,17 @@ These tests ensure both interfaces work correctly in real-world scenarios. ### Infrastructure - `conftest.py` - Pytest fixtures for SDK client instances +### Agent Tests +- `test_agent.py` - Synchronous agent operations +- `test_async_agent.py` - Asynchronous agent operations + +**Test Coverage:** +- Agent lifecycle (create, get_info) +- Agent listing and retrieval +- Agent creation with metadata +- Agent creation with different source types (npm, git) +- Public/private agent configuration + ### Devbox Tests - `test_devbox.py` - Synchronous devbox operations - `test_async_devbox.py` - Asynchronous devbox operations @@ -74,23 +85,30 @@ export RUNLOOP_API_KEY=your_api_key_here ### Run All SDK Smoke Tests ```bash -RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/ +RUN_SMOKETESTS=1 uv run pytest -q -vv -n 20 -m smoketest tests/smoketests/sdk/ ``` ### Run Specific Test File ```bash -RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_devbox.py +RUN_SMOKETESTS=1 uv run pytest -q -vv -n 20 -m smoketest tests/smoketests/sdk/test_devbox.py ``` ### Run Specific Test ```bash -RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest -k "test_devbox_lifecycle" tests/smoketests/sdk/ +# match a test from anywhere in the directory +RUN_SMOKETESTS=1 uv run pytest -q -vv-m smoketest -k "test_devbox_lifecycle" tests/smoketests/sdk/ + +# specify the exact test +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_devbox.py::TestDevboxLifecycle::test_devbox_create + +# match a test from one file +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest -k "test_devbox_create" tests/smoketests/sdk/test_devbox.py ``` ### Run Only Sync or Async Tests ```bash # Sync tests only (files without 'async' prefix) -RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_devbox.py tests/smoketests/sdk/test_blueprint.py tests/smoketests/sdk/test_snapshot.py tests/smoketests/sdk/test_storage_object.py +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest $(ls tests/smoketests/sdk/*.py | grep -v async) # Async tests only (files with 'async' prefix) RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_async_*.py diff --git a/tests/smoketests/sdk/test_agent.py b/tests/smoketests/sdk/test_agent.py new file mode 100644 index 000000000..b6df4af20 --- /dev/null +++ b/tests/smoketests/sdk/test_agent.py @@ -0,0 +1,197 @@ +"""Synchronous SDK smoke tests for Agent operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAgentLifecycle: + """Test basic agent lifecycle operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_agent_create_basic(self, sdk_client: RunloopSDK) -> None: + """Test creating a basic agent.""" + name = unique_name("sdk-agent-test-basic") + agent = sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + assert agent is not None + assert agent.id is not None + assert len(agent.id) > 0 + + # Verify agent information + info = agent.get_info() + assert info.id == agent.id + assert info.name == name + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + # Currently agents don't have a delete method - they persist after tests + # Once implemented, add: agent.delete() + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_agent_get_info(self, sdk_client: RunloopSDK) -> None: + """Test retrieving agent information.""" + name = unique_name("sdk-agent-test-info") + agent = sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + info = agent.get_info() + + assert info.id == agent.id + assert info.name == name + assert info.create_time_ms > 0 + assert isinstance(info.is_public, bool) + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + +class TestAgentListing: + """Test agent listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_agents(self, sdk_client: RunloopSDK) -> None: + """Test listing agents.""" + agents = sdk_client.agent.list(limit=10) + + assert isinstance(agents, list) + # List might be empty, that's okay + assert len(agents) >= 0 + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_get_agent_by_id(self, sdk_client: RunloopSDK) -> None: + """Test retrieving agent by ID.""" + # Create an agent + created = sdk_client.agent.create( + name=unique_name("sdk-agent-test-retrieve"), + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + # Retrieve it by ID + retrieved = sdk_client.agent.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same agent + info = retrieved.get_info() + assert info.id == created.id + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_multiple_agents(self, sdk_client: RunloopSDK) -> None: + """Test listing multiple agents after creation.""" + source_config = { + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + } + + # Create multiple agents + agent1 = sdk_client.agent.create(name=unique_name("sdk-agent-test-list-1"), source=source_config) + agent2 = sdk_client.agent.create(name=unique_name("sdk-agent-test-list-2"), source=source_config) + agent3 = sdk_client.agent.create(name=unique_name("sdk-agent-test-list-3"), source=source_config) + + try: + # List agents + agents = sdk_client.agent.list(limit=100) + + assert isinstance(agents, list) + assert len(agents) >= 3 + + # Verify our agents are in the list + agent_ids = [a.id for a in agents] + assert agent1.id in agent_ids + assert agent2.id in agent_ids + assert agent3.id in agent_ids + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + # Should delete: agent1, agent2, agent3 + pass + + +class TestAgentCreationVariations: + """Test different agent creation scenarios.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_agent_with_source_npm(self, sdk_client: RunloopSDK) -> None: + """Test creating an agent with npm source.""" + name = unique_name("sdk-agent-test-npm") + + agent = sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + }, + }, + ) + + try: + assert agent.id is not None + info = agent.get_info() + assert info.name == name + assert info.source is not None + assert info.source.type == "npm" + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_agent_with_source_git(self, sdk_client: RunloopSDK) -> None: + """Test creating an agent with git source.""" + name = unique_name("sdk-agent-test-git") + + agent = sdk_client.agent.create( + name=name, + source={ + "type": "git", + "git": { + "repository": "https://github.com/runloop/example-agent", + "ref": "main", + }, + }, + ) + + try: + assert agent.id is not None + info = agent.get_info() + assert info.name == name + assert info.source is not None + assert info.source.type == "git" + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass diff --git a/tests/smoketests/sdk/test_async_agent.py b/tests/smoketests/sdk/test_async_agent.py new file mode 100644 index 000000000..255f54d05 --- /dev/null +++ b/tests/smoketests/sdk/test_async_agent.py @@ -0,0 +1,197 @@ +"""Asynchronous SDK smoke tests for Agent operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAsyncAgentLifecycle: + """Test basic async agent lifecycle operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_agent_create_basic(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a basic agent.""" + name = unique_name("sdk-async-agent-test-basic") + agent = await async_sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + assert agent is not None + assert agent.id is not None + assert len(agent.id) > 0 + + # Verify agent information + info = await agent.get_info() + assert info.id == agent.id + assert info.name == name + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + # Currently agents don't have a delete method - they persist after tests + # Once implemented, add: await agent.delete() + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_agent_get_info(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving agent information.""" + name = unique_name("sdk-async-agent-test-info") + agent = await async_sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + info = await agent.get_info() + + assert info.id == agent.id + assert info.name == name + assert info.create_time_ms > 0 + assert isinstance(info.is_public, bool) + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + +class TestAsyncAgentListing: + """Test async agent listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_agents(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing agents.""" + agents = await async_sdk_client.agent.list(limit=10) + + assert isinstance(agents, list) + # List might be empty, that's okay + assert len(agents) >= 0 + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_get_agent_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving agent by ID.""" + # Create an agent + created = await async_sdk_client.agent.create( + name=unique_name("sdk-async-agent-test-retrieve"), + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + }, + ) + + try: + # Retrieve it by ID + retrieved = async_sdk_client.agent.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same agent + info = await retrieved.get_info() + assert info.id == created.id + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_multiple_agents(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing multiple agents after creation.""" + source_config = { + "type": "npm", + "npm": { + "package_name": "@runloop/hello-world-agent", + }, + } + + # Create multiple agents + agent1 = await async_sdk_client.agent.create(name=unique_name("sdk-async-agent-test-list-1"), source=source_config) + agent2 = await async_sdk_client.agent.create(name=unique_name("sdk-async-agent-test-list-2"), source=source_config) + agent3 = await async_sdk_client.agent.create(name=unique_name("sdk-async-agent-test-list-3"), source=source_config) + + try: + # List agents + agents = await async_sdk_client.agent.list(limit=100) + + assert isinstance(agents, list) + assert len(agents) >= 3 + + # Verify our agents are in the list + agent_ids = [a.id for a in agents] + assert agent1.id in agent_ids + assert agent2.id in agent_ids + assert agent3.id in agent_ids + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + # Should delete: agent1, agent2, agent3 + pass + + +class TestAsyncAgentCreationVariations: + """Test different async agent creation scenarios.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_agent_with_source_npm(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating an agent with npm source.""" + name = unique_name("sdk-async-agent-test-npm") + + agent = await async_sdk_client.agent.create( + name=name, + source={ + "type": "npm", + "npm": { + "package_name": "@runloop/example-agent", + }, + }, + ) + + try: + assert agent.id is not None + info = await agent.get_info() + assert info.name == name + assert info.source is not None + assert info.source.type == "npm" + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_agent_with_source_git(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating an agent with git source.""" + name = unique_name("sdk-async-agent-test-git") + + agent = await async_sdk_client.agent.create( + name=name, + source={ + "type": "git", + "git": { + "repository": "https://github.com/runloop/example-agent", + "ref": "main", + }, + }, + ) + + try: + assert agent.id is not None + info = await agent.get_info() + assert info.name == name + assert info.source is not None + assert info.source.type == "git" + finally: + # TODO: Add agent cleanup once delete endpoint is implemented + pass diff --git a/uv.lock b/uv.lock index e5bd5b8fd..50fa85fae 100644 --- a/uv.lock +++ b/uv.lock @@ -777,7 +777,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -2098,7 +2098,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "0.69.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "anyio" },