From 368b7e60058dc5e074c8973c85de55d66380d27f Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:44:10 +0200 Subject: [PATCH 1/2] feat: introduce capabilities for chat.start and chat.send ref ENG-996 - Added `thread_capabilities` to `chat.start` for overriding agent capabilities for the thread's lifetime. - Introduced `message_scope` in `chat.start` and `chat.send` to enable/disable capabilities or narrow their allowlist for individual messages. - Removed deprecated parameters: `source_ids`, `document_ids`, and `disabled_tools` in favor of the new capabilities structure. - Updated documentation and examples to reflect these changes, including new usage patterns for capabilities. --- CHANGELOG.md | 43 +++- README.md | 84 ++++++- examples/07_custom_agent.py | 8 +- examples/08_mcp_linear.py | 14 +- examples/11_capabilities.py | 66 +++++ examples/README.md | 1 + src/cominty_sdk/__init__.py | 38 ++- src/cominty_sdk/exceptions.py | 2 +- src/cominty_sdk/models/__init__.py | 38 ++- src/cominty_sdk/models/capabilities.py | 256 +++++++++++++++++++ src/cominty_sdk/models/chat.py | 31 +-- src/cominty_sdk/resources/chat.py | 56 ++--- tests/unit/test_capabilities.py | 331 +++++++++++++++++++++++++ tests/unit/test_chat_send.py | 39 ++- tests/unit/test_chat_start.py | 141 +++++------ 15 files changed, 989 insertions(+), 159 deletions(-) create mode 100644 examples/11_capabilities.py create mode 100644 src/cominty_sdk/models/capabilities.py create mode 100644 tests/unit/test_capabilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9cce2..f3a7d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-10-15 + +### Added +- Capabilities. `chat.start(..., thread_capabilities=AgentCapabilities(...))` overrides some + of the agent's capabilities for the thread, frozen for its lifetime (not accepted on + `chat.send`). `message_scope=MessageScope(...)` on `start` and `send` turns a + capability on or off, or narrows its allowlist, for one message (sent as `message.scope`). + Every capability is an explicit object with a required activation: + `WebCapability`, `IndexedDocumentsCapability`, `McpCapability`, `SkillsCapability`, + `ImageGenerationCapability`, `VideoGenerationCapability`, `MusicGenerationCapability` + (`"always"`, `"on_request"` or `"never"`; skills take `"always"` or `"never"`). Message + scopes are `WebScope`, `IndexedDocumentsScope`, `McpScope`, `ImageGenerationScope`, + `VideoGenerationScope`, `MusicGenerationScope`, each with a required `enabled`. There are no + shortcuts such as `web=True` or `web="on_request"`. Filters (`source_ids`, `document_ids`, + `connections`, `include`) default to the new `ALL` (`"*"`, no restriction), which can also be + passed explicitly; `None`, `[]` and `"*"` inside a list raise `InvalidParams`, and so does a + filter combined with `"never"` or `enabled=False`. +- `MemoryCapability(activation="always", namespace=...)` / `MemoryCapability(activation="never")` + in `AgentCapabilities(memory=...)`: sets the thread's memory bag (1 to 128 characters), or + runs it without memory. Without a `memory` entry, the thread uses the agent's namespace, or + no memory if the agent has none. +- New exports: `ALL`, `AgentCapabilities`, `MessageScope`, the `*Capability` and `*Scope` + classes above, and `MemoryCapability`. +- `examples/11_capabilities.py`. + +### Removed +- BREAKING: `source_ids`, `document_ids` and `disabled_tools` on `chat.start` / `chat.send`, + plus `DisablableTool`, `HumanMessage.source_ids/document_ids/disabled_tools`. Migrate to + `message_scope`: + `disabled_tools=["web"]` -> `MessageScope(web=WebScope(enabled=False))`; + `"company_documents"` -> `indexed_documents=IndexedDocumentsScope(enabled=False)`; + `"mcp:*"` -> `mcp=McpScope(enabled=False)`; + `"mcp:notion"` -> `mcp=McpScope(enabled=True, connections=[])`; + `source_ids=[101]` -> `indexed_documents=IndexedDocumentsScope(enabled=True, source_ids=[101])`; + `document_ids=[...]` -> `IndexedDocumentsScope(enabled=True, document_ids=[...])`. +- BREAKING: `memory_namespace` on `chat.start`, and `StartChatOptions.memory_namespace`. The + memory bag is now a capability, like the others. Migrate: + `memory_namespace="support-bot"` -> + `thread_capabilities=AgentCapabilities(memory=MemoryCapability(activation="always", namespace="support-bot"))`. + ## [0.5.0] - 2026-10-07 ### Added @@ -151,7 +191,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial async client `AsyncCominty` covering threads, chat, messages (send/stream/export/cancel), files (upload/download), usage, and API tokens. -[Unreleased]: https://github.com/cominty/python-sdk/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/cominty/python-sdk/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/cominty/python-sdk/compare/v0.5.0...v1.0.0 [0.5.0]: https://github.com/cominty/python-sdk/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/cominty/python-sdk/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/cominty/python-sdk/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 8d8c0d3..82c175f 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,81 @@ A few other things worth knowing: - `content` may be an empty string; there's no minimum length. - `memory.delete()` is not idempotent: deleting an already-deleted path raises `NotFoundError`, not a repeated success. -- `chat.start(..., memory_namespace=...)` attaches a thread to a bag; the - bag is frozen once the thread starts (see [Message parameters](#message-parameters)). +- A thread is attached to a bag with `MemoryCapability` in `chat.start(thread_capabilities=...)`; + the bag is frozen once the thread starts (see [Capabilities](#capabilities)). + +### Capabilities + +A capability is something the agent may use: `web`, `indexed_documents`, `mcp`, `skills`, +`image_generation`, `video_generation`, `music_generation`, plus `memory`. Each one is an +explicit object, and the keyword name tells you the scope: + +```python +from cominty_sdk import ( + ALL, + AgentCapabilities, + ImageGenerationCapability, + ImageGenerationScope, + IndexedDocumentsCapability, + IndexedDocumentsScope, + McpCapability, + McpScope, + MemoryCapability, + MessageScope, + SkillsCapability, + WebCapability, + WebScope, +) + +run = await client.chat.start( + agent_id=AGENT_ID, + message="Summarize every board deck", + thread_capabilities=AgentCapabilities( # frozen for the thread, start only + web=WebCapability(activation="always"), + # document_ids omitted: defaults to ALL + indexed_documents=IndexedDocumentsCapability(activation="always", source_ids=[101]), + # explicit ALL, same as omitting connections + mcp=McpCapability(activation="on_request", connections=ALL), + skills=SkillsCapability(activation="always"), + image_generation=ImageGenerationCapability(activation="on_request"), + memory=MemoryCapability(activation="always", namespace="support-bot"), + ), + message_scope=MessageScope( # this message only + image_generation=ImageGenerationScope(enabled=True) + ), +) + +await client.chat.send( + run.thread.id, + agent_id=AGENT_ID, + message="Only deck 2024", + message_scope=MessageScope( + indexed_documents=IndexedDocumentsScope(enabled=True, source_ids=[101]), + mcp=McpScope(enabled=True, connections=["linear"]), + web=WebScope(enabled=False), + ), +) +``` + +- There are no shortcuts: no `web=True`, no `web="on_request"`. Thread capabilities are + `Capability(activation=...)` objects, message scopes are `Scope(enabled=...)` + objects, and the activation is always required. +- Activation is `"always"` (on by default), `"on_request"` (off until a message enables it) or + `"never"` (not installed). `SkillsCapability` and `MemoryCapability` have no `"on_request"`. + A capability you do not pass keeps the agent's value. +- Filters (`source_ids`, `document_ids`, `connections`, `include`) default to `ALL` (the string + `"*"`, no restriction), so passing `ALL` only makes the intent explicit. A capability you send + replaces the stored one, so an omitted filter resets to `ALL`. Otherwise pass a non-empty + list: `None`, `[]` and a `"*"` inside a list raise `InvalidParams`. +- `"never"` and `enabled=False` send nothing else: passing a filter with them raises + `InvalidParams`. +- `MemoryCapability(activation="always", namespace=...)` attaches the thread to a memory bag, + `MemoryCapability(activation="never")` runs it without memory. A message has no memory scope. + Without a `memory` entry, the thread uses the agent's namespace, or no memory if the agent + has none. The bag is frozen for the thread: a follow-up cannot change it. +- `MessageScope` has no `skills` or `memory`. +- A message can only narrow what the thread allows. The server answers 422 (`APIError`) + when it widens an allowlist or targets a capability the thread does not have. ## Examples @@ -275,6 +348,7 @@ Runnable scripts for each scenario live in [`examples/`](examples/): | [`07_custom_agent.py`](examples/07_custom_agent.py) | Call a custom managed agent (your own model + instructions) | | [`08_mcp_linear.py`](examples/08_mcp_linear.py) | Custom agent pulls live context from an MCP server (Linear) | | [`10_max_steps.py`](examples/10_max_steps.py) | Cap tool rounds per message, then continue past the cap | +| [`11_capabilities.py`](examples/11_capabilities.py) | Thread and message capabilities | They render colored, aligned output with [`rich`](https://github.com/Textualize/rich), which ships in the dev extras: @@ -295,10 +369,8 @@ Both `chat.start` and `chat.send` accept: | `message` | `str` | **Required.** The user's message (max 30,000 chars). | | `name` | `str` | `start` only: names the new thread. | | `file_ids` | `list[str]` | Attach previously-uploaded files (max 5). | -| `source_ids` | `list[int]` | Restrict retrieval to specific knowledge sources. | -| `document_ids` | `list[str]` | Restrict retrieval to specific documents. | -| `disabled_tools` | `list[str]` | Turn tools off: `"web"`, `"company_documents"`, `"mcp:"`, or `"mcp:*"` for all MCP. | -| `memory_namespace` | `str` | `start` only: the memory bag for this thread (max 128 chars), frozen for its lifetime. A follow-up cannot change it. | +| `thread_capabilities` | `AgentCapabilities` | `start` only: capability override, frozen for the thread. Includes the memory bag (`MemoryCapability`). | +| `message_scope` | `MessageScope` | Turn capabilities on/off or narrow them for this message (sent as `message.scope`). | | `max_steps` | `int` | Cap on tool rounds for **this message** (integer `>= 1`). Per message, not per thread. Unset (or `SERVER_DEFAULT`) means the server default, 60 today. | Invalid values raise `InvalidParams` **before** any request is sent. diff --git a/examples/07_custom_agent.py b/examples/07_custom_agent.py index 600a558..b8d1960 100644 --- a/examples/07_custom_agent.py +++ b/examples/07_custom_agent.py @@ -19,6 +19,8 @@ import _pretty as pretty from _shared import CUSTOM_AGENT_ID, make_client +from cominty_sdk import IndexedDocumentsScope, McpScope, MessageScope, WebScope + # Deliberately dense and technical: the agent's job is to make this legible to # a non-technical executive, in French. TECHNICAL_INPUT = """\ @@ -50,7 +52,11 @@ async def main() -> None: message=TECHNICAL_INPUT, # Pure transformation: no tools needed, so turn them off for speed # and determinism. - disabled_tools=["web", "company_documents", "mcp:*"], + message_scope=MessageScope( + web=WebScope(enabled=False), + indexed_documents=IndexedDocumentsScope(enabled=False), + mcp=McpScope(enabled=False), + ), ) async for event in run: diff --git a/examples/08_mcp_linear.py b/examples/08_mcp_linear.py index d1f8329..084e84e 100644 --- a/examples/08_mcp_linear.py +++ b/examples/08_mcp_linear.py @@ -12,9 +12,10 @@ - The Linear MCP server is connected to your org / available to the agent. - The custom agent has tools (MCP) enabled. -Tool control is by *exclusion*: tools are on by default and you disable what you -don't want. Here we disable web search and company documents so the agent draws -context only from MCP (Linear). Watch the stream for the Linear tool calls. +Capability control is per message: capabilities are on by default and you turn off +what you don't want with ``MessageScope``. Here we turn off web search and +indexed documents so the agent draws context only from MCP (Linear). Watch the +stream for the Linear tool calls. """ from __future__ import annotations @@ -24,7 +25,7 @@ import _pretty as pretty from _shared import CUSTOM_AGENT_ID, make_client -from cominty_sdk import events +from cominty_sdk import IndexedDocumentsScope, MessageScope, WebScope, events REQUEST = ( "Using Linear, list the tasks assigned to Hiroshi in the current sprint. " @@ -48,7 +49,10 @@ async def main() -> None: agent_id=CUSTOM_AGENT_ID, message=REQUEST, # Keep MCP (Linear) ON; cut the rest so context comes only from Linear. - disabled_tools=["web", "company_documents"], + message_scope=MessageScope( + web=WebScope(enabled=False), + indexed_documents=IndexedDocumentsScope(enabled=False), + ), ) # The stream surfaces the Linear MCP calls as ToolCall events. diff --git a/examples/11_capabilities.py b/examples/11_capabilities.py new file mode 100644 index 0000000..e6b67c7 --- /dev/null +++ b/examples/11_capabilities.py @@ -0,0 +1,66 @@ +"""Control what the agent can use. + + python examples/11_capabilities.py + +Two scopes, one rule: the keyword name tells you the scope. + +- ``thread_capabilities``: set when the thread starts, frozen for its lifetime. +- ``message_scope``: turn a capability on/off or narrow it for one message. + +Every capability is an explicit object (``WebCapability``, ``McpCapability``, ...) +and its activation is always spelled out. Filters (``source_ids``, ``connections``, +...) default to ``ALL``, so passing ``ALL`` only makes the intent explicit. +""" + +from __future__ import annotations + +import asyncio + +import _pretty as pretty +from _shared import AGENT_ID, make_client + +from cominty_sdk import ( + ALL, + AgentCapabilities, + ImageGenerationCapability, + IndexedDocumentsCapability, + IndexedDocumentsScope, + McpCapability, + MemoryCapability, + MessageScope, + WebCapability, + WebScope, +) + + +async def main() -> None: + async with make_client() as client: + run = await client.chat.start( + agent_id=AGENT_ID, + message="What is the VAT rate for food in France?", + thread_capabilities=AgentCapabilities( + web=WebCapability(activation="always"), + # source_ids and document_ids omitted: both default to ALL. + indexed_documents=IndexedDocumentsCapability(activation="on_request"), + # Explicit ALL, same as leaving connections out. + mcp=McpCapability(activation="on_request", connections=ALL), + image_generation=ImageGenerationCapability(activation="never"), + memory=MemoryCapability(activation="always", namespace="vat-demo"), + ), + ) + pretty.answer(await run.text()) + + follow_up = await client.chat.send( + run.thread.id, + agent_id=AGENT_ID, + message="Now answer from our documents only.", + message_scope=MessageScope( + web=WebScope(enabled=False), + indexed_documents=IndexedDocumentsScope(enabled=True), + ), + ) + pretty.answer(await follow_up.text()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md index f13f5a1..aba38fb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,5 +36,6 @@ python examples/01_stream_events.py | [`08_mcp_linear.py`](08_mcp_linear.py) | Custom agent pulls live context from the Linear MCP server | | [`09_memory.py`](09_memory.py) | Create, list, read, update, and delete a memory file | | [`10_max_steps.py`](10_max_steps.py) | Cap tool rounds per message with `max_steps`, then continue past the cap | +| [`11_capabilities.py`](11_capabilities.py) | Thread and message capabilities | > Shared client setup lives in [`_shared.py`](_shared.py). \ No newline at end of file diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index 43e87e8..013d3ad 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -19,12 +19,30 @@ ServerError, StreamInterrupted, ) +from .models.capabilities import ( + ALL, + AgentCapabilities, + ImageGenerationCapability, + ImageGenerationScope, + IndexedDocumentsCapability, + IndexedDocumentsScope, + McpCapability, + McpScope, + MemoryCapability, + MessageScope, + MusicGenerationCapability, + MusicGenerationScope, + SkillsCapability, + VideoGenerationCapability, + VideoGenerationScope, + WebCapability, + WebScope, +) from .models.chat import ( SERVER_DEFAULT, Agent, ContentOrigin, ConversationFile, - DisablableTool, HumanMessage, MaxSteps, Message, @@ -73,10 +91,26 @@ "InvalidParam", "InvalidParams", # models + "ALL", + "AgentCapabilities", + "ImageGenerationCapability", + "ImageGenerationScope", + "IndexedDocumentsCapability", + "IndexedDocumentsScope", + "McpCapability", + "McpScope", + "MemoryCapability", + "MessageScope", + "MusicGenerationCapability", + "MusicGenerationScope", + "SkillsCapability", + "VideoGenerationCapability", + "VideoGenerationScope", + "WebCapability", + "WebScope", "Agent", "ContentOrigin", "ConversationFile", - "DisablableTool", "HumanMessage", "MaxSteps", "Message", diff --git a/src/cominty_sdk/exceptions.py b/src/cominty_sdk/exceptions.py index bf6e09c..1831afd 100644 --- a/src/cominty_sdk/exceptions.py +++ b/src/cominty_sdk/exceptions.py @@ -166,7 +166,7 @@ class InvalidParam(TypedDict): """One offending parameter in an :class:`InvalidParams` error.""" param: str - """The public argument that failed, e.g. ``"disabled_tools[0]"``.""" + """The public argument that failed, e.g. ``"file_ids[0]"``.""" message: str """Why it failed (the underlying validation message).""" input: Any diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 39bdb5b..2279cb4 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -1,11 +1,29 @@ from __future__ import annotations +from .capabilities import ( + ALL, + AgentCapabilities, + ImageGenerationCapability, + ImageGenerationScope, + IndexedDocumentsCapability, + IndexedDocumentsScope, + McpCapability, + McpScope, + MemoryCapability, + MessageScope, + MusicGenerationCapability, + MusicGenerationScope, + SkillsCapability, + VideoGenerationCapability, + VideoGenerationScope, + WebCapability, + WebScope, +) from .chat import ( SERVER_DEFAULT, Agent, ContentOrigin, ConversationFile, - DisablableTool, HumanMessage, MaxSteps, Message, @@ -22,10 +40,26 @@ from .memory import MemoryFileCreate, MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate __all__ = [ + "ALL", + "AgentCapabilities", + "ImageGenerationCapability", + "ImageGenerationScope", + "IndexedDocumentsCapability", + "IndexedDocumentsScope", + "McpCapability", + "McpScope", + "MemoryCapability", + "MessageScope", + "MusicGenerationCapability", + "MusicGenerationScope", + "SkillsCapability", + "VideoGenerationCapability", + "VideoGenerationScope", + "WebCapability", + "WebScope", "Agent", "ContentOrigin", "ConversationFile", - "DisablableTool", "HumanMessage", "MaxSteps", "Message", diff --git a/src/cominty_sdk/models/capabilities.py b/src/cominty_sdk/models/capabilities.py new file mode 100644 index 0000000..f792de0 --- /dev/null +++ b/src/cominty_sdk/models/capabilities.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from typing import Annotated, Final, Generic, Literal, TypeVar, Union, cast + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + ModelWrapValidatorHandler, + SerializerFunctionWrapHandler, + ValidationError, + ValidationInfo, + field_validator, + model_serializer, + model_validator, +) +from typing_extensions import Self, TypeAlias + +from cominty_sdk.exceptions import InvalidParams + +from .memory import validate_memory_namespace + +__all__ = [ + "ALL", + "AgentCapabilities", + "WebCapability", + "IndexedDocumentsCapability", + "McpCapability", + "SkillsCapability", + "ImageGenerationCapability", + "VideoGenerationCapability", + "MusicGenerationCapability", + "MemoryCapability", + "MessageScope", + "WebScope", + "IndexedDocumentsScope", + "McpScope", + "ImageGenerationScope", + "VideoGenerationScope", + "MusicGenerationScope", +] + +_T = TypeVar("_T") +_ActivationT = TypeVar("_ActivationT", bound=str) + + +ALL: Final[Literal["*"]] = "*" +"""Allowlist marker meaning "no restriction": every id the end user can access. It is +the default of every filter, so pass it only to make the intent explicit.""" + +_Activation: TypeAlias = Literal["always", "on_request", "never"] +"""``always``: on by default. ``on_request``: installed but off until a message turns +it on. ``never``: not installed.""" + +_EMPTY_LIST_HINT = ( + "an empty list is ambiguous: pass ALL for no restriction, or turn the capability off instead" +) +_NEVER_HINT = "not accepted when the capability is off: it takes no filter" + + +def _reject_none(value: object) -> object: + if value is None: + raise ValueError( + "None is not accepted: pass ALL ('*') for no restriction, or a non-empty list" + ) + return value + + +def _non_empty(value: list[_T]) -> list[_T]: + if not value: + raise ValueError(_EMPTY_LIST_HINT) + return value + + +def _no_wildcard(value: list[str]) -> list[str]: + if "*" in value: + raise ValueError("'*' inside a list is not accepted: pass ALL on its own for every id") + return value + + +_IntIds: TypeAlias = Annotated[list[int], AfterValidator(_non_empty)] +_StrIds: TypeAlias = Annotated[list[str], AfterValidator(_non_empty), AfterValidator(_no_wildcard)] + +_IntAllowList: TypeAlias = Annotated[Union[_IntIds, Literal["*"]], BeforeValidator(_reject_none)] +_StrAllowList: TypeAlias = Annotated[Union[_StrIds, Literal["*"]], BeforeValidator(_reject_none)] + +_MemoryNamespaceValue: TypeAlias = Annotated[ + str, Field(min_length=1), AfterValidator(validate_memory_namespace) +] + +_FILTER_FIELDS = ("source_ids", "document_ids", "connections", "include") + + +def _drop_none(wire: dict[str, object]) -> dict[str, object]: + return {name: value for name, value in wire.items() if value is not None} + + +class _StrictModel(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + @model_validator(mode="wrap") + @classmethod + def _raise_invalid_params(cls, data: object, handler: ModelWrapValidatorHandler[Self]) -> Self: + try: + return handler(data) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context=cls.__name__) from None + + +# --------------------------------------------------------------------------- # +# Thread level (saved agent / thread start) +# --------------------------------------------------------------------------- # +class _Capability(_StrictModel, Generic[_ActivationT]): + # Declared first so that filter validators of the subclasses can read it. + activation: _ActivationT + + @field_validator(*_FILTER_FIELDS, mode="after", check_fields=False) + @classmethod + def _no_filter_when_never(cls, value: _T, info: ValidationInfo) -> _T: + if info.data.get("activation") == "never": + raise ValueError(_NEVER_HINT) + return value + + @model_serializer(mode="wrap") + def _to_wire(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + wire: dict[str, object] = handler(self) + return {"activation": "never"} if self.activation == "never" else wire + + +class WebCapability(_Capability[_Activation]): + pass + + +class ImageGenerationCapability(_Capability[_Activation]): + pass + + +class VideoGenerationCapability(_Capability[_Activation]): + pass + + +class MusicGenerationCapability(_Capability[_Activation]): + pass + + +class IndexedDocumentsCapability(_Capability[_Activation]): + source_ids: _IntAllowList = ALL + document_ids: _StrAllowList = ALL + + +class McpCapability(_Capability[_Activation]): + connections: _StrAllowList = ALL + + +class SkillsCapability(_Capability[Literal["always", "never"]]): + include: _StrAllowList = ALL + + +class MemoryCapability(_StrictModel): + """``always`` needs a ``namespace`` (the memory bag); ``never`` takes none.""" + + activation: Literal["always", "never"] + namespace: _MemoryNamespaceValue | None = None + + # "before", not "after": a subclass "after" validator runs outside the parent's + # InvalidParams wrapper, and only the raw input still shows an explicit None. + @model_validator(mode="before") + @classmethod + def _namespace_matches_activation(cls, data: object) -> object: + if not isinstance(data, dict): + return data + fields = cast("dict[str, object]", data) + if fields.get("activation") == "always" and fields.get("namespace") is None: + raise ValueError("namespace is required when activation='always'") + if fields.get("activation") == "never" and "namespace" in fields: + raise ValueError("namespace is not accepted with activation='never': memory is off") + return fields + + @model_serializer(mode="wrap") + def _to_wire(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + wire: dict[str, object] = handler(self) + return {"activation": "never"} if self.activation == "never" else wire + + +class AgentCapabilities(_StrictModel): + web: WebCapability | None = None + indexed_documents: IndexedDocumentsCapability | None = None + mcp: McpCapability | None = None + skills: SkillsCapability | None = None + image_generation: ImageGenerationCapability | None = None + video_generation: VideoGenerationCapability | None = None + music_generation: MusicGenerationCapability | None = None + memory: MemoryCapability | None = None + + @model_serializer(mode="wrap") + def _to_wire(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + return _drop_none(handler(self)) + + +# --------------------------------------------------------------------------- # +# Message level (one message) +# --------------------------------------------------------------------------- # +class _Scope(_StrictModel): + enabled: bool + + @field_validator(*_FILTER_FIELDS, mode="after", check_fields=False) + @classmethod + def _no_filter_when_disabled(cls, value: _T, info: ValidationInfo) -> _T: + if info.data.get("enabled") is False: + raise ValueError(_NEVER_HINT) + return value + + @model_serializer(mode="wrap") + def _to_wire(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + wire: dict[str, object] = handler(self) + return wire if self.enabled else {"enabled": False} + + +class WebScope(_Scope): + pass + + +class ImageGenerationScope(_Scope): + pass + + +class VideoGenerationScope(_Scope): + pass + + +class MusicGenerationScope(_Scope): + pass + + +class IndexedDocumentsScope(_Scope): + source_ids: _IntAllowList = ALL + document_ids: _StrAllowList = ALL + + +class McpScope(_Scope): + connections: _StrAllowList = ALL + + +class MessageScope(_StrictModel): + web: WebScope | None = None + indexed_documents: IndexedDocumentsScope | None = None + mcp: McpScope | None = None + image_generation: ImageGenerationScope | None = None + video_generation: VideoGenerationScope | None = None + music_generation: MusicGenerationScope | None = None + + @model_serializer(mode="wrap") + def _to_wire(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + return _drop_none(handler(self)) diff --git a/src/cominty_sdk/models/chat.py b/src/cominty_sdk/models/chat.py index b631c67..50bf8a4 100644 --- a/src/cominty_sdk/models/chat.py +++ b/src/cominty_sdk/models/chat.py @@ -11,12 +11,11 @@ BaseModel, ConfigDict, Field, - StringConstraints, field_validator, ) from typing_extensions import TypeAlias -from .memory import MemoryNamespace +from .capabilities import AgentCapabilities, MessageScope __all__ = [ # enums / aliases @@ -24,11 +23,8 @@ "MessageStatus", "ContentOrigin", "ThreadInception", - "DisablableTool", "UserId", "validate_user_id", - "DISABLE_MCP_PREFIX", - "DISABLE_ALL_MCP", "SERVER_DEFAULT", "MaxSteps", # request models @@ -98,13 +94,6 @@ def validate_user_id(value: str) -> str: typo'd or malformed id fails locally instead of as a server 400/404.""" -DISABLE_MCP_PREFIX = "mcp:" -"""Prefix for disabling a single MCP server, e.g. ``"mcp:slack"``.""" -# TODO(mcp): how is this used ? -DISABLE_ALL_MCP = f"{DISABLE_MCP_PREFIX}*" -"""Wildcard token that disables every connected MCP server at once.""" - - # Single-member enum: the one sentinel idiom pyright narrows with `is`. # typing_extensions.sentinel is not understood by pyright's bundled typeshed. class _ServerDefaultType(Enum): @@ -118,15 +107,6 @@ class _ServerDefaultType(Enum): MaxSteps: TypeAlias = Union[int, Literal[_ServerDefaultType.SERVER_DEFAULT]] """A tool-round cap: an integer ``>= 1``, or ``SERVER_DEFAULT`` to omit it.""" -# TODO: include the wildcard * for disable all, can it be with the other literals? -DisablableTool: TypeAlias = Union[ - Literal["web", "company_documents"], - Annotated[str, StringConstraints(pattern=rf"^{DISABLE_MCP_PREFIX}.+")], -] -"""A tool the agent may disable: the built-in ``"web"`` / ``"company_documents"``, -or an MCP token ``"mcp:"`` (``"mcp:*"`` for all). The MCP arm is regex- -validated, so arbitrary strings are rejected rather than silently sent.""" - # --------------------------------------------------------------------------- # # Request models (strict: reject unknown fields, no coercion) @@ -136,9 +116,7 @@ class HumanMessage(BaseModel): content: str = Field(max_length=_CONTENT_MAX_LENGTH) file_ids: list[str] | None = Field(default=None, max_length=_MAX_FILES) - source_ids: list[int] | None = None - document_ids: list[str] | None = None - disabled_tools: list[DisablableTool] | None = None + scope: MessageScope | None = None class StartChatOptions(BaseModel): @@ -147,9 +125,8 @@ class StartChatOptions(BaseModel): agent_id: str user_id: UserId """Required: the API-token endpoint rejects a missing ``user_id`` with 400.""" - memory_namespace: MemoryNamespace | None = None - """Memory bag for this thread, frozen once the thread starts. Omit to run - with no memory tools, unless the agent has its own namespace set.""" + capabilities: AgentCapabilities | None = None + """Thread-level override, frozen once the thread starts. ``start`` only.""" max_steps: int | None = Field(default=None, ge=1) """Tool-round cap for this message only. Omit to let the server apply its default. ``None`` is the "omitted" state, never a value: an explicit ``None`` diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index 5c5a114..ab2ade1 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -6,9 +6,9 @@ from pydantic import ValidationError from cominty_sdk.exceptions import InvalidParams, SDKError +from cominty_sdk.models.capabilities import AgentCapabilities, MessageScope from cominty_sdk.models.chat import ( SERVER_DEFAULT, - DisablableTool, MaxSteps, Message, MessageRole, @@ -35,10 +35,8 @@ async def start( message: str, name: str | None = None, file_ids: list[str] | None = None, - source_ids: list[int] | None = None, - document_ids: list[str] | None = None, - disabled_tools: list[DisablableTool] | None = None, - memory_namespace: str | None = None, + thread_capabilities: AgentCapabilities | None = None, + message_scope: MessageScope | None = None, max_steps: MaxSteps = SERVER_DEFAULT, ) -> StartedChat: """ @@ -52,12 +50,14 @@ async def start( message (str): First user message. name (str | None): Thread title. Omit it to leave the thread unnamed. file_ids (list[str] | None): Uploaded file ids attached to the message. - source_ids (list[int] | None): Connected source ids attached to the message. - document_ids (list[str] | None): Document ids attached to the message. - disabled_tools (list[DisablableTool] | None): Tools the agent must not use. - memory_namespace (str | None): Memory bag for this thread, frozen for its - lifetime. Omit to run with no memory tools, unless the agent has its - own namespace set. A follow-up cannot change it. + thread_capabilities (AgentCapabilities | None): Overrides some of the + agent's capabilities for this thread, frozen for its lifetime. Each + capability you pass replaces the agent's; the others keep the agent's + value. A follow-up cannot change it. ``memory`` freezes the thread's + memory bag. With capabilities but no ``memory``, the thread uses the + agent's namespace, or runs without memory if the agent has none. + message_scope (MessageScope | None): Turns a capability on + or off, or narrows its allowlist, for this first message only. max_steps (MaxSteps): Cap on the agent's tool rounds for this message, an integer ``>= 1``. Leave it at ``SERVER_DEFAULT`` to let the server apply its own default (60 today, subject to change). Reaching the @@ -76,10 +76,8 @@ async def start( message=message, name=name, file_ids=file_ids, - source_ids=source_ids, - document_ids=document_ids, - disabled_tools=disabled_tools, - memory_namespace=memory_namespace, + thread_capabilities=thread_capabilities, + message_scope=message_scope, max_steps=max_steps, context="chat.start", ) @@ -95,9 +93,7 @@ async def send( message: str, agent_id: str, file_ids: list[str] | None = None, - source_ids: list[int] | None = None, - document_ids: list[str] | None = None, - disabled_tools: list[DisablableTool] | None = None, + message_scope: MessageScope | None = None, max_steps: MaxSteps = SERVER_DEFAULT, ) -> AssistantRun: """ @@ -114,9 +110,9 @@ async def send( message (str): Follow-up text. agent_id (str): Managed agent that handles this turn. file_ids (list[str] | None): Uploaded file ids attached to the message. - source_ids (list[int] | None): Connected source ids attached to the message. - document_ids (list[str] | None): Document ids attached to the message. - disabled_tools (list[DisablableTool] | None): Tools the agent must not use. + message_scope (MessageScope | None): Turns a capability on + or off, or narrows its allowlist, for this message only. Limited to + what the thread was started with. max_steps (MaxSteps): Cap on the agent's tool rounds for this message, an integer ``>= 1``. The cap is per message, not per thread: a follow-up that leaves it at ``SERVER_DEFAULT`` runs with the server @@ -135,10 +131,8 @@ async def send( message=message, name=None, file_ids=file_ids, - source_ids=source_ids, - document_ids=document_ids, - disabled_tools=disabled_tools, - memory_namespace=None, + thread_capabilities=None, + message_scope=message_scope, max_steps=max_steps, context="chat.send", ) @@ -167,10 +161,8 @@ def _build_body( message: str, name: str | None, file_ids: list[str] | None, - source_ids: list[int] | None, - document_ids: list[str] | None, - disabled_tools: list[DisablableTool] | None, - memory_namespace: str | None, + thread_capabilities: AgentCapabilities | None, + message_scope: MessageScope | None, max_steps: MaxSteps, context: str, ) -> dict[str, object]: @@ -180,7 +172,7 @@ def _build_body( options: dict[str, object] = { "agent_id": agent_id, "user_id": self._user_id, - "memory_namespace": memory_namespace, + "capabilities": thread_capabilities, } # The sentinel leaves the key out so the server default applies. An # explicit None is passed through on purpose: the model rejects it. @@ -193,9 +185,7 @@ def _build_body( "message": { "content": message, "file_ids": file_ids, - "source_ids": source_ids, - "document_ids": document_ids, - "disabled_tools": disabled_tools, + "scope": message_scope, }, "options": options, } diff --git a/tests/unit/test_capabilities.py b/tests/unit/test_capabilities.py new file mode 100644 index 0000000..eda8384 --- /dev/null +++ b/tests/unit/test_capabilities.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import pytest + +from cominty_sdk import ( + ALL, + AgentCapabilities, + ImageGenerationCapability, + ImageGenerationScope, + IndexedDocumentsCapability, + IndexedDocumentsScope, + InvalidParams, + McpCapability, + McpScope, + MemoryCapability, + MessageScope, + MusicGenerationCapability, + MusicGenerationScope, + SkillsCapability, + VideoGenerationCapability, + VideoGenerationScope, + WebCapability, + WebScope, +) + + +def test_all_is_the_wildcard_string() -> None: + assert ALL == "*" + + +def test_agent_capabilities_wire_shape() -> None: + caps = AgentCapabilities( + web=WebCapability(activation="always"), + indexed_documents=IndexedDocumentsCapability(activation="always", source_ids=[1, 2]), + mcp=McpCapability(activation="on_request", connections=["linear"]), + skills=SkillsCapability(activation="always"), + image_generation=ImageGenerationCapability(activation="on_request"), + video_generation=VideoGenerationCapability(activation="never"), + music_generation=MusicGenerationCapability(activation="always"), + memory=MemoryCapability(activation="always", namespace="support-bot"), + ) + + assert caps.model_dump(mode="json") == { + "web": {"activation": "always"}, + "indexed_documents": {"activation": "always", "source_ids": [1, 2], "document_ids": "*"}, + "mcp": {"activation": "on_request", "connections": ["linear"]}, + "skills": {"activation": "always", "include": "*"}, + "image_generation": {"activation": "on_request"}, + "video_generation": {"activation": "never"}, + "music_generation": {"activation": "always"}, + "memory": {"activation": "always", "namespace": "support-bot"}, + } + + +def test_empty_agent_capabilities_is_empty_object() -> None: + assert AgentCapabilities().model_dump(mode="json") == {} + + +@pytest.mark.parametrize( + ("field", "shortcut", "expected"), + [ + ("web", "on_request", "WebCapability"), + ("web", True, "WebCapability"), + ("memory", "never", "MemoryCapability"), + ], +) +def test_shortcuts_are_rejected(field: str, shortcut: object, expected: str) -> None: + with pytest.raises(InvalidParams) as exc: + AgentCapabilities(**{field: shortcut}) + + assert expected in str(exc.value) + + +@pytest.mark.parametrize( + "capability", + [ + WebCapability, + ImageGenerationCapability, + VideoGenerationCapability, + MusicGenerationCapability, + IndexedDocumentsCapability, + McpCapability, + SkillsCapability, + MemoryCapability, + ], +) +def test_activation_is_required(capability: type) -> None: + with pytest.raises(InvalidParams) as exc: + capability() + + assert exc.value.errors[0]["param"] == "activation" + + +@pytest.mark.parametrize("bad", ["on", "ALWAYS", True, None]) +def test_activation_rejects_unknown_values(bad: object) -> None: + with pytest.raises(InvalidParams): + WebCapability(activation=bad) # type: ignore[arg-type] + + +@pytest.mark.parametrize("activation", ["always", "on_request", "never"]) +def test_toggle_capabilities_accept_every_activation(activation: str) -> None: + caps = AgentCapabilities( + web=WebCapability(activation=activation), # type: ignore[arg-type] + ) + + assert caps.model_dump(mode="json") == {"web": {"activation": activation}} + + +def test_skills_reject_on_request() -> None: + with pytest.raises(InvalidParams): + SkillsCapability(activation="on_request") # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("capability", "field"), + [ + (IndexedDocumentsCapability, "source_ids"), + (IndexedDocumentsCapability, "document_ids"), + (McpCapability, "connections"), + (SkillsCapability, "include"), + ], +) +def test_filters_default_to_all_and_accept_it_explicitly(capability: type, field: str) -> None: + default = capability(activation="always") + explicit = capability(activation="always", **{field: ALL}) + + assert default.model_dump(mode="json") == explicit.model_dump(mode="json") + assert default.model_dump(mode="json")[field] == "*" + + +@pytest.mark.parametrize( + ("capability", "field", "value"), + [ + (IndexedDocumentsCapability, "source_ids", [1]), + (IndexedDocumentsCapability, "document_ids", ["d"]), + (McpCapability, "connections", ["linear"]), + (SkillsCapability, "include", ["skl_a"]), + (McpCapability, "connections", ALL), + ], +) +def test_filter_is_refused_when_never(capability: type, field: str, value: object) -> None: + with pytest.raises(InvalidParams) as exc: + capability(activation="never", **{field: value}) + + assert exc.value.errors[0]["param"] == field + + +def test_never_sends_only_the_activation() -> None: + caps = AgentCapabilities( + indexed_documents=IndexedDocumentsCapability(activation="never"), + mcp=McpCapability(activation="never"), + skills=SkillsCapability(activation="never"), + ) + + assert caps.model_dump(mode="json") == { + "indexed_documents": {"activation": "never"}, + "mcp": {"activation": "never"}, + "skills": {"activation": "never"}, + } + + +@pytest.mark.parametrize("bad", [None, []]) +def test_filter_rejects_none_and_empty_list(bad: object) -> None: + with pytest.raises(InvalidParams) as exc: + McpCapability(activation="always", connections=bad) # type: ignore[arg-type] + + assert "ALL" in str(exc.value) + assert exc.value.errors[0]["param"] == "connections" + + +@pytest.mark.parametrize("value", [["*"], ["*", "id_1"]]) +@pytest.mark.parametrize( + ("model", "base", "field"), + [ + (IndexedDocumentsCapability, {"activation": "always"}, "document_ids"), + (McpCapability, {"activation": "always"}, "connections"), + (SkillsCapability, {"activation": "always"}, "include"), + (IndexedDocumentsScope, {"enabled": True}, "document_ids"), + (McpScope, {"enabled": True}, "connections"), + ], +) +def test_wildcard_inside_a_list_is_rejected( + model: type, base: dict[str, object], field: str, value: list[str] +) -> None: + with pytest.raises(InvalidParams) as exc: + model(**base, **{field: value}) + + assert "ALL" in str(exc.value) + assert exc.value.errors[0]["param"] == field + + +def test_capability_rejects_unknown_field() -> None: + with pytest.raises(InvalidParams): + WebCapability(activation="always", connections=ALL) # type: ignore[call-arg] + + +@pytest.mark.parametrize("extra", [{}, {"namespace": None}]) +def test_memory_always_requires_a_namespace(extra: dict[str, object]) -> None: + with pytest.raises(InvalidParams) as exc: + MemoryCapability(activation="always", **extra) + + assert "namespace is required" in str(exc.value) + + +def test_memory_never_sends_only_the_activation() -> None: + caps = AgentCapabilities(memory=MemoryCapability(activation="never")) + + assert caps.model_dump(mode="json") == {"memory": {"activation": "never"}} + + +@pytest.mark.parametrize("namespace", ["x", None]) +def test_memory_never_refuses_a_namespace(namespace: str | None) -> None: + with pytest.raises(InvalidParams) as exc: + MemoryCapability(activation="never", namespace=namespace) + + assert "namespace is not accepted" in str(exc.value) + + +def test_memory_rejects_on_request() -> None: + with pytest.raises(InvalidParams): + MemoryCapability(activation="on_request") # type: ignore[arg-type] + + +@pytest.mark.parametrize("namespace", ["", "x" * 129]) +def test_memory_namespace_length_is_bounded(namespace: str) -> None: + with pytest.raises(InvalidParams) as exc: + MemoryCapability(activation="always", namespace=namespace) + + assert exc.value.errors[0]["param"] == "namespace" + + +def test_memory_namespace_at_the_limit_is_accepted() -> None: + caps = MemoryCapability(activation="always", namespace="x" * 128) + + assert caps.model_dump(mode="json") == {"activation": "always", "namespace": "x" * 128} + + +def test_message_scope_wire_shape() -> None: + scope = MessageScope( + web=WebScope(enabled=False), + indexed_documents=IndexedDocumentsScope( + enabled=True, source_ids=[101], document_ids=["101#a"] + ), + mcp=McpScope(enabled=True, connections=["linear"]), + image_generation=ImageGenerationScope(enabled=True), + video_generation=VideoGenerationScope(enabled=False), + music_generation=MusicGenerationScope(enabled=True), + ) + + assert scope.model_dump(mode="json") == { + "web": {"enabled": False}, + "indexed_documents": {"enabled": True, "source_ids": [101], "document_ids": ["101#a"]}, + "mcp": {"enabled": True, "connections": ["linear"]}, + "image_generation": {"enabled": True}, + "video_generation": {"enabled": False}, + "music_generation": {"enabled": True}, + } + + +def test_empty_message_scope_is_empty_object() -> None: + assert MessageScope().model_dump(mode="json") == {} + + +@pytest.mark.parametrize("scope", [WebScope, IndexedDocumentsScope, McpScope]) +def test_scope_requires_enabled(scope: type) -> None: + with pytest.raises(InvalidParams) as exc: + scope() + + assert exc.value.errors[0]["param"] == "enabled" + + +@pytest.mark.parametrize( + ("scope", "field"), + [ + (IndexedDocumentsScope, "source_ids"), + (IndexedDocumentsScope, "document_ids"), + (McpScope, "connections"), + ], +) +def test_scope_filters_default_to_all_and_accept_it_explicitly(scope: type, field: str) -> None: + default = scope(enabled=True) + explicit = scope(enabled=True, **{field: ALL}) + + assert default.model_dump(mode="json") == explicit.model_dump(mode="json") + assert default.model_dump(mode="json")[field] == "*" + + +@pytest.mark.parametrize( + ("scope", "field", "value"), + [ + (IndexedDocumentsScope, "source_ids", [1]), + (IndexedDocumentsScope, "document_ids", ALL), + (McpScope, "connections", ["linear"]), + ], +) +def test_scope_filter_is_refused_when_disabled(scope: type, field: str, value: object) -> None: + with pytest.raises(InvalidParams) as exc: + scope(enabled=False, **{field: value}) + + assert exc.value.errors[0]["param"] == field + + +def test_disabled_scope_sends_only_enabled() -> None: + scope = MessageScope( + indexed_documents=IndexedDocumentsScope(enabled=False), + mcp=McpScope(enabled=False), + ) + + assert scope.model_dump(mode="json") == { + "indexed_documents": {"enabled": False}, + "mcp": {"enabled": False}, + } + + +@pytest.mark.parametrize("bad", [[], "linear", 1, None]) +def test_scope_mcp_rejects_bad_connections(bad: object) -> None: + with pytest.raises(InvalidParams): + McpScope(enabled=True, connections=bad) # type: ignore[arg-type] + + +@pytest.mark.parametrize("bad", ["yes", 1, None]) +def test_scope_enabled_must_be_a_bool(bad: object) -> None: + with pytest.raises(InvalidParams): + WebScope(enabled=bad) # type: ignore[arg-type] + + +def test_message_scope_has_no_skills_or_memory() -> None: + with pytest.raises(InvalidParams): + MessageScope(skills=WebScope(enabled=True)) # type: ignore[call-arg] + with pytest.raises(InvalidParams): + MessageScope(memory=WebScope(enabled=True)) # type: ignore[call-arg] diff --git a/tests/unit/test_chat_send.py b/tests/unit/test_chat_send.py index 35a1585..a2dce27 100644 --- a/tests/unit/test_chat_send.py +++ b/tests/unit/test_chat_send.py @@ -9,7 +9,15 @@ import pytest import respx -from cominty_sdk import SERVER_DEFAULT, AssistantRun, AsyncCominty, InvalidParams +from cominty_sdk import ( + SERVER_DEFAULT, + AssistantRun, + AsyncCominty, + IndexedDocumentsScope, + InvalidParams, + McpScope, + MessageScope, +) # Mirror the canonical ids in conftest (tests/ is not an importable package). THREAD_ID = "11111111-1111-1111-1111-111111111111" @@ -126,7 +134,34 @@ async def test_validation_fires_before_request( route = mock_api.post(f"/chat/{THREAD_ID}") with pytest.raises(InvalidParams) as exc: - await client.chat.send(THREAD_ID, message="hi", agent_id="a", disabled_tools=["bogus"]) + await client.chat.send(THREAD_ID, message="x" * 30_001, agent_id="a") assert not route.called assert str(exc.value).startswith("Invalid parameters for chat.send:") + + +async def test_sends_message_scope( + client: AsyncCominty, mock_api: respx.MockRouter, make_message: MakeMessage +) -> None: + route = mock_api.post(f"/chat/{THREAD_ID}").mock( + return_value=httpx.Response( + 200, json=make_message(id=ASSISTANT_MSG_ID, role="assistant", live=True) + ) + ) + + await client.chat.send( + THREAD_ID, + message="only 2024", + agent_id="agt_1", + message_scope=MessageScope( + indexed_documents=IndexedDocumentsScope(enabled=True, source_ids=[101]), + mcp=McpScope(enabled=False), + ), + ) + + body = json.loads(route.calls.last.request.content) + assert body["message"]["scope"] == { + "indexed_documents": {"enabled": True, "source_ids": [101], "document_ids": "*"}, + "mcp": {"enabled": False}, + } + assert body["options"] == {"agent_id": "agt_1", "user_id": USER_ID} diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index c6f6e2d..c8c51ee 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -10,18 +10,25 @@ import respx from cominty_sdk import ( + ALL, SERVER_DEFAULT, Agent, + AgentCapabilities, AsyncCominty, AuthError, ConflictError, + ImageGenerationScope, InvalidParams, + McpCapability, + MemoryCapability, + MessageScope, NotFoundError, PermissionError, RateLimitError, SDKError, ServerError, StartedChat, + WebCapability, ) MakeThread = Callable[..., dict[str, Any]] @@ -98,17 +105,11 @@ async def test_includes_optional_fields_when_provided( message="hi", name="My chat", file_ids=["f1", "f2"], - source_ids=[1, 2], - document_ids=["d1"], - disabled_tools=["web", "mcp:slack"], ) body = json.loads(route.calls.last.request.content) assert body["name"] == "My chat" assert body["message"]["file_ids"] == ["f1", "f2"] - assert body["message"]["source_ids"] == [1, 2] - assert body["message"]["document_ids"] == ["d1"] - assert body["message"]["disabled_tools"] == ["web", "mcp:slack"] async def test_start_does_not_open_the_stream( @@ -123,46 +124,15 @@ async def test_start_does_not_open_the_stream( assert mock_api.calls.last.request.method == "POST" -@pytest.mark.parametrize("tool", ["web", "company_documents", "mcp:slack", "mcp:*"]) -async def test_valid_disabled_tools_accepted( - client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread, tool: str -) -> None: - route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) - - await client.chat.start(agent_id="a", message="hi", disabled_tools=[tool]) - - assert route.called - assert json.loads(route.calls.last.request.content)["message"]["disabled_tools"] == [tool] - - -async def test_includes_memory_namespace_when_provided( - client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread -) -> None: - route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) - - await client.chat.start(agent_id="agt_1", message="hi", memory_namespace="support-bot") - - body = json.loads(route.calls.last.request.content) - assert body["options"]["memory_namespace"] == "support-bot" - - -async def test_omits_memory_namespace_when_not_provided( - client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread -) -> None: - route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) - - await client.chat.start(agent_id="agt_1", message="hi") - - assert "memory_namespace" not in json.loads(route.calls.last.request.content)["options"] - - -async def test_memory_namespace_too_long_raises_invalid_params( +async def test_memory_namespace_is_no_longer_a_parameter( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: route = mock_api.post("/chat") - with pytest.raises(InvalidParams): - await client.chat.start(agent_id="agt_1", message="hi", memory_namespace="x" * 129) + with pytest.raises(TypeError): + await client.chat.start( # type: ignore[call-arg] + agent_id="agt_1", message="hi", memory_namespace="support-bot" + ) assert not route.called @@ -221,36 +191,6 @@ async def test_uses_configured_base_url(make_thread: MakeThread) -> None: # --------------------------------------------------------------------------- # # Client-side validation: must fail BEFORE any HTTP request # --------------------------------------------------------------------------- # -async def test_invalid_disabled_tool_raises_before_request( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - route = mock_api.post("/chat") - - with pytest.raises(InvalidParams) as exc: - await client.chat.start(agent_id="a", message="hi", disabled_tools=["bogus"]) - - assert not route.called - assert len(mock_api.calls) == 0 - assert any(e["param"] == "disabled_tools[0]" for e in exc.value.errors) - assert exc.value.errors[0]["input"] == "bogus" - - -async def test_invalid_source_id_type_raises( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - route = mock_api.post("/chat") - - with pytest.raises(InvalidParams) as exc: - await client.chat.start( - agent_id="a", - message="hi", - source_ids=["nope"], # type: ignore[list-item] - ) - - assert not route.called - assert any(e["param"] == "source_ids[0]" for e in exc.value.errors) - - async def test_content_too_long_raises(client: AsyncCominty, mock_api: respx.MockRouter) -> None: route = mock_api.post("/chat") @@ -281,24 +221,23 @@ async def test_multiple_validation_errors_collected( with pytest.raises(InvalidParams) as exc: await client.chat.start( agent_id="a", - message="hi", - disabled_tools=["bad"], - source_ids=["nope"], # type: ignore[list-item] + message="x" * 30_001, + file_ids=[1], # type: ignore[list-item] ) params = {e["param"] for e in exc.value.errors} - assert {"disabled_tools[0]", "source_ids[0]"} <= params + assert {"content", "file_ids[0]"} <= params assert len(mock_api.calls) == 0 async def test_invalid_params_message_is_clean(client: AsyncCominty) -> None: with pytest.raises(InvalidParams) as exc: - await client.chat.start(agent_id="a", message="hi", disabled_tools=["bad"]) + await client.chat.start(agent_id="a", message="x" * 30_001) text = str(exc.value) assert text.startswith("Invalid parameters for chat.start:") - assert "disabled_tools[0]" in text - assert "got 'bad'" in text # the offending value is shown + assert "content" in text + assert "at most 30000" in text # the constraint is shown assert "pydantic" not in text.lower() # no leaked library internals @@ -440,3 +379,47 @@ async def test_picks_last_assistant_message( run = await client.chat.start(agent_id="a", message="hi") assert str(run.message_id) == _ASSISTANT_2 + + +# --------------------------------------------------------------------------- # +# Capabilities +# --------------------------------------------------------------------------- # +async def test_sends_thread_capabilities_and_message_scope( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + await client.chat.start( + agent_id="agt_1", + message="hi", + thread_capabilities=AgentCapabilities( + web=WebCapability(activation="never"), + mcp=McpCapability(activation="always", connections=ALL), + memory=MemoryCapability(activation="always", namespace="support-bot"), + ), + message_scope=MessageScope(image_generation=ImageGenerationScope(enabled=True)), + ) + + body = json.loads(route.calls.last.request.content) + assert body["options"]["capabilities"] == { + "web": {"activation": "never"}, + "mcp": {"activation": "always", "connections": "*"}, + "memory": {"activation": "always", "namespace": "support-bot"}, + } + assert body["message"]["scope"] == {"image_generation": {"enabled": True}} + assert "capabilities" not in body["message"] + + +async def test_sends_memory_never( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + await client.chat.start( + agent_id="agt_1", + message="hi", + thread_capabilities=AgentCapabilities(memory=MemoryCapability(activation="never")), + ) + + body = json.loads(route.calls.last.request.content) + assert body["options"]["capabilities"] == {"memory": {"activation": "never"}} From ff9d4c93fa0c3d882939214da4079e82a6884046 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:11:52 +0200 Subject: [PATCH 2/2] chore(release): version 1.0.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e11727d..9ec2611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cominty-sdk" -version = "0.5.0" +version = "1.0.0" description = "Official async Python client for the Cominty managed agent chat API" readme = "README.md" requires-python = ">=3.9" diff --git a/uv.lock b/uv.lock index f52c35a..a49ac33 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "cominty-sdk" -version = "0.5.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "eval-type-backport", marker = "python_full_version < '3.10'" },