diff --git a/CHANGELOG.md b/CHANGELOG.md index 25725c2..415f440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `client.agents` resource wrapping the public `/agents` API: `list`, `create`, + `get`, `update` (PATCH), `delete`, `list_models`, and `set_models`, with typed + `CustomAgent` / `CustomAgentSummary` / `LLMModelSummary` models (names chosen + to avoid colliding with the chat-thread `Agent`). + ### Changed - `__version__` is now resolved at runtime from installed package metadata (`importlib.metadata.version("cominty-sdk")`) instead of the removed diff --git a/README.md b/README.md index 3a249ce..72b15b8 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,25 @@ await client.threads.update(thread_id, name="Renamed", starred=True) await client.threads.archive(thread_id) ``` +### Manage agents + +`client.agents` creates and updates custom agents with the same API key used for +chat. Pass the returned `id` to `chat.start` as `agent_id`. + +```python +models = await client.agents.list_models() +agent = await client.agents.create( + name="Researcher", + instructions="Cite sources.", + scope="organization", + model_ids=[models[0].id], +) +await client.agents.update(agent.id, description="Org research helper") + +run = await client.chat.start(agent_id=agent.id, message="Summarize Q3.") +print(await run.text()) +``` + ## Examples Runnable scripts for each scenario live in [`examples/`](examples/): diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index 313c369..3ec486a 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -21,6 +21,14 @@ ServerError, StreamInterrupted, ) +from .models.agents import ( + CreateAgentParams, + CustomAgent, + CustomAgentSummary, + LLMModelSummary, + SetAgentModelsParams, + UpdateAgentParams, +) from .models.chat import ( Agent, ContentOrigin, @@ -69,16 +77,22 @@ "Agent", "ContentOrigin", "ConversationFile", + "CreateAgentParams", + "CustomAgent", + "CustomAgentSummary", "DisablableTool", "HumanMessage", + "LLMModelSummary", "Message", "MessageRole", "MessageStatus", "Question", + "SetAgentModelsParams", "ShareLink", "StartChatOptions", "StartChatParams", "Thread", "ThreadSummary", + "UpdateAgentParams", "UpdateThreadParams", ] diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index 566e5dd..85b4be5 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -9,6 +9,7 @@ from ._config import Config from ._transport import AsyncTransport from .models.chat import validate_user_id +from .resources.agents import AgentsResource from .resources.chat import ChatResource from .resources.threads import ThreadsResource @@ -53,6 +54,7 @@ def __init__( self._transport = AsyncTransport(self._config) self.chat = ChatResource(self._transport, user_id=self._config.user_id) self.threads = ThreadsResource(self._transport, user_id=self._config.user_id) + self.agents = AgentsResource(self._transport) @property def user_id(self) -> str: diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 805426a..1838d58 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -2,6 +2,14 @@ from __future__ import annotations +from .agents import ( + CreateAgentParams, + CustomAgent, + CustomAgentSummary, + LLMModelSummary, + SetAgentModelsParams, + UpdateAgentParams, +) from .chat import ( Agent, ContentOrigin, @@ -23,15 +31,21 @@ "Agent", "ContentOrigin", "ConversationFile", + "CreateAgentParams", + "CustomAgent", + "CustomAgentSummary", "DisablableTool", "HumanMessage", + "LLMModelSummary", "Message", "MessageRole", "MessageStatus", "Question", + "SetAgentModelsParams", "ShareLink", "StartChatOptions", "StartChatParams", "Thread", "ThreadSummary", + "UpdateAgentParams", ] diff --git a/src/cominty_sdk/models/agents.py b/src/cominty_sdk/models/agents.py new file mode 100644 index 0000000..9458c67 --- /dev/null +++ b/src/cominty_sdk/models/agents.py @@ -0,0 +1,84 @@ +"""Request and response models for the agents resource. + +Request models are strict (``strict=True, extra="forbid"``). Response models are +lenient (``extra="ignore"``). Named ``CustomAgent*`` to avoid colliding with the +chat-thread :class:`~.chat.Agent` (``{id, name}`` only). +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateAgentParams", + "UpdateAgentParams", + "SetAgentModelsParams", + "LLMModelSummary", + "CustomAgentSummary", + "CustomAgent", +] + +AgentScope = Literal["organization", "private"] + + +# --------------------------------------------------------------------------- # +# Request models (strict — reject unknown fields, no coercion) +# --------------------------------------------------------------------------- # +class CreateAgentParams(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + name: str + description: str | None = None + instructions: str | None = None + scope: AgentScope = "private" + execution_options: dict[str, Any] | None = None + allow_execution_options_override: bool = False + model_ids: list[str] | None = Field(default=None, min_length=1) + + +class UpdateAgentParams(BaseModel): + """Mutable agent fields. Only the fields you pass are sent (``exclude_none``).""" + + model_config = ConfigDict(strict=True, extra="forbid") + + name: str | None = None + description: str | None = None + instructions: str | None = None + execution_options: dict[str, Any] | None = None + allow_execution_options_override: bool | None = None + + +class SetAgentModelsParams(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + model_ids: list[str] = Field(min_length=1) + + +# --------------------------------------------------------------------------- # +# Response models (lenient — ignore unknown fields) +# --------------------------------------------------------------------------- # +class LLMModelSummary(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + name: str + + +class CustomAgentSummary(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + name: str + description: str | None + execution_options: dict[str, Any] | None + managed: bool + scope: str + owner: str + + +class CustomAgent(CustomAgentSummary): + instructions: str | None + allow_execution_options_override: bool + models: list[LLMModelSummary] diff --git a/src/cominty_sdk/resources/__init__.py b/src/cominty_sdk/resources/__init__.py index ab7f6b8..06fee5d 100644 --- a/src/cominty_sdk/resources/__init__.py +++ b/src/cominty_sdk/resources/__init__.py @@ -2,7 +2,8 @@ from __future__ import annotations +from .agents import AgentsResource from .chat import ChatResource from .threads import ThreadsResource -__all__ = ["ChatResource", "ThreadsResource"] +__all__ = ["AgentsResource", "ChatResource", "ThreadsResource"] diff --git a/src/cominty_sdk/resources/agents.py b/src/cominty_sdk/resources/agents.py new file mode 100644 index 0000000..c3558bb --- /dev/null +++ b/src/cominty_sdk/resources/agents.py @@ -0,0 +1,113 @@ +"""The agents resource: list, create, read, update, delete, and model assignment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import ValidationError + +from ..exceptions import InvalidParams +from ..models.agents import ( + CreateAgentParams, + CustomAgent, + CustomAgentSummary, + LLMModelSummary, + SetAgentModelsParams, + UpdateAgentParams, +) + +if TYPE_CHECKING: + from .._transport import AsyncTransport + +__all__ = ["AgentsResource"] + +AgentScope = Literal["organization", "private"] + + +class AgentsResource: + """Manage custom agents via ``/agents`` (API-key auth; no ``user_id`` query).""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def list(self) -> list[CustomAgentSummary]: + """List agents visible to the API key (``GET /agents``).""" + raw = await self._transport.request("GET", "/agents") + return [CustomAgentSummary.model_validate(item) for item in raw] + + async def create( + self, + *, + name: str, + description: str | None = None, + instructions: str | None = None, + scope: AgentScope = "private", + execution_options: dict[str, Any] | None = None, + allow_execution_options_override: bool = False, + model_ids: list[str] | None = None, + ) -> CustomAgent: + """Create a custom agent (``POST /agents``).""" + try: + params = CreateAgentParams( + name=name, + description=description, + instructions=instructions, + scope=scope, + execution_options=execution_options, + allow_execution_options_override=allow_execution_options_override, + model_ids=model_ids, + ) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="agents.create") from None + body = params.model_dump(mode="json", exclude_none=True) + raw = await self._transport.request("POST", "/agents", json_body=body) + return CustomAgent.model_validate(raw) + + async def get(self, agent_id: str) -> CustomAgent: + """Fetch one agent with instructions and models (``GET /agents/{id}``).""" + raw = await self._transport.request("GET", f"/agents/{agent_id}") + return CustomAgent.model_validate(raw) + + async def update( + self, + agent_id: str, + *, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + execution_options: dict[str, Any] | None = None, + allow_execution_options_override: bool | None = None, + ) -> CustomAgent: + """Partial update (``PATCH /agents/{id}``). Only passed fields are sent.""" + try: + params = UpdateAgentParams( + name=name, + description=description, + instructions=instructions, + execution_options=execution_options, + allow_execution_options_override=allow_execution_options_override, + ) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="agents.update") from None + body = params.model_dump(mode="json", exclude_none=True) + raw = await self._transport.request("PATCH", f"/agents/{agent_id}", json_body=body) + return CustomAgent.model_validate(raw) + + async def delete(self, agent_id: str) -> None: + """Delete a custom agent (``DELETE /agents/{id}``).""" + await self._transport.request("DELETE", f"/agents/{agent_id}") + + async def list_models(self) -> list[LLMModelSummary]: + """List models selectable for custom agents (``GET /agents/models``).""" + raw = await self._transport.request("GET", "/agents/models") + return [LLMModelSummary.model_validate(item) for item in raw] + + async def set_models(self, agent_id: str, *, model_ids: list[str]) -> CustomAgent: + """Replace an agent's model chain (``PUT /agents/{id}/models``).""" + try: + params = SetAgentModelsParams(model_ids=model_ids) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="agents.set_models") from None + body = params.model_dump(mode="json") + raw = await self._transport.request("PUT", f"/agents/{agent_id}/models", json_body=body) + return CustomAgent.model_validate(raw) diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py new file mode 100644 index 0000000..4fd792f --- /dev/null +++ b/tests/unit/test_agents.py @@ -0,0 +1,442 @@ +"""Exhaustive unit tests for ``client.agents``. + +Covers happy paths for every method, request shaping (token, no user_id, +partial bodies), client-side validation (must never hit the network), HTTP +error mapping, and response tolerance — the scenarios a human QA pass would +walk through against a mocked API. +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +import respx + +from cominty_sdk import ( + Agent, + APIError, + AsyncCominty, + AuthError, + CustomAgent, + CustomAgentSummary, + InvalidParams, + LLMModelSummary, + NotFoundError, + PermissionError, +) + +AGENT_ID = "agt_custom_1" +MODEL_ID = "mdl_1" +USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" + + +def _model_summary(model_id: str = MODEL_ID, name: str = "GPT") -> dict[str, Any]: + return {"id": model_id, "name": name} + + +def _agent_summary( + *, + agent_id: str = AGENT_ID, + name: str = "My agent", + scope: str = "private", + managed: bool = False, +) -> dict[str, Any]: + return { + "id": agent_id, + "name": name, + "description": "A helper", + "execution_options": None, + "managed": managed, + "scope": scope, + "owner": USER_ID, + } + + +def _agent_detail( + *, + name: str | None = None, + instructions: str = "Be helpful", + scope: str = "private", + models: list[dict[str, Any]] | None = None, + execution_options: dict[str, Any] | None = None, + allow_execution_options_override: bool = False, +) -> dict[str, Any]: + return { + **_agent_summary(name=name or "My agent", scope=scope), + "instructions": instructions, + "allow_execution_options_override": allow_execution_options_override, + "execution_options": execution_options, + "models": models if models is not None else [_model_summary()], + } + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # +async def test_list_returns_summaries(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.get("/agents").mock( + return_value=httpx.Response( + 200, + json=[ + _agent_summary(name="one", scope="organization"), + _agent_summary(name="two"), + ], + ) + ) + + agents = await client.agents.list() + + assert [a.name for a in agents] == ["one", "two"] + assert agents[0].scope == "organization" + assert all(isinstance(a, CustomAgentSummary) for a in agents) + assert route.calls.last.request.method == "GET" + assert route.calls.last.request.url.path == "/agents" + assert "user_id" not in route.calls.last.request.url.params + assert route.calls.last.request.headers["x-cominty-token"] == "test-token" + + +async def test_list_empty(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + mock_api.get("/agents").mock(return_value=httpx.Response(200, json=[])) + assert await client.agents.list() == [] + + +# --------------------------------------------------------------------------- # +# create +# --------------------------------------------------------------------------- # +async def test_create_sends_full_body_and_returns_detail( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/agents").mock( + return_value=httpx.Response( + 200, + json=_agent_detail( + scope="organization", + execution_options={"temperature": 0.2}, + allow_execution_options_override=True, + ), + ) + ) + + agent = await client.agents.create( + name="My agent", + description="A helper", + instructions="Be helpful", + scope="organization", + execution_options={"temperature": 0.2}, + allow_execution_options_override=True, + model_ids=[MODEL_ID, "mdl_2"], + ) + + request = route.calls.last.request + assert request.method == "POST" + assert request.url.path == "/agents" + assert json.loads(request.content) == { + "name": "My agent", + "description": "A helper", + "instructions": "Be helpful", + "scope": "organization", + "execution_options": {"temperature": 0.2}, + "allow_execution_options_override": True, + "model_ids": [MODEL_ID, "mdl_2"], + } + assert "user_id" not in request.url.params + assert request.headers["x-cominty-token"] == "test-token" + assert isinstance(agent, CustomAgent) + assert agent.id == AGENT_ID + assert agent.scope == "organization" + assert agent.allow_execution_options_override is True + assert agent.execution_options == {"temperature": 0.2} + assert agent.models[0].id == MODEL_ID + + +async def test_create_omits_none_optional_fields( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/agents").mock(return_value=httpx.Response(200, json=_agent_detail())) + + await client.agents.create(name="Minimal") + + assert json.loads(route.calls.last.request.content) == { + "name": "Minimal", + "scope": "private", + "allow_execution_options_override": False, + } + + +async def test_create_empty_model_ids_raises_before_request( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/agents") + + with pytest.raises(InvalidParams) as exc: + await client.agents.create(name="Bad", model_ids=[]) + + assert not route.called + assert len(mock_api.calls) == 0 + assert any(e["param"] == "model_ids" for e in exc.value.errors) + + +async def test_create_invalid_scope_raises_before_request( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/agents") + + with pytest.raises(InvalidParams) as exc: + await client.agents.create( + name="Bad", + scope="team", # type: ignore[arg-type] + ) + + assert not route.called + assert any(e["param"] == "scope" for e in exc.value.errors) + + +# --------------------------------------------------------------------------- # +# get +# --------------------------------------------------------------------------- # +async def test_get_returns_detail(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.get(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(200, json=_agent_detail()) + ) + + agent = await client.agents.get(AGENT_ID) + + assert isinstance(agent, CustomAgent) + assert agent.instructions == "Be helpful" + assert agent.owner == USER_ID + assert route.calls.last.request.method == "GET" + assert route.calls.last.request.url.path == f"/agents/{AGENT_ID}" + + +async def test_get_ignores_unknown_response_fields( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.get(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response( + 200, json={**_agent_detail(), "future_field": "ok", "models": []} + ) + ) + + agent = await client.agents.get(AGENT_ID) + + assert agent.models == [] + assert not hasattr(agent, "future_field") + + +# --------------------------------------------------------------------------- # +# update +# --------------------------------------------------------------------------- # +async def test_update_sends_patch_with_only_provided_fields( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.patch(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(200, json=_agent_detail(name="Renamed", instructions="New")) + ) + + agent = await client.agents.update(AGENT_ID, name="Renamed") + + request = route.calls.last.request + assert request.method == "PATCH" + assert request.url.path == f"/agents/{AGENT_ID}" + assert json.loads(request.content) == {"name": "Renamed"} + assert agent.name == "Renamed" + + +async def test_update_multiple_fields_and_execution_options( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.patch(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response( + 200, + json=_agent_detail( + instructions="Revise", + execution_options={"max_tokens": 100}, + allow_execution_options_override=True, + ), + ) + ) + + await client.agents.update( + AGENT_ID, + description="Updated", + instructions="Revise", + execution_options={"max_tokens": 100}, + allow_execution_options_override=True, + ) + + assert json.loads(route.calls.last.request.content) == { + "description": "Updated", + "instructions": "Revise", + "execution_options": {"max_tokens": 100}, + "allow_execution_options_override": True, + } + + +async def test_update_bool_false_is_sent(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.patch(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(200, json=_agent_detail()) + ) + + await client.agents.update(AGENT_ID, allow_execution_options_override=False) + + assert json.loads(route.calls.last.request.content) == { + "allow_execution_options_override": False + } + + +async def test_update_empty_body_when_nothing_passed( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.patch(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(200, json=_agent_detail()) + ) + + await client.agents.update(AGENT_ID) + + assert json.loads(route.calls.last.request.content) == {} + + +# --------------------------------------------------------------------------- # +# delete +# --------------------------------------------------------------------------- # +async def test_delete_sends_delete(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.delete(f"/agents/{AGENT_ID}").mock(return_value=httpx.Response(204)) + + result = await client.agents.delete(AGENT_ID) + + assert result is None + assert route.calls.last.request.method == "DELETE" + assert route.calls.last.request.url.path == f"/agents/{AGENT_ID}" + + +# --------------------------------------------------------------------------- # +# list_models / set_models +# --------------------------------------------------------------------------- # +async def test_list_models(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.get("/agents/models").mock( + return_value=httpx.Response(200, json=[_model_summary(), _model_summary("mdl_2", "Claude")]) + ) + + models = await client.agents.list_models() + + assert [m.id for m in models] == [MODEL_ID, "mdl_2"] + assert all(isinstance(m, LLMModelSummary) for m in models) + assert route.calls.last.request.method == "GET" + assert route.calls.last.request.url.path == "/agents/models" + + +async def test_set_models(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.put(f"/agents/{AGENT_ID}/models").mock( + return_value=httpx.Response( + 200, + json=_agent_detail(models=[_model_summary("mdl_2", "Claude")]), + ) + ) + + agent = await client.agents.set_models(AGENT_ID, model_ids=["mdl_2"]) + + request = route.calls.last.request + assert request.method == "PUT" + assert request.url.path == f"/agents/{AGENT_ID}/models" + assert json.loads(request.content) == {"model_ids": ["mdl_2"]} + assert agent.models[0].id == "mdl_2" + + +async def test_set_models_empty_raises_before_request( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put(f"/agents/{AGENT_ID}/models") + + with pytest.raises(InvalidParams) as exc: + await client.agents.set_models(AGENT_ID, model_ids=[]) + + assert not route.called + assert any(e["param"] == "model_ids" for e in exc.value.errors) + + +# --------------------------------------------------------------------------- # +# End-to-end lifecycle (mocked) — create → get → update → set_models → delete +# --------------------------------------------------------------------------- # +async def test_lifecycle_create_get_update_set_models_delete( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.post("/agents").mock(return_value=httpx.Response(200, json=_agent_detail())) + mock_api.get(f"/agents/{AGENT_ID}").mock(return_value=httpx.Response(200, json=_agent_detail())) + mock_api.patch(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(200, json=_agent_detail(name="Renamed")) + ) + mock_api.put(f"/agents/{AGENT_ID}/models").mock( + return_value=httpx.Response(200, json=_agent_detail(models=[_model_summary("mdl_2")])) + ) + mock_api.delete(f"/agents/{AGENT_ID}").mock(return_value=httpx.Response(204)) + + created = await client.agents.create(name="My agent", model_ids=[MODEL_ID]) + fetched = await client.agents.get(created.id) + updated = await client.agents.update(fetched.id, name="Renamed") + with_models = await client.agents.set_models(updated.id, model_ids=["mdl_2"]) + await client.agents.delete(with_models.id) + + assert created.id == AGENT_ID + assert fetched.instructions == "Be helpful" + assert updated.name == "Renamed" + assert with_models.models[0].id == "mdl_2" + assert len(mock_api.calls) == 5 + + +# --------------------------------------------------------------------------- # +# HTTP error mapping (same transport path as chat) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + ("status", "expected"), + [ + (401, AuthError), + (403, PermissionError), + (404, NotFoundError), + ], +) +async def test_http_errors_map_to_typed_exceptions( + client: AsyncCominty, + mock_api: respx.MockRouter, + status: int, + expected: type[Exception], +) -> None: + mock_api.get(f"/agents/{AGENT_ID}").mock( + return_value=httpx.Response(status, json={"detail": "Nope"}) + ) + + with pytest.raises(expected) as exc: + await client.agents.get(AGENT_ID) + + err = exc.value + assert isinstance(err, (AuthError, PermissionError, NotFoundError)) + assert err.status_code == status + assert err.detail == "Nope" + + +async def test_create_forbidden_maps_api_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.post("/agents").mock( + return_value=httpx.Response(400, json={"detail": "model_ids is not allowed for members"}) + ) + + with pytest.raises(APIError) as exc: + await client.agents.create(name="X", model_ids=[MODEL_ID]) + + assert exc.value.status_code == 400 + assert "model_ids" in str(exc.value) + + +# --------------------------------------------------------------------------- # +# Naming: chat Agent vs CustomAgent stay distinct +# --------------------------------------------------------------------------- # +def test_chat_agent_and_custom_agent_are_distinct_types() -> None: + chat_agent = Agent(id="agt_1", name="Support") + custom = CustomAgent.model_validate(_agent_detail()) + + assert type(chat_agent) is not type(custom) + assert chat_agent.id == "agt_1" + assert custom.models[0].id == MODEL_ID diff --git a/tests/unit/test_agents_workflows.py b/tests/unit/test_agents_workflows.py new file mode 100644 index 0000000..7d5f865 --- /dev/null +++ b/tests/unit/test_agents_workflows.py @@ -0,0 +1,518 @@ +"""Simulate full user journeys against a stateful fake ``/agents`` API. + +These are not per-method unit checks — each test walks a realistic sequence +(discover models → create → list/get → optional chat.start → update → +set_models → delete) and asserts the SDK never raises unexpectedly, and that +documented failure modes surface as typed errors. +""" + +from __future__ import annotations + +import json +import re +from typing import Any +from uuid import uuid4 + +import httpx +import pytest +import respx + +from cominty_sdk import ( + APIError, + AsyncCominty, + AuthError, + CustomAgent, + InvalidParams, + NotFoundError, + PermissionError, + ServerError, +) + +USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" +THREAD_ID = "11111111-1111-1111-1111-111111111111" +USER_MSG_ID = "22222222-2222-2222-2222-222222222222" +ASSISTANT_MSG_ID = "33333333-3333-3333-3333-333333333333" + +_MODELS = [ + {"id": "mdl_gpt", "name": "GPT"}, + {"id": "mdl_claude", "name": "Claude"}, +] + + +class FakeAgentsBackend: + """In-memory stand-in for ENG-1171 ``/agents`` routes.""" + + def __init__(self) -> None: + self.agents: dict[str, dict[str, Any]] = {} + self.auth_ok = True + + def _unauthorized(self) -> httpx.Response: + return httpx.Response(401, json={"detail": "Invalid token"}) + + def _check_auth(self) -> httpx.Response | None: + if not self.auth_ok: + return self._unauthorized() + return None + + def list_models(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + return httpx.Response(200, json=list(_MODELS)) + + def list_agents(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + summaries = [ + { + "id": a["id"], + "name": a["name"], + "description": a["description"], + "execution_options": a["execution_options"], + "managed": a["managed"], + "scope": a["scope"], + "owner": a["owner"], + } + for a in self.agents.values() + ] + return httpx.Response(200, json=summaries) + + def create(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + body = json.loads(request.content) + model_ids = body.get("model_ids") + if model_ids is not None and len(model_ids) == 0: + return httpx.Response(422, json={"detail": "model_ids too short"}) + if body.get("scope") == "organization" and body.get("_member"): + return httpx.Response(400, json={"detail": "Unauthorized scope"}) + + agent_id = f"agt_{uuid4().hex[:8]}" + models = [_m for _m in _MODELS if _m["id"] in (model_ids or [])] + agent = { + "id": agent_id, + "name": body["name"], + "description": body.get("description"), + "instructions": body.get("instructions"), + "execution_options": body.get("execution_options"), + "allow_execution_options_override": body.get("allow_execution_options_override", False), + "managed": False, + "scope": body.get("scope", "private"), + "owner": USER_ID, + "models": models, + } + self.agents[agent_id] = agent + return httpx.Response(200, json=agent) + + def get(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + agent_id = request.url.path.rsplit("/", 1)[-1] + agent = self.agents.get(agent_id) + if agent is None: + return httpx.Response(404, json={"detail": "Not Found"}) + return httpx.Response(200, json=agent) + + def update(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + agent_id = request.url.path.rsplit("/", 1)[-1] + agent = self.agents.get(agent_id) + if agent is None: + return httpx.Response(404, json={"detail": "Not Found"}) + body = json.loads(request.content or b"{}") + if "model_ids" in body: + return httpx.Response(422, json={"detail": "extra fields not permitted"}) + for key, value in body.items(): + agent[key] = value + return httpx.Response(200, json=agent) + + def set_models(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + # path: /agents/{id}/models + parts = request.url.path.strip("/").split("/") + agent_id = parts[1] + agent = self.agents.get(agent_id) + if agent is None: + return httpx.Response(404, json={"detail": "Not Found"}) + body = json.loads(request.content) + model_ids = body["model_ids"] + unknown = [mid for mid in model_ids if mid not in {m["id"] for m in _MODELS}] + if unknown: + return httpx.Response(404, json={"detail": "Bad model_ids"}) + agent["models"] = [m for m in _MODELS if m["id"] in model_ids] + # preserve order from request + by_id = {m["id"]: m for m in _MODELS} + agent["models"] = [by_id[mid] for mid in model_ids] + return httpx.Response(200, json=agent) + + def delete(self, request: httpx.Request) -> httpx.Response: + if blocked := self._check_auth(): + return blocked + agent_id = request.url.path.rsplit("/", 1)[-1] + if agent_id not in self.agents: + return httpx.Response(404, json={"detail": "Not Found"}) + del self.agents[agent_id] + return httpx.Response(204) + + +def _mount(mock_api: respx.MockRouter, backend: FakeAgentsBackend) -> None: + mock_api.get("/agents/models").mock(side_effect=backend.list_models) + mock_api.get("/agents").mock(side_effect=backend.list_agents) + mock_api.post("/agents").mock(side_effect=backend.create) + mock_api.get(url__regex=r"/agents/[^/]+$").mock(side_effect=backend.get) + mock_api.patch(url__regex=r"/agents/[^/]+$").mock(side_effect=backend.update) + mock_api.put(url__regex=r"/agents/[^/]+/models$").mock(side_effect=backend.set_models) + mock_api.delete(url__regex=r"/agents/[^/]+$").mock(side_effect=backend.delete) + + +def _thread_for_agent(agent_id: str, agent_name: str) -> dict[str, Any]: + return { + "id": THREAD_ID, + "name": "Workflow thread", + "created_at": "2026-06-28T10:00:00Z", + "live": True, + "agent": {"id": agent_id, "name": agent_name}, + "starred": False, + "project_id": None, + "messages": [ + { + "id": USER_MSG_ID, + "thread_id": THREAD_ID, + "role": "user", + "content": "hi", + "questions": None, + "live": False, + "status": "success", + "events": None, + "structured_output": None, + "files": [], + }, + { + "id": ASSISTANT_MSG_ID, + "thread_id": THREAD_ID, + "role": "assistant", + "content": "", + "questions": None, + "live": True, + "status": "pending", + "events": None, + "structured_output": None, + "files": [], + }, + ], + } + + +# --------------------------------------------------------------------------- # +# Happy-path workflows +# --------------------------------------------------------------------------- # +async def test_workflow_org_admin_full_lifecycle( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + """Discover models → create org agent → list/get → update → set_models → delete.""" + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + models = await client.agents.list_models() + assert [m.id for m in models] == ["mdl_gpt", "mdl_claude"] + + agent = await client.agents.create( + name="Researcher", + description="Org research", + instructions="Cite sources.", + scope="organization", + execution_options={"temperature": 0.1}, + allow_execution_options_override=True, + model_ids=[models[0].id], + ) + assert isinstance(agent, CustomAgent) + assert agent.scope == "organization" + assert agent.models[0].id == "mdl_gpt" + + listed = await client.agents.list() + assert len(listed) == 1 + assert listed[0].id == agent.id + + fetched = await client.agents.get(agent.id) + assert fetched.instructions == "Cite sources." + assert fetched.owner == USER_ID + + updated = await client.agents.update( + agent.id, + description="Updated blurb", + instructions="Cite sources. Be brief.", + ) + assert updated.description == "Updated blurb" + assert updated.instructions == "Cite sources. Be brief." + # untouched fields survive + assert updated.scope == "organization" + assert updated.models[0].id == "mdl_gpt" + + reassigned = await client.agents.set_models(agent.id, model_ids=["mdl_claude", "mdl_gpt"]) + assert [m.id for m in reassigned.models] == ["mdl_claude", "mdl_gpt"] + + await client.agents.delete(agent.id) + assert backend.agents == {} + + with pytest.raises(NotFoundError): + await client.agents.get(agent.id) + + +async def test_workflow_minimal_private_then_chat( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + """Create private agent with defaults → chat.start with its id → delete.""" + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Scratch") + assert agent.scope == "private" + assert agent.models == [] + assert agent.allow_execution_options_override is False + + mock_api.post("/chat").mock( + return_value=httpx.Response(200, json=_thread_for_agent(agent.id, agent.name)) + ) + + run = await client.chat.start(agent_id=agent.id, message="hello") + assert run.thread.agent.id == agent.id + assert run.thread.agent.name == "Scratch" + + await client.agents.delete(agent.id) + with pytest.raises(NotFoundError): + await client.agents.get(agent.id) + + +async def test_workflow_create_without_models_then_attach( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Later models") + assert agent.models == [] + + with_models = await client.agents.set_models(agent.id, model_ids=["mdl_gpt"]) + assert [m.id for m in with_models.models] == ["mdl_gpt"] + + again = await client.agents.get(agent.id) + assert again.models[0].id == "mdl_gpt" + + await client.agents.delete(agent.id) + + +async def test_workflow_two_agents_list_and_selective_delete( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + a = await client.agents.create(name="Alpha", model_ids=["mdl_gpt"]) + b = await client.agents.create(name="Beta", scope="organization", model_ids=["mdl_claude"]) + + names = {x.name for x in await client.agents.list()} + assert names == {"Alpha", "Beta"} + + await client.agents.delete(a.id) + remaining = await client.agents.list() + assert len(remaining) == 1 + assert remaining[0].id == b.id + + await client.agents.delete(b.id) + assert await client.agents.list() == [] + + +async def test_workflow_partial_updates_accumulate( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create( + name="Mutable", + instructions="v1", + execution_options={"temperature": 0.5}, + ) + + await client.agents.update(agent.id, name="Mutable v2") + mid = await client.agents.get(agent.id) + assert mid.name == "Mutable v2" + assert mid.instructions == "v1" + + await client.agents.update( + agent.id, + instructions="v2", + allow_execution_options_override=True, + ) + final = await client.agents.get(agent.id) + assert final.name == "Mutable v2" + assert final.instructions == "v2" + assert final.allow_execution_options_override is True + assert final.execution_options == {"temperature": 0.5} + + await client.agents.delete(agent.id) + + +# --------------------------------------------------------------------------- # +# Failure workflows — typed errors, no silent success +# --------------------------------------------------------------------------- # +async def test_workflow_client_validation_never_hits_network( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + calls_before = len(mock_api.calls) + + with pytest.raises(InvalidParams): + await client.agents.create(name="Bad", model_ids=[]) + with pytest.raises(InvalidParams): + await client.agents.create( + name="Bad", + scope="team", # type: ignore[arg-type] + ) + with pytest.raises(InvalidParams): + await client.agents.set_models("agt_x", model_ids=[]) + + assert len(mock_api.calls) == calls_before + assert backend.agents == {} + + +async def test_workflow_missing_agent_errors( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + missing = "agt_does_not_exist" + + with pytest.raises(NotFoundError): + await client.agents.get(missing) + with pytest.raises(NotFoundError): + await client.agents.update(missing, name="Nope") + with pytest.raises(NotFoundError): + await client.agents.set_models(missing, model_ids=["mdl_gpt"]) + with pytest.raises(NotFoundError): + await client.agents.delete(missing) + + +async def test_workflow_delete_twice_second_raises( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Once") + await client.agents.delete(agent.id) + with pytest.raises(NotFoundError): + await client.agents.delete(agent.id) + + +async def test_workflow_bad_model_ids_on_set_models( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Models") + with pytest.raises(NotFoundError) as exc: + await client.agents.set_models(agent.id, model_ids=["mdl_unknown"]) + assert exc.value.detail == "Bad model_ids" + + # store unchanged + assert (await client.agents.get(agent.id)).models == [] + await client.agents.delete(agent.id) + + +async def test_workflow_auth_failure_mid_journey( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Alive") + backend.auth_ok = False + + with pytest.raises(AuthError): + await client.agents.list() + with pytest.raises(AuthError): + await client.agents.get(agent.id) + with pytest.raises(AuthError): + await client.agents.update(agent.id, name="X") + with pytest.raises(AuthError): + await client.agents.delete(agent.id) + + backend.auth_ok = True + still = await client.agents.get(agent.id) + assert still.name == "Alive" + await client.agents.delete(agent.id) + + +async def test_workflow_permission_and_server_errors( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + """When the API rejects a call, the SDK surfaces typed errors (no crash).""" + mock_api.get("/agents").mock(return_value=httpx.Response(403, json={"detail": "Forbidden"})) + with pytest.raises(PermissionError): + await client.agents.list() + + mock_api.post("/agents").mock( + return_value=httpx.Response(400, json={"detail": "model_ids is not allowed for members"}) + ) + with pytest.raises(APIError) as exc: + await client.agents.create(name="Member", model_ids=["mdl_gpt"]) + assert exc.value.status_code == 400 + + mock_api.get("/agents/models").mock( + return_value=httpx.Response(503, json={"detail": "Unavailable"}) + ) + with pytest.raises(ServerError): + await client.agents.list_models() + + +async def test_workflow_recover_after_failed_update( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + """A failed update must not corrupt subsequent successful calls.""" + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + agent = await client.agents.create(name="Stable", instructions="keep") + + # Force next PATCH to 500 via a one-shot override + call_count = {"n": 0} + + def flaky_update(request: httpx.Request) -> httpx.Response: + call_count["n"] += 1 + if call_count["n"] == 1: + return httpx.Response(500, json={"detail": "boom"}) + return backend.update(request) + + mock_api.patch(url__regex=r"/agents/[^/]+$").mock(side_effect=flaky_update) + + with pytest.raises(ServerError): + await client.agents.update(agent.id, name="Should fail") + + ok = await client.agents.update(agent.id, name="Recovered") + assert ok.name == "Recovered" + assert ok.instructions == "keep" + await client.agents.delete(agent.id) + + +# --------------------------------------------------------------------------- # +# Guard: path routing (list_models vs get-by-id) +# --------------------------------------------------------------------------- # +async def test_workflow_list_models_not_confused_with_get( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + backend = FakeAgentsBackend() + _mount(mock_api, backend) + + models = await client.agents.list_models() + assert len(models) == 2 + + # Creating an agent whose id looks nothing like "models" + agent = await client.agents.create(name="Normal") + assert not re.search(r"models", agent.id) + fetched = await client.agents.get(agent.id) + assert fetched.name == "Normal" + await client.agents.delete(agent.id)