diff --git a/AGENTS.md b/AGENTS.md index 9b546d232..aa0d7afab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,31 @@ This is a Python SDK that connects AI agents to the Band collaborative platform. - `band_supersede_memory`: Mark memory as superseded (soft delete) - `band_archive_memory`: Archive memory (hide but preserve) +### Room File Tools +- `band_list_room_files`: List files shared in messages addressed to this agent +- `band_read_room_file`: Read a file (text inline, images as vision input) +- `band_send_room_file`: Attach a file to a message the agent posts + +### Capability negotiation + +`Capability.FILES` is the *operator* saying "this agent may use files". It +cannot say "this deployment has them" — one SDK build talks to the SaaS node, +to an on-prem node whose licence grants room files and to one whose licence +does not, and only the last 404s the file endpoints. So the SDK asks. + +`GET /api/v1/agent/me` answers with a `feature_flags` block under the same +`ff_*` keys the web app and JAM read from their own boot payload (one +vocabulary, three clients). `PlatformRuntime` keeps the answer from the +metadata fetch it already performs, and `Agent.start()` removes any refused +capability from the adapter's `AdapterFeatures` **before** `on_started` — every +adapter builds its tool list there, and `features.capabilities` is the single +seam they all read, so one prune hides the tools in every framework at once. + +Only an explicit `false` refuses. A platform that answers nothing is not saying +no — it may predate the flag while still serving the endpoints, so treating +silence as a refusal would take working tools away. See +`src/band/runtime/capabilities.py`. + ## REST Client API Pattern The SDK uses Fern-generated REST client with property-based namespace API: diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 0033507b2..edf220c95 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -77,6 +77,7 @@ from band.runtime.tools import ( ALL_TOOL_NAMES, BASE_TOOL_NAMES, + FILE_TOOL_NAMES, MCP_TOOL_PREFIX, MEMORY_TOOL_NAMES, iter_tool_definitions, @@ -90,8 +91,9 @@ # Derived from TOOL_MODELS — single source of truth BAND_BASE_TOOLS: list[str] = mcp_tool_names(BASE_TOOL_NAMES) BAND_MEMORY_TOOLS: list[str] = mcp_tool_names(MEMORY_TOOL_NAMES) -# All tools: chat + contacts + memory (17 total). For chat-only tools (7), -# see band.integrations.claude_sdk.tools.BAND_CHAT_TOOLS. +BAND_FILE_TOOLS: list[str] = mcp_tool_names(FILE_TOOL_NAMES) +# All tools: chat + contacts + memory + files (20 total). For chat-only tools +# (7), see band.integrations.claude_sdk.tools.BAND_CHAT_TOOLS. BAND_ALL_TOOLS: list[str] = mcp_tool_names(ALL_TOOL_NAMES) _BAND_TOOLS: list[str] = BAND_ALL_TOOLS @@ -189,7 +191,7 @@ class ClaudeSDKAdapter(SimpleAdapter[ClaudeSDKSessionState]): {Emit.EXECUTION, Emit.THOUGHTS, Emit.USAGE} ) SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( - {Capability.MEMORY, Capability.CONTACTS} + {Capability.MEMORY, Capability.CONTACTS, Capability.FILES} ) def __init__( @@ -460,10 +462,12 @@ async def _create_mcp_backend(self) -> BandMCPBackend: """Create shared MCP backend that uses stored room tools.""" include_memory = Capability.MEMORY in self.features.capabilities include_contacts = Capability.CONTACTS in self.features.capabilities + include_files = Capability.FILES in self.features.capabilities tool_definitions = list( iter_tool_definitions( include_memory=include_memory, include_contacts=include_contacts, + include_files=include_files, ) ) backend = await create_band_mcp_backend( diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index 68b7ca545..ec6907f56 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -104,7 +104,7 @@ class CrewAIAdapter(SimpleAdapter[CrewAIMessages]): SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION}) SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( - {Capability.MEMORY, Capability.CONTACTS} + {Capability.MEMORY, Capability.CONTACTS, Capability.FILES} ) def __init__( diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index c172e22cc..bbfec6729 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -1503,7 +1503,9 @@ class CrewAIFlowAdapter(SimpleAdapter[CrewAIFlowSessionState]): """ SUPPORTED_EMIT = frozenset({Emit.EXECUTION}) - SUPPORTED_CAPABILITIES = frozenset({Capability.MEMORY, Capability.CONTACTS}) + SUPPORTED_CAPABILITIES = frozenset( + {Capability.MEMORY, Capability.CONTACTS, Capability.FILES} + ) def __init__( self, diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 5c8268d4c..6b3d2ce43 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -65,6 +65,7 @@ is_terminal_success, missing_reply_error, serialize_tool_result, + describe_tool_result_as_text, ) logger = logging.getLogger(__name__) @@ -217,7 +218,7 @@ class PydanticAIAdapter(SimpleAdapter[PydanticAIMessages]): SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION, Emit.USAGE}) SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( - {Capability.MEMORY, Capability.CONTACTS} + {Capability.MEMORY, Capability.CONTACTS, Capability.FILES} ) def __init__( @@ -677,6 +678,53 @@ async def band_archive_memory( band_archive_memory.__doc__ = get_tool_description("band_archive_memory") agent.tool(band_archive_memory) + # Room file tools (opt-in via Capability.FILES) + if Capability.FILES in self.features.capabilities: + + async def band_list_room_files( + ctx: RunContext[AgentToolsProtocol], + ) -> str: + try: + return await ctx.deps.list_room_files() + except Exception as e: + return f"Error listing room files: {e}" + + band_list_room_files.__doc__ = get_tool_description("band_list_room_files") + agent.tool(band_list_room_files) + + async def band_read_room_file( + ctx: RunContext[AgentToolsProtocol], + file_id: str, + ) -> Any: + try: + # pydantic-ai has no MCP content path, so the image + # block would be stringified into the turn as base64. + return describe_tool_result_as_text( + await ctx.deps.read_room_file(file_id) + ) + except Exception as e: + return f"Error reading room file: {e}" + + band_read_room_file.__doc__ = get_tool_description("band_read_room_file") + agent.tool(band_read_room_file) + + async def band_send_room_file( + ctx: RunContext[AgentToolsProtocol], + filename: str, + text_content: str, + mention: str, + message: str = "", + ) -> str: + try: + return await ctx.deps.send_room_file( + filename, text_content, mention, message + ) + except Exception as e: + return f"Error sending room file: {e}" + + band_send_room_file.__doc__ = get_tool_description("band_send_room_file") + agent.tool(band_send_room_file) + # Register custom tools (user-provided PydanticAI-compatible functions) on # the path their signature calls for — pydantic-ai keeps the two apart. for custom_tool in self._custom_tools: diff --git a/src/band/agent.py b/src/band/agent.py index 339f6c3dd..ef65a8492 100644 --- a/src/band/agent.py +++ b/src/band/agent.py @@ -12,6 +12,7 @@ from band.core.protocols import FrameworkAdapter, Preprocessor from band.core.simple_adapter import SimpleAdapter +from band.runtime.capabilities import capabilities_the_platform_refuses from band.runtime.platform_runtime import PlatformRuntime from band.runtime.types import ( AgentConfig, @@ -237,6 +238,7 @@ async def start(self) -> None: await self._runtime.initialize() # 2. Initialize adapter with agent metadata BEFORE message processing + self._honour_platform_capabilities() setattr(self._adapter, "_band_agent_id", self._runtime.agent_id) await self._adapter.on_started( self._runtime.agent_name, @@ -299,6 +301,39 @@ async def stop(self, timeout: float | None = None) -> bool: ) return graceful + def _honour_platform_capabilities(self) -> None: + """Drop capabilities this deployment says it does not serve. + + The operator's `Capability.FILES` means "this agent may use files"; it + cannot mean "this deployment has them", and only the platform knows. + Runs before `on_started` because that is where adapters build their + tool lists — pruning afterwards would change nothing, and an agent + advertising a tool the platform 404s spends real model turns finding + out. + """ + # getattr because Agent is also driven with hand-rolled runtime objects + # that predate this property; one missing attribute must not take an + # agent's startup down. + refused = capabilities_the_platform_refuses( + getattr(self._runtime, "platform_feature_flags", None) + ) + features = getattr(self._adapter, "features", None) + if not refused or features is None: + return + + unavailable = refused & features.capabilities + if not unavailable: + return + + logger.warning( + "Platform does not serve %s; their tools stay hidden from agent %s", + ", ".join(sorted(capability.value for capability in unavailable)), + self._runtime.agent_id, + ) + # setattr because `features` belongs to SimpleAdapter, not to the + # FrameworkAdapter protocol — same reason as `_band_agent_id` above. + setattr(self._adapter, "features", features.without_capabilities(unavailable)) + async def _cleanup_adapter(self) -> None: """Release adapter-wide resources, best-effort.""" cleanup_all = getattr(self._adapter, "cleanup_all", None) diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index d1b1b9f84..766bc4192 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -122,18 +122,41 @@ def get_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]] | list["ToolParam"]: """Get tool schemas in provider-specific format (openai/anthropic).""" ... + async def list_room_files(self) -> str: + """List files shared in this room, newest first.""" + ... + + async def read_room_file(self, file_id: str) -> Any: + """Read a room file: text inline, images as MCP content, rest described.""" + ... + + async def send_room_file( + self, filename: str, text_content: str, mention: str, message: str = "" + ) -> str: + """Upload a text file and attach it to a new message in this room.""" + ... + def get_anthropic_tool_schemas( - self, *, include_memory: bool = False, include_contacts: bool = True + self, + *, + include_memory: bool = False, + include_contacts: bool = True, + include_files: bool = False, ) -> list["ToolParam"]: """Get tool schemas in Anthropic format (strongly typed).""" ... def get_openai_tool_schemas( - self, *, include_memory: bool = False, include_contacts: bool = True + self, + *, + include_memory: bool = False, + include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: """Get tool schemas in OpenAI format (strongly typed).""" ... diff --git a/src/band/core/types.py b/src/band/core/types.py index 32ba5e878..c8ed1b101 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -52,6 +52,7 @@ class Capability(str, Enum): MEMORY = "memory" CONTACTS = "contacts" + FILES = "files" class Emit(str, Enum): @@ -297,6 +298,23 @@ def __init__( tuple(include_categories) if include_categories is not None else None, ) + def without_capabilities( + self, capabilities: Iterable[Capability] + ) -> AdapterFeatures: + """These settings minus `capabilities`, leaving the rest untouched. + + Used when the deployment serves less than the operator asked for: the + capability set is the one seam every adapter's tool gating reads, so + removing an entry here removes the tools everywhere at once. + """ + return AdapterFeatures( + capabilities=self.capabilities - frozenset(capabilities), + emit=self.emit, + include_tools=self.include_tools, + exclude_tools=self.exclude_tools, + include_categories=self.include_categories, + ) + @dataclass(frozen=True) class PlatformMessage: diff --git a/src/band/integrations/claude_sdk/tools.py b/src/band/integrations/claude_sdk/tools.py index 8b7d2e74c..0996eed5e 100644 --- a/src/band/integrations/claude_sdk/tools.py +++ b/src/band/integrations/claude_sdk/tools.py @@ -72,8 +72,32 @@ def __getattr__(name: str) -> Any: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +def _is_mcp_content(data: Any) -> bool: + """True when a tool result is already an MCP content payload. + + The shape is a non-empty list of blocks, each tagged with a ``type``. An + empty list is not it: a payload that merely happens to carry a ``content`` + key would otherwise pass through as a tool result with nothing in it, and + everything else the payload said would be lost on the way to the model. + """ + blocks = data.get("content") if isinstance(data, dict) else None + return ( + isinstance(blocks, list) + and bool(blocks) + and all(isinstance(block, dict) and "type" in block for block in blocks) + ) + + def _make_result(data: Any) -> dict[str, Any]: - """Format tool result for Claude SDK MCP responses.""" + """Format tool result for Claude SDK MCP responses. + + A result that is already MCP-shaped passes through untouched — that is + how a tool returns non-text content (an ``image`` block reaches the model + as vision input; json-dumping it here would demote it to base64 prose). + Everything else is wrapped as a single text block, as before. + """ + if _is_mcp_content(data): + return data return {"content": [{"type": "text", "text": json.dumps(data, default=str)}]} @@ -246,6 +270,12 @@ def _build_custom_sdk_tool( input_model, _ = tool_def tool_name = get_custom_tool_name(input_model) schema = _build_sdk_schema(input_model, include_room_id=include_room_id) + # ``room_id`` is normally a schema-injected extra the model must echo so + # the bridge can resolve the room — it is not part of the tool's own + # contract, so it gets stripped before validation. A tool that declares + # ``room_id`` as a real field opts out of the strip: for it, dropping the + # key would make validation fail on every call. + declares_room_id = "room_id" in input_model.model_fields @tool( tool_name, @@ -254,7 +284,10 @@ def _build_custom_sdk_tool( ) async def handler(args: dict[str, Any]) -> dict[str, Any]: try: - tool_args = {k: v for k, v in args.items() if k != "room_id"} + if declares_room_id: + tool_args = dict(args) + else: + tool_args = {k: v for k, v in args.items() if k != "room_id"} result = await execute_custom_tool(tool_def, tool_args) return _make_result(result) except Exception as error: diff --git a/src/band/integrations/crewai/tools.py b/src/band/integrations/crewai/tools.py index e2a7cf360..80c293ba2 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -72,6 +72,8 @@ get_tool_description, is_terminal_success, serialize_tool_result, + describe_tool_result_as_text, + is_room_posting_tool, ) logger = logging.getLogger(__name__) @@ -94,6 +96,9 @@ "band_get_memory": "memory", "band_supersede_memory": "memory", "band_archive_memory": "memory", + "band_list_room_files": "files", + "band_read_room_file": "files", + "band_send_room_file": "files", } @@ -312,7 +317,11 @@ async def _execute() -> str: tool_name, succeeded=True, custom_terminal=custom_terminal ): context.reply_tracker.tool_executed = True - if tool_name == _SEND_MESSAGE_TOOL: + # Any tool that posts to the room counts as replying, not + # just band_send_message: an agent that answers by sharing a + # file has answered, and reporting 'no reply' after a + # successful share puts a spurious error in the room. + if is_room_posting_tool(tool_name): context.reply_tracker.replied = True except (json.JSONDecodeError, AttributeError, TypeError): pass @@ -498,12 +507,33 @@ class _ArchiveMemoryInput(BaseModel): _no_cache: Any = staticmethod(lambda *_a, **_kw: False) +class _ListRoomFilesInput(BaseModel): + """No arguments — lists files visible to this agent in the current room.""" + + +class _ReadRoomFileInput(BaseModel): + file_id: str = Field( + ..., description="The file id from band_list_room_files or a message" + ) + + +class _SendRoomFileInput(BaseModel): + filename: str = Field(..., description="Name for the file, e.g. plan.txt") + text_content: str = Field(..., description="The file's text content") + mention: str = Field( + ..., description="Handle of the participant to address, e.g. '@john'" + ) + message: str = Field( + default="", description="Optional message text to accompany the file" + ) + + def _make_platform_tools( *, get_context: Callable[[], CrewAIToolContext | None], reporter: CrewAIToolReporter, fallback_loop: asyncio.AbstractEventLoop | None, -) -> tuple[list[BaseTool], list[BaseTool], list[BaseTool]]: +) -> tuple[list[BaseTool], list[BaseTool], list[BaseTool], list[BaseTool]]: """Build the 7 base + 5 contact + 5 memory platform tools. Returns a (base, contacts, memory) triple. ``build_band_crewai_tools`` @@ -969,6 +999,70 @@ async def execute(tools: AgentToolsProtocol) -> str: return _exec("band_archive_memory", execute) + class ListRoomFilesTool(BaseTool): + name: str = "band_list_room_files" + description: str = get_tool_description("band_list_room_files") + args_schema: Type[BaseModel] = _ListRoomFilesInput + cache_function: Any = _no_cache + + def _run(self, *_args: Any, **_kwargs: Any) -> Any: + async def execute(tools: AgentToolsProtocol) -> str: + await reporter.report_call(tools, "band_list_room_files", {}) + result = await tools.list_room_files() + await reporter.report_result(tools, "band_list_room_files", result) + return serialize_success_result(result) + + return _exec("band_list_room_files", execute) + + class ReadRoomFileTool(BaseTool): + name: str = "band_read_room_file" + description: str = get_tool_description("band_read_room_file") + args_schema: Type[BaseModel] = _ReadRoomFileInput + cache_function: Any = _no_cache + + def _run(self, *_args: Any, **kwargs: Any) -> Any: + file_id = kwargs.get("file_id", "") + + async def execute(tools: AgentToolsProtocol) -> str: + await reporter.report_call( + tools, "band_read_room_file", {"file_id": file_id} + ) + # CrewAI json-dumps whatever it is handed, so the MCP image + # block would arrive as base64 prose rather than vision. + result = describe_tool_result_as_text( + await tools.read_room_file(file_id) + ) + await reporter.report_result(tools, "band_read_room_file", result) + return serialize_success_result(result) + + return _exec("band_read_room_file", execute) + + class SendRoomFileTool(BaseTool): + name: str = "band_send_room_file" + description: str = get_tool_description("band_send_room_file") + args_schema: Type[BaseModel] = _SendRoomFileInput + cache_function: Any = _no_cache + + def _run(self, *_args: Any, **kwargs: Any) -> Any: + filename = kwargs.get("filename", "") + text_content = kwargs.get("text_content", "") + mention = kwargs.get("mention", "") + message = kwargs.get("message", "") + + async def execute(tools: AgentToolsProtocol) -> str: + await reporter.report_call( + tools, + "band_send_room_file", + {"filename": filename, "mention": mention}, + ) + result = await tools.send_room_file( + filename, text_content, mention, message + ) + await reporter.report_result(tools, "band_send_room_file", result) + return serialize_success_result(result) + + return _exec("band_send_room_file", execute) + base_tools: list[BaseTool] = [ SendMessageTool(), SendEventTool(), @@ -992,8 +1086,13 @@ async def execute(tools: AgentToolsProtocol) -> str: SupersedeMemoryTool(), ArchiveMemoryTool(), ] + file_tools: list[BaseTool] = [ + ListRoomFilesTool(), + ReadRoomFileTool(), + SendRoomFileTool(), + ] - return base_tools, contact_tools, memory_tools + return base_tools, contact_tools, memory_tools, file_tools def _make_custom_tools( @@ -1092,13 +1191,14 @@ def build_band_crewai_tools( - 7 base tools always. - +5 contact tools when Capability.CONTACTS is in `capabilities`. - +5 memory tools when Capability.MEMORY is in `capabilities`. + - +3 file tools when Capability.FILES is in `capabilities`. - +N custom tools after platform tools. The returned tools close over `get_context`, `reporter`, and `fallback_loop`. Each adapter passes its own getter/reporter so the wrappers stay framework-agnostic. """ - base, contacts, memories = _make_platform_tools( + base, contacts, memories, files = _make_platform_tools( get_context=get_context, reporter=reporter, fallback_loop=fallback_loop, @@ -1110,6 +1210,8 @@ def build_band_crewai_tools( selected.extend(contacts) if Capability.MEMORY in active_features.capabilities: selected.extend(memories) + if Capability.FILES in active_features.capabilities: + selected.extend(files) selected = filter_tool_schemas( selected, diff --git a/src/band/integrations/slack/adapter.py b/src/band/integrations/slack/adapter.py index ba09779cb..2e83bdd5a 100644 --- a/src/band/integrations/slack/adapter.py +++ b/src/band/integrations/slack/adapter.py @@ -220,12 +220,14 @@ def get_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]] | list[Any]: """Return the base schemas plus our ``slack_send_message`` entry.""" base = super().get_tool_schemas( format, include_memory=include_memory, include_contacts=include_contacts, + include_files=include_files, ) if format == "openai": slack_schema: dict[str, Any] = { diff --git a/src/band/runtime/capabilities.py b/src/band/runtime/capabilities.py new file mode 100644 index 000000000..764b2aab6 --- /dev/null +++ b/src/band/runtime/capabilities.py @@ -0,0 +1,42 @@ +"""What optional features a deployment serves. + +A Band platform is not one platform: the SaaS node, an on-prem node whose +licence grants room files and an on-prem node whose licence does not all speak +the same API, and only the last one 404s the file endpoints. Nothing in the +SDK can know which it is talking to, so it asks — the platform publishes the +answer on ``GET /api/v1/agent/me`` under the same ``ff_*`` keys the web app and +JAM read from their own boot payload. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from band.core.types import Capability + +# The flag key that gates each SDK capability, typed once. A second spelling +# anywhere else would fail silently as "the platform turned this off". +PLATFORM_CAPABILITY_FLAGS: dict[Capability, str] = { + Capability.FILES: "ff_file_transfer", +} + + +def capabilities_the_platform_refuses( + feature_flags: Mapping[str, Any] | None, +) -> frozenset[Capability]: + """Capabilities this deployment says it does not serve. + + Only an explicit ``false`` refuses. A platform old enough to answer + nothing, or to have never heard of the capability, is not saying no — and + it may well serve the endpoints, so treating silence as a refusal would + take working tools away from a deployment that has them. + """ + if not feature_flags: + return frozenset() + + return frozenset( + capability + for capability, flag in PLATFORM_CAPABILITY_FLAGS.items() + if feature_flags.get(flag) is False + ) diff --git a/src/band/runtime/platform_runtime.py b/src/band/runtime/platform_runtime.py index 63c55bc12..21a893208 100644 --- a/src/band/runtime/platform_runtime.py +++ b/src/band/runtime/platform_runtime.py @@ -4,7 +4,7 @@ import asyncio import logging -from typing import Awaitable, Callable +from typing import Any, Awaitable, Callable from band.client.rest import DEFAULT_REQUEST_OPTIONS from band.platform.link import BandLink @@ -70,6 +70,7 @@ def __init__( self._instance_guard: SingleInstanceGuard | None = None self._agent_name: str = "" self._agent_description: str = "" + self._platform_feature_flags: dict[str, Any] | None = None self._contact_handler: ContactEventHandler | None = None self._pending_broadcasts: list[str] = [] self._contacts_subscribed: bool = False @@ -89,6 +90,15 @@ def agent_name(self) -> str: def agent_description(self) -> str: return self._agent_description + @property + def platform_feature_flags(self) -> dict[str, Any] | None: + """Optional features this deployment serves, or None if it did not say. + + Answered by the platform during the metadata fetch, so it costs no + extra round trip. See `band.runtime.capabilities`. + """ + return self._platform_feature_flags + @property def link(self) -> BandLink: if not self._link: @@ -288,6 +298,9 @@ async def _fetch_agent_metadata(self) -> None: self._agent_name = agent.name self._agent_description = agent.description + # Platforms older than the capability block send nothing here, which + # is not the same answer as "off" — see `band.runtime.capabilities`. + self._platform_feature_flags = getattr(agent, "feature_flags", None) logger.debug("Fetched metadata for agent: %s", self._agent_name) @staticmethod diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index 408d48e81..ff4d38221 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -6,7 +6,11 @@ from __future__ import annotations +import base64 +import codecs +import hashlib import logging +import re import warnings from dataclasses import dataclass from datetime import datetime @@ -102,6 +106,85 @@ def _truncate_event_content(content: str) -> str: return content[:head_len] + _EVENT_TRUNCATION_MARKER + content[-tail_len:] +def describe_tool_result_as_text(result: Any) -> Any: + """Render an MCP content result as plain text, dropping binary payloads. + + ``read_room_file`` answers images as MCP content — an ``image`` block plus a + ``text`` block — so a bridge that forwards MCP gives the model real vision + input. A bridge that does not forward it serializes whatever it is handed, + and a base64 image is roughly 4.2 million characters at the inline limit: + the model gets no picture, an unusable context, and the bill for both. + + Adapters without a vision path call this instead. The text blocks already + name the file, its type and its size, which is the useful half; the image + block becomes a line saying the picture could not be shown, so the model can + say so rather than pretend it looked. + + Anything that is not MCP content is returned untouched. + """ + if not isinstance(result, dict): + return result + + blocks = result.get("content") + if not isinstance(blocks, list): + return result + + lines: list[str] = [] + for block in blocks: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + lines.append(str(block.get("text", ""))) + elif block.get("type") == "image": + lines.append( + f"[an {block.get('mimeType', 'image')} was attached; this " + "framework cannot show it to you]" + ) + + if not lines: + return result + + return "\n".join(line for line in lines if line) + + +# Phoenix answers an unrouted path with 404 too, so "file not found" and "this +# platform has no file API" arrive identically. Telling them apart matters: the +# second is true for every id the agent will ever try, and answering it with +# "check the file_id" sends the agent back to the listing tool — which reads a +# different endpoint and keeps showing the files — for an endless retry. +# +# The discriminator is the body. Every real answer from the file routes goes +# through the API's error view and carries an "error" object; the framework's +# own not-found does not. +def _describe_404(response: Any) -> str: + try: + payload = response.json() + except Exception as error: # noqa: BLE001 - any unreadable body lands here + # A body that will not parse is the one case where the answer below is + # a guess, so say which body it was and why it could not be read. The + # body itself is never logged: this is the file-download response, so + # it may well be the file. + logger.warning( + "Could not read the body of a 404 from %s (%s: %s); " + "answering as if this platform has no agent file API", + getattr(response, "url", "the file endpoint"), + type(error).__name__, + error, + ) + payload = None + + routed = isinstance(payload, dict) and isinstance(payload.get("error"), dict) + if routed: + return ( + "No such file in this room (check the file_id with band_list_room_files)." + ) + + return ( + "This platform build has no agent file API, so files cannot be read " + "here. Ask the sender to paste what you need, and do not retry." + ) + + def _normalize_handle(value: str) -> str: """Strip leading ``@`` so ``@alice`` and ``alice`` compare equal.""" return value.lstrip("@").lower() @@ -195,6 +278,12 @@ class ToolDefinition: # --- Tool input models (single source of truth for schemas) --- +# What "a small text file" means for the tool that writes one. Unbounded, a +# model could materialise megabytes of string, encode it, and hold both copies +# before the platform's own cap ever answered. +_SEND_TEXT_MAX_CHARS = 262_144 + + class SendMessageInput(BaseModel): """Send a message to the chat room. @@ -233,6 +322,57 @@ class SendEventInput(BaseModel): ) +class ListRoomFilesInput(BaseModel): + """List files recently shared in this chat room, newest first. + + Use this when someone mentions a file ("this file", "the file I sent") + to find its file_id before reading it with band_read_room_file. You only + see files from messages that @mentioned you — if a file is missing, ask + the sender to @mention you with it. + """ + + +class ReadRoomFileInput(BaseModel): + """Read a file shared in this chat room. + + Text files return their content. Images are shown to you directly — you + can see them. Other binary formats return a description of the file. + """ + + file_id: str = Field( + ..., + description="The file id, from band_list_room_files or a message's attachments", + ) + + +class SendRoomFileInput(BaseModel): + """Create a small text file and share it in this chat room. + + The file is uploaded and attached to a new message from you. Messages + require a mention, so pass the handle of whoever you're replying to. + Prefer this over pasting a wall of text when sharing something you wrote + (a plan, a list, a report). + """ + + filename: str = Field(..., description="Name for the file, e.g. plan.txt") + text_content: str = Field( + ..., + max_length=_SEND_TEXT_MAX_CHARS, + description=( + "The file's text content. This tool is for small text files; the " + "content is held in memory and uploaded whole." + ), + ) + mention: str = Field( + ..., + description=( + "Handle of the participant to address. For users: @. " + "For agents: @/." + ), + ) + message: str = Field("", description="Optional message text to accompany the file") + + class AddParticipantInput(BaseModel): """Add a participant (agent or user) to the chat room. @@ -748,7 +888,7 @@ class ListMyPeersInput(BaseModel): # is the legacy band-mcp <=1.3.1 spelling, kept so older out-of-process servers # still match. ROOM_POSTING_TOOL_NAMES: frozenset[str] = frozenset( - {"band_send_message", "create_agent_chat_message"} + {"band_send_message", "create_agent_chat_message", "band_send_room_file"} ) @@ -784,6 +924,21 @@ def is_room_posting_tool(tool_name: str) -> bool: input_model=SendEventInput, method_name="send_event", ), + "band_list_room_files": ToolDefinition( + name="band_list_room_files", + input_model=ListRoomFilesInput, + method_name="list_room_files", + ), + "band_read_room_file": ToolDefinition( + name="band_read_room_file", + input_model=ReadRoomFileInput, + method_name="read_room_file", + ), + "band_send_room_file": ToolDefinition( + name="band_send_room_file", + input_model=SendRoomFileInput, + method_name="send_room_file", + ), "band_add_participant": ToolDefinition( name="band_add_participant", input_model=AddParticipantInput, @@ -1066,6 +1221,52 @@ def is_room_posting_tool(tool_name: str) -> bool: } ) +# File tools - explicitly listed for the same reason as contacts: gating by +# name must never depend on a substring heuristic. +FILE_TOOL_NAMES: frozenset[str] = frozenset( + { + "band_list_room_files", + "band_read_room_file", + "band_send_room_file", + } +) + +# Inline cap for text files read into a turn: enough for notes and log +# excerpts, small enough that one file can't blow out the context window. +_FILE_TEXT_INLINE_LIMIT = 16_384 + +# Images up to this size are handed to the model as vision input; larger +# ones get described instead. Vision cost scales with pixels, and anything a +# chat participant drags in sits comfortably under this. +_FILE_IMAGE_INLINE_LIMIT = 3 * 1024 * 1024 + +_FILE_TEXTLIKE_PREFIXES = ("text/", "application/json", "application/xml") + + +def _decode_utf8_head(data: bytes, limit: int) -> str: + """Decode at most *limit* bytes of text, clipping between characters. + + The cap counts bytes and the excerpt is read as characters, so a plain + slice can land inside a multi-byte one; decoding that stump ends the + excerpt in a replacement character the model reads as content. The + incremental decoder holds an incomplete trailing sequence back instead, + so the clip falls on a character boundary. Bytes that are invalid rather + than merely unfinished still become replacement characters, since those + are what the file actually says. + """ + return codecs.getincrementaldecoder("utf-8")("replace").decode(data[:limit]) + + +def _filename_from_disposition(header: str | None) -> str | None: + """Extract the filename from a Content-Disposition header, if any.""" + if not header: + return None + match = re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))', header) + if not match: + return None + return (match.group(1) or match.group(2)).strip() + + # Read-only / informational agent tools - explicitly listed (not derived by a # name heuristic) because misclassifying a write tool as read-only would weaken # the benign-empty-answer suppression in the crewai/pydantic-ai adapters. These @@ -1080,6 +1281,8 @@ def is_room_posting_tool(tool_name: str) -> bool: "band_list_contact_requests", "band_list_memories", "band_get_memory", + "band_list_room_files", + "band_read_room_file", } ) @@ -1214,21 +1417,22 @@ def missing_reply_error(framework: str, *, detail: str = "") -> str: f"{HUMAN_CONTACT_TOOL_NAMES - _ALL_DEFINITION_NAMES}" ) -BASE_TOOL_NAMES: frozenset[str] = ALL_TOOL_NAMES - MEMORY_TOOL_NAMES +BASE_TOOL_NAMES: frozenset[str] = ALL_TOOL_NAMES - MEMORY_TOOL_NAMES - FILE_TOOL_NAMES CHAT_TOOL_NAMES: frozenset[str] = BASE_TOOL_NAMES - CONTACT_TOOL_NAMES MCP_TOOL_PREFIX: str = "mcp__band__" # AdapterFeatures category for each platform tool name. Shared across adapters -# so include_categories filtering is consistent (chat/contacts/memory). +# so include_categories filtering is consistent (chat/contacts/memory/files). _TOOL_CATEGORIES: dict[str, str] = { **{name: "chat" for name in CHAT_TOOL_NAMES}, **{name: "contacts" for name in CONTACT_TOOL_NAMES}, **{name: "memory" for name in MEMORY_TOOL_NAMES}, + **{name: "files" for name in FILE_TOOL_NAMES}, } def get_band_tool_category(name: str) -> str | None: - """Return the AdapterFeatures category ("chat"/"contacts"/"memory") for a tool.""" + """Return the AdapterFeatures category ("chat"/"contacts"/"memory"/"files") for a tool.""" return _TOOL_CATEGORIES.get(name) @@ -1279,6 +1483,7 @@ def iter_tool_definitions( surface: Literal["agent", "human"] | None = "agent", include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[ToolDefinition]: """Return built-in tool definitions with optional category filtering. @@ -1308,6 +1513,10 @@ def iter_tool_definitions( ``Capability.CONTACTS``. The hub-room execution path always forces this to True regardless of adapter preference (see ``AgentTools.get_tool_schemas`` HUB_ROOM auto-enable rule). + include_files: Include room file tools (list/read/send). Default + False — gated behind ``Capability.FILES`` because the platform + endpoints they call require a deployment with file storage + configured; on one without it every call fails. """ excluded: set[str] = set() if not include_memory: @@ -1316,6 +1525,8 @@ def iter_tool_definitions( if not include_contacts: excluded |= CONTACT_TOOL_NAMES excluded |= HUMAN_CONTACT_TOOL_NAMES + if not include_files: + excluded |= FILE_TOOL_NAMES results: list[ToolDefinition] = [] for definition in TOOL_DEFINITIONS.values(): @@ -1611,6 +1822,192 @@ async def send_event( raise RuntimeError("Failed to send event - no response data") return response.data + # --- File tools --- + # + # The generated REST client does not expose the agent file endpoints yet, + # so these tools speak to them through the client's own transport (same + # base URL, auth headers, connection pool). Once the generated client + # grows an agent files resource, migrate these calls onto it. + + def _files_transport(self) -> tuple[Any, str, dict[str, str]]: + """Raw httpx client + base URL + auth headers from the REST client.""" + wrapper = self.rest._client_wrapper + return ( + wrapper.httpx_client.httpx_client, + wrapper.get_base_url().rstrip("/"), + wrapper.get_headers(), + ) + + async def list_room_files(self) -> str: + """List files shared in this room via messages addressed to this agent. + + The agent message index is delivery-scoped and filtered by delivery + status. Two views matter and neither alone is enough: the message + that triggered the current turn sits in ``processing`` until the turn + ends, while everything older is ``processed``. ``pending`` and the + unfiltered view cover not-yet-claimed backlog. + """ + http, base, headers = self._files_transport() + entries: list[dict[str, Any]] = [] + seen: set[str] = set() + # sort_order=desc because the index is oldest-first by default: without + # it these queries return the FIRST fifty messages ever addressed to + # this agent, and reversing a page of the oldest cannot recover the + # newest. The platform accepts the parameter on the cursor path, which + # is the path `limit` selects. + for query in ( + "?limit=50&sort_order=desc&status=processing", + "?limit=50&sort_order=desc&status=processed", + "?limit=50&sort_order=desc&status=pending", + "?limit=50&sort_order=desc", + ): + response = await http.get( + f"{base}/api/v1/agent/chats/{self.room_id}/messages{query}", + headers=headers, + ) + response.raise_for_status() + for message in response.json().get("data", []): + if message.get("id") not in seen: + seen.add(message.get("id")) + entries.append(message) + + # Already newest-first from the server, so no reversal: reversing here + # would put the oldest of the fetched page at the top and truncation + # would then keep the wrong end. + rows: list[str] = [] + for message in entries: + for attachment in message.get("attachments") or []: + sender = message.get("sender_name") or message.get("sender_type") or "?" + excerpt = (message.get("content") or "").replace("\n", " ")[:60] + # Descriptor object on current platform builds; bare id on + # older ones. + if isinstance(attachment, dict): + rows.append( + f"file_id={attachment.get('id')} " + f"name={attachment.get('name')} " + f"type={attachment.get('content_type')} " + f"bytes={attachment.get('bytes')} " + f'from={sender} message="{excerpt}"' + ) + else: + rows.append( + f'file_id={attachment} from={sender} message="{excerpt}"' + ) + if not rows: + return ( + "No files found. You only see files from messages that " + "@mentioned you — ask the sender to @mention you with the file." + ) + return "Files in this room, newest first:\n" + "\n".join(rows[:10]) + + async def read_room_file(self, file_id: str) -> Any: + """Read a room file: text inline, images as vision input, rest described. + + Images come back as an MCP-shaped content dict (an ``image`` block + plus a ``text`` block) so bridges that forward MCP content give the + model actual vision input rather than base64 prose. + """ + http, base, headers = self._files_transport() + response = await http.get( + f"{base}/api/v1/agent/chats/{self.room_id}/files/{file_id}", + headers=headers, + ) + if response.status_code == 404: + return _describe_404(response) + response.raise_for_status() + + content_type = ( + response.headers.get("content-type", "application/octet-stream") + .split(";")[0] + .strip() + ) + name = ( + _filename_from_disposition(response.headers.get("content-disposition")) + or file_id + ) + size = len(response.content) + + if content_type.startswith(_FILE_TEXTLIKE_PREFIXES): + text = _decode_utf8_head(response.content, _FILE_TEXT_INLINE_LIMIT) + clipped = " (clipped)" if size > _FILE_TEXT_INLINE_LIMIT else "" + return f"{name} ({content_type}, {size} bytes){clipped}:\n{text}" + + if content_type.startswith("image/") and size <= _FILE_IMAGE_INLINE_LIMIT: + return { + "content": [ + { + "type": "image", + "data": base64.standard_b64encode(response.content).decode( + "ascii" + ), + "mimeType": content_type, + }, + { + "type": "text", + "text": ( + f"The image above is {name} ({content_type}, " + f"{size} bytes). Describe what you see in it." + ), + }, + ] + } + + return ( + f"{name} is a binary file ({content_type}, {size} bytes) — you " + "can't view this format, but you can tell the room its name, " + "type and size." + ) + + async def send_room_file( + self, + filename: str, + text_content: str, + mention: str, + message: str = "", + ) -> str: + """Upload a text file and attach it to a new message in this room. + + The mention is resolved client-side against the cached participants + (same as ``send_message``), so this works against platforms that + don't resolve handles server-side. + + Raises: + ValueError: If the mention handle is not found in participants. + """ + http, base, headers = self._files_transport() + body = text_content.encode("utf-8") + resolved = self._resolve_mentions([mention]) + + upload = await http.put( + f"{base}/api/v1/agent/chats/{self.room_id}/files", + headers={ + **headers, + "content-type": "text/plain", + "x-file-name": filename, + "x-file-sha256": hashlib.sha256(body).hexdigest(), + }, + content=body, + ) + upload.raise_for_status() + file_id = upload.json()["data"]["id"] + + handle = resolved[0].get("handle", mention).lstrip("@") + text = message or f"sharing {filename}" + post = await http.post( + f"{base}/api/v1/agent/chats/{self.room_id}/messages", + headers=headers, + json={ + "message": { + "content": f"@{handle} {text}", + "mentions": resolved, + "attachment_ids": [file_id], + } + }, + ) + post.raise_for_status() + logger.debug("Shared file %s (%s) in room %s", filename, file_id, self.room_id) + return f"Shared {filename} (file_id={file_id}) in the room." + async def create_chatroom(self, task_id: str | None = None) -> str: """ Create a new chat room. @@ -2334,6 +2731,7 @@ def get_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]] | list["ToolParam"]: """ Get tool schemas in provider-specific format. @@ -2369,6 +2767,7 @@ def get_tool_schemas( for definition in iter_tool_definitions( include_memory=include_memory, include_contacts=effective_include_contacts, + include_files=include_files, ): schema = definition.input_model.model_json_schema() # Remove Pydantic-specific keys @@ -2406,6 +2805,7 @@ def get_anthropic_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list["ToolParam"]: """Get tool schemas in Anthropic format (strongly typed).""" return cast( @@ -2414,6 +2814,7 @@ def get_anthropic_tool_schemas( "anthropic", include_memory=include_memory, include_contacts=include_contacts, + include_files=include_files, ), ) @@ -2422,6 +2823,7 @@ def get_openai_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: """Get tool schemas in OpenAI format (strongly typed).""" return cast( @@ -2430,6 +2832,7 @@ def get_openai_tool_schemas( "openai", include_memory=include_memory, include_contacts=include_contacts, + include_files=include_files, ), ) diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index 37ae82002..8a9d55f1e 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -78,6 +78,7 @@ def __init__( self._hub_room_id = hub_room_id self.messages_sent: list[dict[str, Any]] = [] self.events_sent: list[dict[str, Any]] = [] + self.files_shared: list[dict[str, Any]] = [] self._participants: list[dict[str, Any]] = participants or [] self._room_context: list[dict[str, Any]] = list(room_context or []) # Seeds are validated and canonicalized at seed time (not list time), @@ -359,6 +360,45 @@ def _set_memory_status( return deepcopy(memory) raise RuntimeError(f"Failed to {action} memory - no response data") + async def list_room_files(self) -> str: + """List files recorded by ``send_room_file``, newest first.""" + if not self.files_shared: + return ( + "No files found. You only see files from messages that " + "@mentioned you — ask the sender to @mention you with the file." + ) + rows = [ + f"file_id={f['id']} name={f['filename']} from={f['mention']}" + for f in reversed(self.files_shared) + ] + return "Files in this room, newest first:\n" + "\n".join(rows) + + async def read_room_file(self, file_id: str) -> Any: + """Return a shared file's text, mirroring the real inline format.""" + for f in self.files_shared: + if f["id"] == file_id: + text = f["text_content"] + return f"{f['filename']} (text/plain, {len(text)} bytes):\n{text}" + return ( + "No such file in this room (check the file_id with band_list_room_files)." + ) + + async def send_room_file( + self, filename: str, text_content: str, mention: str, message: str = "" + ) -> str: + """Record the share and answer like the real tool.""" + file_id = f"file-{len(self.files_shared)}" + self.files_shared.append( + { + "id": file_id, + "filename": filename, + "text_content": text_content, + "mention": mention, + "message": message, + } + ) + return f"Shared {filename} (file_id={file_id}) in the room." + @property def memory_contents(self) -> list[str]: """Contents of the stored memories, oldest first — a readable @@ -371,6 +411,7 @@ def get_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: return [] @@ -379,6 +420,7 @@ def get_anthropic_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: return [] @@ -387,6 +429,7 @@ def get_openai_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: return [] diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index c56b75fac..25f46ff7d 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -85,6 +85,60 @@ def test_enable_memory_tools(self): assert Capability.MEMORY in adapter.features.capabilities +class TestFileToolGating: + """The FILES capability is what puts the three room-file tools in front of + the model, so the gate itself needs a test: without one, deleting the + capability check leaves every suite green while the tools ship to agents + that never asked for them. + """ + + def test_file_tools_are_absent_by_default(self): + from band.core.types import Capability + from band.runtime.tools import FILE_TOOL_NAMES, iter_tool_definitions + + adapter = ClaudeSDKAdapter() + + assert Capability.FILES not in adapter.features.capabilities + names = {d.name for d in iter_tool_definitions()} + assert not (FILE_TOOL_NAMES & names) + + def test_the_capability_is_what_exposes_them(self): + from band.core.types import Capability + from band.runtime.tools import FILE_TOOL_NAMES, iter_tool_definitions + + from band.core.types import AdapterFeatures + + adapter = ClaudeSDKAdapter( + features=AdapterFeatures(capabilities={Capability.FILES}) + ) + + assert Capability.FILES in adapter.features.capabilities + names = { + d.name + for d in iter_tool_definitions( + include_files=Capability.FILES in adapter.features.capabilities + ) + } + assert FILE_TOOL_NAMES <= names + + @pytest.mark.asyncio + @pytest.mark.parametrize("granted", [False, True]) + async def test_the_backend_the_model_talks_to_honours_the_gate(self, granted): + """The two tests above re-derive the tool list themselves, so they pin + the registry rather than this adapter: deleting the capability check in + _create_mcp_backend leaves both green. What decides what the model can + call is the MCP backend's own allowed-tool list, so assert on that. + """ + from band.core.types import AdapterFeatures, Capability + from band.runtime.tools import FILE_TOOL_NAMES, mcp_tool_names + + features = AdapterFeatures(capabilities={Capability.FILES}) if granted else None + backend = await ClaudeSDKAdapter(features=features)._create_mcp_backend() + + file_tools = set(mcp_tool_names(FILE_TOOL_NAMES)) + assert (file_tools <= set(backend.allowed_tools)) is granted + + class TestOnStarted: """Tests for on_started() method.""" @@ -562,11 +616,14 @@ def test_band_memory_tools_list(self): "duplicate entries in BAND_MEMORY_TOOLS" ) - def test_band_all_tools_combines_base_and_memory(self): - """BAND_ALL_TOOLS should combine base and memory tools without duplicates.""" + def test_band_all_tools_combines_base_memory_and_files(self): + """BAND_ALL_TOOLS combines base, memory and file tools without duplicates.""" + from band.adapters.claude_sdk import BAND_FILE_TOOLS from band.runtime.tools import mcp_tool_names - assert set(BAND_ALL_TOOLS) == set(BAND_BASE_TOOLS) | set(BAND_MEMORY_TOOLS) + assert set(BAND_ALL_TOOLS) == ( + set(BAND_BASE_TOOLS) | set(BAND_MEMORY_TOOLS) | set(BAND_FILE_TOOLS) + ) assert len(BAND_ALL_TOOLS) == len(set(BAND_ALL_TOOLS)), "duplicate entries" assert set(BAND_ALL_TOOLS) == set(mcp_tool_names(ALL_TOOL_NAMES)), ( "BAND_ALL_TOOLS content does not match mcp_tool_names(ALL_TOOL_NAMES) — " diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index a712cac9f..f520932a3 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -55,6 +55,7 @@ from band.core.protocols import AgentToolsProtocol from band.core.types import AdapterFeatures, Capability, PlatformMessage from band.runtime.custom_tools import get_custom_tool_name +from band.runtime.tools import FILE_TOOL_NAMES def make_stream_events( @@ -597,6 +598,52 @@ def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: assert trace_capture.operations() == ["chat", "invoke_agent"] +class TestFileToolSeam: + """pydantic-ai has no MCP content path, so both halves of the gate live at + this adapter's own registration seam: whether the file tools are offered at + all, and what the reader hands back once one runs. A read that forwards the + raw result puts a base64 image — up to about 4.2 million characters at the + inline limit — into the turn, which the model cannot see and still pays for. + """ + + IMAGE_RESULT = { + "content": [ + {"type": "image", "data": "A" * 4_000, "mimeType": "image/png"}, + {"type": "text", "text": "The image above is shot.png (image/png)."}, + ] + } + + def registered_tools(self, *, granted: bool) -> dict[str, Any]: + features = AdapterFeatures(capabilities={Capability.FILES}) if granted else None + adapter = PydanticAIAdapter( + model=TestModel(), # type: ignore[arg-type] # real Agent, no network + features=features, + ) + adapter.agent_name = "TestBot" + agent = adapter._create_agent() + return { + name: tool + for toolset in agent.toolsets + for name, tool in getattr(toolset, "tools", {}).items() + } + + @pytest.mark.parametrize("granted", [False, True]) + def test_only_the_capability_puts_file_tools_on_the_agent(self, granted): + registered = FILE_TOOL_NAMES & set(self.registered_tools(granted=granted)) + assert registered == (FILE_TOOL_NAMES if granted else set()) + + @pytest.mark.asyncio + async def test_an_image_read_reaches_the_model_as_words_not_base64(self): + tools = MagicMock() + tools.read_room_file = AsyncMock(return_value=self.IMAGE_RESULT) + read = self.registered_tools(granted=True)["band_read_room_file"].function + + answer = await read(SimpleNamespace(deps=tools), "file-1") + + assert "AAAA" not in answer + assert "shot.png" in answer + + class TestOnStarted: """Tests for on_started() method.""" diff --git a/tests/framework_conformance/test_tool_name_drift.py b/tests/framework_conformance/test_tool_name_drift.py index 04ec687f0..03558c105 100644 --- a/tests/framework_conformance/test_tool_name_drift.py +++ b/tests/framework_conformance/test_tool_name_drift.py @@ -23,6 +23,7 @@ import pytest from band.adapters.claude_sdk import _CLAUDE_SDK_AVAILABLE as _HAS_CLAUDE_SDK from band.runtime.tools import ( + FILE_TOOL_NAMES, ALL_TOOL_NAMES, BASE_TOOL_NAMES, CHAT_TOOL_NAMES, @@ -92,7 +93,9 @@ def test_derives_memory_tools_from_central_registry(self): def test_shared_builder_covers_all_tools(self): """Every Band tool should be buildable for the Claude SDK adapter.""" sdk_tools = build_band_sdk_tools( - tool_definitions=iter_tool_definitions(include_memory=True), + tool_definitions=iter_tool_definitions( + include_memory=True, include_files=True + ), get_tools=lambda _room_id: None, ) found = {tool.name for tool in sdk_tools} @@ -247,8 +250,11 @@ def test_all_chat_tools_registered(self): class TestToolRegistryConsistency: """Verify the derived sets are consistent with TOOL_MODELS.""" - def test_all_equals_base_plus_memory(self): - assert ALL_TOOL_NAMES == BASE_TOOL_NAMES | MEMORY_TOOL_NAMES + def test_all_equals_base_plus_memory_plus_files(self): + assert ALL_TOOL_NAMES == BASE_TOOL_NAMES | MEMORY_TOOL_NAMES | FILE_TOOL_NAMES + + def test_no_overlap_base_files(self): + assert not (BASE_TOOL_NAMES & FILE_TOOL_NAMES) def test_base_equals_chat_plus_contact(self): assert BASE_TOOL_NAMES == CHAT_TOOL_NAMES | CONTACT_TOOL_NAMES diff --git a/tests/integrations/claude_sdk/test_mcp_content_passthrough.py b/tests/integrations/claude_sdk/test_mcp_content_passthrough.py new file mode 100644 index 000000000..9d0a56d81 --- /dev/null +++ b/tests/integrations/claude_sdk/test_mcp_content_passthrough.py @@ -0,0 +1,83 @@ +"""Bridge fixes: MCP-shaped results pass through; declared room_id survives.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import BaseModel, Field + +from band.integrations.claude_sdk.tools import ( + _build_custom_sdk_tool, + _make_result, +) + + +class TestMakeResultPassthrough: + def test_plain_values_still_wrap_as_one_text_block(self) -> None: + result = _make_result({"status": "ok"}) + assert result["content"][0]["type"] == "text" + assert json.loads(result["content"][0]["text"]) == {"status": "ok"} + + def test_mcp_shaped_content_passes_through_untouched(self) -> None: + vision = { + "content": [ + {"type": "image", "data": "aGk=", "mimeType": "image/png"}, + {"type": "text", "text": "the image above"}, + ] + } + assert _make_result(vision) is vision + + def test_content_key_holding_non_blocks_is_not_mistaken_for_mcp(self) -> None: + lookalike = {"content": ["just", "strings"]} + result = _make_result(lookalike) + assert result["content"][0]["type"] == "text" + + def test_an_empty_content_list_is_not_mistaken_for_mcp(self) -> None: + # A payload that merely happens to carry a "content" key. Passed + # through, it reaches the model as a tool result with nothing in it + # and everything else the payload said is lost. + lookalike = {"content": [], "rows": 0} + result = _make_result(lookalike) + assert json.loads(result["content"][0]["text"]) == lookalike + + +class EchoRoomInput(BaseModel): + """Echo tool that declares room_id as a real field.""" + + room_id: str = Field(description="The current chat room id") + + +class EchoPlainInput(BaseModel): + """Echo tool without a room_id field.""" + + label: str = Field(default="x") + + +class TestCustomToolRoomIdStrip: + @pytest.mark.asyncio + async def test_declared_room_id_reaches_the_handler(self) -> None: + seen: dict[str, str] = {} + + async def handler(inp: EchoRoomInput) -> str: + seen["room_id"] = inp.room_id + return "ok" + + sdk_tool = _build_custom_sdk_tool( + (EchoRoomInput, handler), include_room_id=True + ) + result = await sdk_tool.handler({"room_id": "room-7"}) + assert seen["room_id"] == "room-7" + assert "is_error" not in result + + @pytest.mark.asyncio + async def test_undeclared_room_id_is_still_stripped(self) -> None: + async def handler(inp: EchoPlainInput) -> str: + return f"label={inp.label}" + + sdk_tool = _build_custom_sdk_tool( + (EchoPlainInput, handler), include_room_id=True + ) + result = await sdk_tool.handler({"room_id": "room-7", "label": "y"}) + assert "is_error" not in result + assert "label=y" in result["content"][0]["text"] diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py new file mode 100644 index 000000000..4b751df3c --- /dev/null +++ b/tests/runtime/test_file_tools.py @@ -0,0 +1,469 @@ +"""Room file tools: gating, listing, reading (text/image/binary), sending.""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import logging +from typing import Any + +import pytest + +from band.runtime.tools import ( + FILE_TOOL_NAMES, + AgentTools, + describe_tool_result_as_text, + is_room_posting_tool, + iter_tool_definitions, +) + +ROOM_ID = "room-1" +FILE_ID = "file-1" + + +class FakeResponse: + def __init__( + self, + *, + status_code: int = 200, + content: bytes = b"", + headers: dict[str, str] | None = None, + payload: Any = None, + ) -> None: + self.status_code = status_code + self.content = content if payload is None else json.dumps(payload).encode() + self.headers = headers or {} + self._payload = payload + + def json(self) -> Any: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise AssertionError(f"unexpected HTTP {self.status_code}") + + +class FakeHttp: + """Records requests; answers from a (method, url-substring) route table.""" + + def __init__(self, routes: list[tuple[str, str, FakeResponse]]) -> None: + self.routes = routes + self.requests: list[dict[str, Any]] = [] + + async def _dispatch(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + self.requests.append({"method": method, "url": url, **kwargs}) + for route_method, fragment, response in self.routes: + if route_method == method and fragment in url: + return response + return FakeResponse(payload={"data": []}) + + async def get(self, url: str, **kwargs: Any) -> FakeResponse: + return await self._dispatch("GET", url, **kwargs) + + async def put(self, url: str, **kwargs: Any) -> FakeResponse: + return await self._dispatch("PUT", url, **kwargs) + + async def post(self, url: str, **kwargs: Any) -> FakeResponse: + return await self._dispatch("POST", url, **kwargs) + + +class FakeRest: + """Just enough client_wrapper surface for AgentTools._files_transport.""" + + class _Inner: + def __init__(self, http: FakeHttp) -> None: + self.httpx_client = http + + class _Wrapper: + def __init__(self, http: FakeHttp) -> None: + self.httpx_client = FakeRest._Inner(http) + + def get_base_url(self) -> str: + return "https://platform.test" + + def get_headers(self) -> dict[str, str]: + return {"X-API-Key": "band_a_test"} + + def __init__(self, http: FakeHttp) -> None: + self._client_wrapper = FakeRest._Wrapper(http) + + +def make_tools( + routes: list[tuple[str, str, FakeResponse]], + participants: list[dict[str, str]] | None = None, +) -> tuple[AgentTools, FakeHttp]: + http = FakeHttp(routes) + tools = AgentTools(ROOM_ID, FakeRest(http), participants=participants or []) + return tools, http + + +def schema_name(schema: dict[str, Any]) -> str: + """The tool name, from either provider's schema shape.""" + return schema.get("name") or schema["function"]["name"] + + +class TestGating: + def test_file_tools_are_off_by_default(self) -> None: + names = {d.name for d in iter_tool_definitions()} + assert not (FILE_TOOL_NAMES & names) + + def test_include_files_exposes_all_three(self) -> None: + names = {d.name for d in iter_tool_definitions(include_files=True)} + assert FILE_TOOL_NAMES <= names + + def test_send_room_file_counts_as_a_room_post(self) -> None: + assert is_room_posting_tool("band_send_room_file") + + @pytest.mark.parametrize("helper", ["anthropic", "openai"]) + def test_the_typed_schema_helpers_gate_files_like_every_other_caller( + self, helper: str + ) -> None: + """The typed helpers are the front door for the anthropic and gemini + adapters. Without the switch they cannot express what the capability + decided, so an adapter granted FILES still gets no file tools and one + that was refused them has no gate of its own to fail closed on. + """ + tools, _ = make_tools([]) + get_schemas = getattr(tools, f"get_{helper}_tool_schemas") + + def names(**kwargs: bool) -> set[str]: + return {schema_name(schema) for schema in get_schemas(**kwargs)} + + assert not (FILE_TOOL_NAMES & names()) + assert FILE_TOOL_NAMES <= names(include_files=True) + + +class TestListRoomFiles: + @pytest.mark.asyncio + async def test_queries_every_delivery_status_view(self) -> None: + tools, http = make_tools([]) + await tools.list_room_files() + queries = [request["url"].split("?")[-1] for request in http.requests] + assert queries == [ + "limit=50&sort_order=desc&status=processing", + "limit=50&sort_order=desc&status=processed", + "limit=50&sort_order=desc&status=pending", + "limit=50&sort_order=desc", + ] + + @pytest.mark.asyncio + async def test_renders_descriptors_and_dedupes_across_views(self) -> None: + message = { + "id": "m1", + "sender_name": "Bob", + "content": "here you go", + "attachments": [ + { + "id": FILE_ID, + "name": "notes.txt", + "content_type": "text/plain", + "bytes": 42, + } + ], + } + response = FakeResponse(payload={"data": [message]}) + tools, _ = make_tools( + [ + ("GET", "status=processing", response), + ("GET", "status=processed", response), + ] + ) + listing = await tools.list_room_files() + assert listing.count(f"file_id={FILE_ID}") == 1 + assert "name=notes.txt" in listing + assert "from=Bob" in listing + + @pytest.mark.asyncio + async def test_bare_id_attachments_from_older_platforms_still_render(self) -> None: + message = {"id": "m1", "sender_type": "User", "attachments": [FILE_ID]} + tools, _ = make_tools( + [("GET", "status=processed", FakeResponse(payload={"data": [message]}))] + ) + listing = await tools.list_room_files() + assert f"file_id={FILE_ID}" in listing + + @pytest.mark.asyncio + async def test_empty_room_explains_mention_scoping(self) -> None: + tools, _ = make_tools([]) + assert "@mention" in await tools.list_room_files() + + @pytest.mark.asyncio + async def test_asks_the_platform_for_the_newest_page(self) -> None: + """The listing says "newest first", so it has to ask for newest first. + + The agent message index is oldest-first by default, so a bare + ``limit=50`` returns the FIRST fifty messages ever addressed to this + agent. In any room past that, ``reversed()`` cannot recover what was + never fetched: the tool's own primary use — find the file someone just + sent me — silently returns the oldest files instead. + """ + tools, http = make_tools([]) + await tools.list_room_files() + + for request in http.requests: + assert "sort_order=desc" in request["url"], ( + f"listing asked for the oldest page: {request['url']}" + ) + + @pytest.mark.asyncio + async def test_keeps_the_newest_files_when_truncating(self) -> None: + """Ten rows are shown, and they must be the ten newest. + + With a descending fetch the newest message arrives first, so the rows + are already in the right order and truncation keeps the right end. + """ + messages = [ + { + "id": f"m{index}", + "sender_name": "Bob", + "content": f"file {index}", + "attachments": [{"id": f"file-{index}", "name": f"{index}.txt"}], + } + # Newest first, the way a descending index answers. + for index in range(20, 0, -1) + ] + tools, _ = make_tools( + [("GET", "status=processed", FakeResponse(payload={"data": messages}))] + ) + + listing = await tools.list_room_files() + + assert "file-20" in listing, "the newest file was truncated away" + assert "file-1 " not in listing + + +class TestReadRoomFile: + @pytest.mark.asyncio + async def test_text_file_returns_named_inline_content(self) -> None: + response = FakeResponse( + content=b"the cheese is in the cupboard", + headers={ + "content-type": "text/plain; charset=utf-8", + "content-disposition": 'attachment; filename="secret.txt"', + }, + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", response)]) + result = await tools.read_room_file(FILE_ID) + assert result.startswith("secret.txt (text/plain, 29 bytes)") + assert "cupboard" in result + + @pytest.mark.asyncio + async def test_a_clipped_text_file_does_not_split_a_character(self) -> None: + """The inline cap counts bytes; the excerpt is read as characters. + + A file of multi-byte text — anything not written in English, or any + log with a checkmark in it — has no reason to put a character boundary + exactly on the cap, so cutting there leaves a broken byte the decoder + turns into U+FFFD. The model then reads a corrupted last character as + if it were content. + """ + # Each of these is three bytes; the cap falls one byte into one. + body = "✓".encode() * 20_000 + response = FakeResponse(content=body, headers={"content-type": "text/plain"}) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", response)]) + + result = await tools.read_room_file(FILE_ID) + + assert "(clipped)" in result + assert "�" not in result + + @pytest.mark.asyncio + async def test_image_returns_mcp_vision_content(self) -> None: + response = FakeResponse( + content=b"\x89PNG fake bytes", + headers={"content-type": "image/png"}, + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", response)]) + result = await tools.read_room_file(FILE_ID) + assert isinstance(result, dict) + image_block = result["content"][0] + assert image_block["type"] == "image" + assert image_block["mimeType"] == "image/png" + + @pytest.mark.asyncio + async def test_unknown_binary_is_described_not_dumped(self) -> None: + response = FakeResponse( + content=b"%PDF-1.7 ...", + headers={"content-type": "application/pdf"}, + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", response)]) + result = await tools.read_room_file(FILE_ID) + assert "application/pdf" in result + assert "%PDF" not in result + + @pytest.mark.asyncio + async def test_missing_file_points_at_the_listing_tool(self) -> None: + # The platform's own shape for this: the file route answers through the + # API error view, so a genuine miss carries an "error" object. That body + # is what tells a missing FILE apart from a missing file API. + tools, _ = make_tools( + [ + ( + "GET", + f"/files/{FILE_ID}", + FakeResponse( + status_code=404, + payload={ + "error": { + "code": "not_found", + "message": "Resource not found", + } + }, + ), + ) + ] + ) + assert "band_list_room_files" in await tools.read_room_file(FILE_ID) + + +class TestSendRoomFile: + PARTICIPANTS = [{"id": "u-bob", "handle": "bob", "name": "Bob"}] + + def routes(self) -> list[tuple[str, str, FakeResponse]]: + return [ + ("PUT", "/files", FakeResponse(payload={"data": {"id": FILE_ID}})), + ("POST", "/messages", FakeResponse(payload={"data": {"id": "m9"}})), + ] + + @pytest.mark.asyncio + async def test_uploads_then_attaches_with_resolved_mention(self) -> None: + tools, http = make_tools(self.routes(), participants=self.PARTICIPANTS) + result = await tools.send_room_file("plan.txt", "step 1: cheese", "@bob") + assert FILE_ID in result + + upload, post = http.requests + body = upload["content"] + assert upload["headers"]["x-file-name"] == "plan.txt" + assert upload["headers"]["x-file-sha256"] == hashlib.sha256(body).hexdigest() + + message = post["json"]["message"] + assert message["attachment_ids"] == [FILE_ID] + assert message["mentions"][0]["id"] == "u-bob" + assert message["content"].startswith("@bob ") + + @pytest.mark.asyncio + async def test_unknown_mention_fails_before_any_upload(self) -> None: + tools, http = make_tools(self.routes(), participants=self.PARTICIPANTS) + with pytest.raises(ValueError): + await tools.send_room_file("plan.txt", "text", "@nobody") + assert http.requests == [] + + +class TestImageResultsOnNonVisionAdapters: + """An image read returns MCP content so bridges can give the model vision. + + Only the Claude bridge forwards that shape. CrewAI json-dumps whatever the + tool returns and pydantic-ai stringifies it, so on those adapters the base64 + payload — up to the 3 MiB inline limit, about 4.2 million characters once + encoded — lands in the model's context as prose. The shared tool description + tells the model "Images are shown to you directly", which is true on exactly + one of the four adapters that now advertise Capability.FILES. + """ + + def test_text_rendering_keeps_the_description_and_drops_the_payload(self) -> None: + image_result = { + "content": [ + {"type": "image", "data": "A" * 4_000_000, "mimeType": "image/png"}, + { + "type": "text", + "text": "The image above is shot.png (image/png, 3000000 bytes). " + "Describe what you see in it.", + }, + ] + } + + rendered = describe_tool_result_as_text(image_result) + + assert "AAAA" not in rendered + assert "shot.png" in rendered + assert "image/png" in rendered + + +class TestRoomPostingClassification: + """A file share posts to the room, so it has to count as a reply. + + ``replied`` gates the adapter's "the agent said nothing this turn" error. + Keying it to one tool name means an agent that answers by sharing a file — + a real, room-visible response — is reported as having produced no reply, + and the room gets a spurious error after a successful share. + ``is_room_posting_tool`` already knows better and is the shared vocabulary + for exactly this question. + """ + + def test_the_reply_gate_uses_the_shared_room_posting_vocabulary(self) -> None: + from band.integrations.crewai import tools as crewai_tools + + for name in ("band_send_message", "band_send_room_file"): + assert is_room_posting_tool(name) + + source = inspect.getsource(crewai_tools._execute_tool) + assert "is_room_posting_tool" in source, ( + "the reply gate compares against a single tool name instead of the " + "shared room-posting vocabulary" + ) + + +class TestMissingFileApi: + """A platform without the agent file routes is not a missing file. + + Phoenix answers an unrouted path with 404, and read_room_file maps every + 404 to "no such file in this room". On a deployment where the file API is + not present that answer is false for every id the agent tries, so the agent + concludes each file is gone rather than that it cannot fetch files at all — + and the listing tool, which reads a different endpoint, keeps showing them. + """ + + @pytest.mark.asyncio + async def test_a_missing_route_is_not_reported_as_a_missing_file(self) -> None: + # Phoenix's own 404 body, which carries no FallbackController shape. + route_404 = FakeResponse( + status_code=404, + payload={"errors": {"detail": "Not Found"}}, + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", route_404)]) + + answer = await tools.read_room_file(FILE_ID) + + assert "band_list_room_files" not in answer + assert "file api" in answer.lower() or "not available" in answer.lower() + + @pytest.mark.asyncio + async def test_an_unreadable_404_body_says_so_without_quoting_it( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Which of the two 404s this is comes from the body, so a body that + will not parse is the one case the answer is a guess. Silently guessing + leaves an operator with an agent claiming the deployment has no file API + and nothing to explain why. The body itself stays out of the log: this + is the file-download response, so it can be the file. + """ + + class UnreadableBody(FakeResponse): + def json(self) -> Any: + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + response = UnreadableBody( + status_code=404, content=b"\x89PNG confidential pixels" + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", response)]) + + with caplog.at_level(logging.WARNING): + answer = await tools.read_room_file(FILE_ID) + + assert "band_list_room_files" not in answer + logged = "\n".join(record.getMessage() for record in caplog.records) + assert "ValueError" in logged + assert "confidential" not in logged + + @pytest.mark.asyncio + async def test_a_real_missing_file_still_points_at_the_listing(self) -> None: + file_404 = FakeResponse( + status_code=404, + payload={"error": {"code": "not_found", "message": "File not found"}}, + ) + tools, _ = make_tools([("GET", f"/files/{FILE_ID}", file_404)]) + + answer = await tools.read_room_file(FILE_ID) + + assert "band_list_room_files" in answer diff --git a/tests/runtime/test_platform_capabilities.py b/tests/runtime/test_platform_capabilities.py new file mode 100644 index 000000000..33d33ec11 --- /dev/null +++ b/tests/runtime/test_platform_capabilities.py @@ -0,0 +1,189 @@ +"""What a deployment serves, and what the SDK does when it serves less.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest + +from band.agent import Agent +from band_rest import AgentMe +from band.core.simple_adapter import SimpleAdapter +from band.core.types import AdapterFeatures, Capability +from band.runtime.capabilities import ( + PLATFORM_CAPABILITY_FLAGS, + capabilities_the_platform_refuses, +) + + +class RecordingAdapter(SimpleAdapter[Any]): + """Remembers the capability set it was started with.""" + + SUPPORTED_CAPABILITIES = frozenset({Capability.FILES, Capability.MEMORY}) + + def __init__(self, features: AdapterFeatures) -> None: + super().__init__(features=features) + self.capabilities_at_start: frozenset[Capability] | None = None + + async def on_started(self, agent_name: str, agent_description: str) -> None: + await super().on_started(agent_name, agent_description) + self.capabilities_at_start = self.features.capabilities + + async def on_message(self, *args: Any, **kwargs: Any) -> None: ... + + +def make_agent( + *, capabilities: set[Capability], platform_flags: dict[str, bool] | None +) -> tuple[Agent, RecordingAdapter]: + adapter = RecordingAdapter(AdapterFeatures(capabilities=capabilities)) + runtime = AsyncMock() + # Sync on the real runtime; left async it emits a never-awaited warning. + runtime.claim_single_instance = Mock() + runtime.release_single_instance = Mock() + runtime.agent_name = "Tester" + runtime.agent_description = "Asks what the deployment serves" + runtime.platform_feature_flags = platform_flags + return Agent(runtime=runtime, adapter=adapter), adapter # type: ignore[arg-type] + + +class TestReadingWhatThePlatformServes: + """One SDK build meets three kinds of node, so it has to ask. + + A SaaS node, an on-prem node granted room files and an on-prem node + without them all answer ``GET /api/v1/agent/me``; only the payload differs. + The flag keys are the platform's own — the same ``ff_*`` names the SPA and + JAM read — so all three clients share one vocabulary. + """ + + def test_a_capability_the_deployment_turned_off_is_refused(self) -> None: + refused = capabilities_the_platform_refuses({"ff_file_transfer": False}) + + assert refused == frozenset({Capability.FILES}) + + def test_a_capability_the_deployment_serves_is_not_refused(self) -> None: + assert ( + capabilities_the_platform_refuses({"ff_file_transfer": True}) == frozenset() + ) + + def test_a_platform_that_never_answered_refuses_nothing(self) -> None: + """Silence is not a "no", and treating it as one breaks working agents. + + A platform predating the capability block still serves the file + endpoints. Reading its silence as a refusal would strip the tools from + an agent whose deployment works today, so only an explicit ``false`` + counts. + """ + assert capabilities_the_platform_refuses(None) == frozenset() + + def test_a_platform_that_never_heard_of_the_capability_refuses_nothing( + self, + ) -> None: + assert capabilities_the_platform_refuses({"ff_block_user": True}) == frozenset() + + def test_every_sdk_capability_the_platform_can_gate_names_its_flag(self) -> None: + # The flag key is typed once here and nowhere else; a second spelling + # elsewhere would fail silently as "capability off". + assert PLATFORM_CAPABILITY_FLAGS[Capability.FILES] == "ff_file_transfer" + + +class TestTheGeneratedClientCarriesTheAnswer: + """The REST client is generated, and it was generated before this field. + + The capability read only works because the generated models accept extra + keys. Regenerate them with `extra="forbid"` and the block is dropped in + transit: every deployment then looks like one that said nothing, file tools + stay advertised against nodes that 404 them, and nothing anywhere errors. + """ + + def test_a_field_the_client_predates_survives_the_model(self) -> None: + agent = AgentMe.model_validate( + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Capability Probe", + "handle": "eric/probe", + "description": "Asks what this deployment serves", + "owner_uuid": "7fa85f64-5717-4562-b3fc-2c963f66afa6", + "inserted_at": "2026-08-10T10:30:00Z", + "updated_at": "2026-08-10T10:30:00Z", + "feature_flags": {"ff_file_transfer": False}, + } + ) + + flags = getattr(agent, "feature_flags", None) + + assert capabilities_the_platform_refuses(flags) == frozenset({Capability.FILES}) + + +class TestDroppingRefusedCapabilities: + def test_a_refused_capability_leaves_the_others_alone(self) -> None: + features = AdapterFeatures( + capabilities={Capability.FILES, Capability.MEMORY}, + emit=(), + exclude_tools=("band_send_event",), + ) + + pruned = features.without_capabilities({Capability.FILES}) + + assert pruned.capabilities == frozenset({Capability.MEMORY}) + assert pruned.exclude_tools == ("band_send_event",) + + def test_dropping_nothing_returns_an_equal_set(self) -> None: + features = AdapterFeatures(capabilities={Capability.FILES}) + + assert features.without_capabilities(frozenset()).capabilities == frozenset( + {Capability.FILES} + ) + + +class TestAgentStartupHonoursThePlatform: + """The operator's opt-in says "this agent may use files"; it cannot say + "this deployment has them". When the two disagree the deployment wins, and + it has to win before the adapter builds its tool list — an agent that + advertises a tool the platform will 404 spends real model turns finding out. + """ + + @pytest.mark.asyncio + async def test_a_deployment_without_files_takes_the_file_tools_away(self) -> None: + agent, adapter = make_agent( + capabilities={Capability.FILES, Capability.MEMORY}, + platform_flags={"ff_file_transfer": False}, + ) + + await agent.start() + + assert adapter.features.capabilities == frozenset({Capability.MEMORY}) + + @pytest.mark.asyncio + async def test_a_deployment_with_files_keeps_them(self) -> None: + agent, adapter = make_agent( + capabilities={Capability.FILES}, + platform_flags={"ff_file_transfer": True}, + ) + + await agent.start() + + assert Capability.FILES in adapter.features.capabilities + + @pytest.mark.asyncio + async def test_a_platform_that_says_nothing_changes_nothing(self) -> None: + agent, adapter = make_agent( + capabilities={Capability.FILES}, platform_flags=None + ) + + await agent.start() + + assert Capability.FILES in adapter.features.capabilities + + @pytest.mark.asyncio + async def test_the_adapter_sees_the_pruned_set_when_it_starts(self) -> None: + # Ordering is the whole point: claude_sdk builds its MCP tool list + # inside on_started, so a prune that lands afterwards changes nothing. + agent, adapter = make_agent( + capabilities={Capability.FILES}, + platform_flags={"ff_file_transfer": False}, + ) + + await agent.start() + + assert adapter.capabilities_at_start == frozenset() diff --git a/tests/runtime/test_tool_definitions.py b/tests/runtime/test_tool_definitions.py index 0f5ad22f4..a494777e5 100644 --- a/tests/runtime/test_tool_definitions.py +++ b/tests/runtime/test_tool_definitions.py @@ -137,6 +137,9 @@ def test_all_tools_registered(self): "band_get_memory", "band_supersede_memory", "band_archive_memory", + "band_list_room_files", + "band_read_room_file", + "band_send_room_file", } assert set(TOOL_MODELS.keys()) == expected