From b1ab2f6e1530de6c486016501dcd2cb7c4294481 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Fri, 7 Aug 2026 20:15:28 +0800 Subject: [PATCH 01/20] feat: room file tools behind Capability.FILES Adds three built-in agent tools for the platform's file-transfer surface: band_list_room_files, band_read_room_file, band_send_room_file. They speak to the agent file endpoints through the REST client's own transport (the generated client does not expose them yet) and are gated behind the new Capability.FILES, default off, because the endpoints require a deployment with file storage configured. read_room_file returns images as MCP-shaped content so runtimes that forward MCP blocks give the model real vision input; the Claude SDK bridge now passes such results through instead of json-dumping them into a text block, and stops stripping a custom tool's room_id argument when the input model declares that field as part of its own contract. CrewAI and PydanticAI get concrete wrappers; the tool-family registries, prompts drift checks, protocol, and testing fake are extended accordingly. Tool-name sets treat files like memory: excluded from BASE/CHAT, included in ALL. Co-Authored-By: Claude Fable 5 --- src/band/adapters/claude_sdk.py | 10 +- src/band/adapters/crewai.py | 2 +- src/band/adapters/crewai_flow.py | 4 +- src/band/adapters/pydantic_ai.py | 45 ++- src/band/core/protocols.py | 15 + src/band/core/types.py | 1 + src/band/integrations/claude_sdk/tools.py | 32 +- src/band/integrations/crewai/tools.py | 98 +++++- src/band/integrations/slack/adapter.py | 2 + src/band/runtime/tools.py | 297 +++++++++++++++++- src/band/testing/fake_tools.py | 40 +++ tests/adapters/test_claude_sdk_adapter.py | 9 +- .../test_tool_name_drift.py | 12 +- .../test_mcp_content_passthrough.py | 75 +++++ tests/runtime/test_file_tools.py | 243 ++++++++++++++ tests/runtime/test_tool_definitions.py | 3 + 16 files changed, 867 insertions(+), 21 deletions(-) create mode 100644 tests/integrations/claude_sdk/test_mcp_content_passthrough.py create mode 100644 tests/runtime/test_file_tools.py 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..034cdcbce 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -217,7 +217,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 +677,49 @@ 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: + return 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/core/protocols.py b/src/band/core/protocols.py index d1b1b9f84..f5fcabf3b 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -122,10 +122,25 @@ 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 ) -> list["ToolParam"]: diff --git a/src/band/core/types.py b/src/band/core/types.py index 32ba5e878..0ce0f5d95 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): diff --git a/src/band/integrations/claude_sdk/tools.py b/src/band/integrations/claude_sdk/tools.py index 8b7d2e74c..d7576da35 100644 --- a/src/band/integrations/claude_sdk/tools.py +++ b/src/band/integrations/claude_sdk/tools.py @@ -72,8 +72,27 @@ 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.""" + return ( + isinstance(data, dict) + and isinstance(data.get("content"), list) + and all( + isinstance(block, dict) and "type" in block for block in data["content"] + ) + ) + + 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 +265,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 +279,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..fb974dcdd 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -94,6 +94,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", } @@ -498,12 +501,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 +993,66 @@ 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} + ) + result = 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 +1076,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 +1181,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 +1200,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/tools.py b/src/band/runtime/tools.py index 408d48e81..dbaa950f1 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -6,7 +6,10 @@ from __future__ import annotations +import base64 +import hashlib import logging +import re import warnings from dataclasses import dataclass from datetime import datetime @@ -233,6 +236,50 @@ 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(..., description="The file's text content") + 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 +795,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 +831,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 +1128,38 @@ 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 _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 +1174,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 +1310,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 +1376,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 +1406,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 +1418,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 +1715,189 @@ 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() + for query in ( + "?limit=50&status=processing", + "?limit=50&status=processed", + "?limit=50&status=pending", + "?limit=50", + ): + 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) + + rows: list[str] = [] + for message in reversed(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 ( + "No such file in this room " + "(check the file_id with band_list_room_files)." + ) + 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 = response.content[:_FILE_TEXT_INLINE_LIMIT].decode( + "utf-8", errors="replace" + ) + 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 +2621,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 +2657,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 diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index 37ae82002..531a91547 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 diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index c56b75fac..5bb8ef653 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -562,11 +562,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/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..7181a28db --- /dev/null +++ b/tests/integrations/claude_sdk/test_mcp_content_passthrough.py @@ -0,0 +1,75 @@ +"""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" + + +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..ac097e1ba --- /dev/null +++ b/tests/runtime/test_file_tools.py @@ -0,0 +1,243 @@ +"""Room file tools: gating, listing, reading (text/image/binary), sending.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pytest + +from band.runtime.tools import ( + FILE_TOOL_NAMES, + AgentTools, + 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 + + +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") + + +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&status=processing", + "limit=50&status=processed", + "limit=50&status=pending", + "limit=50", + ] + + @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() + + +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_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: + tools, _ = make_tools( + [("GET", f"/files/{FILE_ID}", FakeResponse(status_code=404))] + ) + 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 == [] 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 From f4808c1650e7b0b4e582887b225a7311d1389d34 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:34:28 +0800 Subject: [PATCH 02/20] =?UTF-8?q?test(files):=20RED=20=E2=80=94=20the=20ro?= =?UTF-8?q?om=20listing=20never=20reaches=20the=20newest=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent messages index is oldest-first and offers no descending order, so taking one page per delivery status returns the OLDEST messages in the room. In any room with more than a page of history the newest files — the ones an agent is nearly always asking about — are exactly the ones missing, and reversing a page of the oldest messages cannot recover them. The tool's own header says 'newest first'. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_file_tools.py | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index ac097e1ba..e1b01c692 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -109,6 +109,56 @@ def test_send_room_file_counts_as_a_room_post(self) -> None: class TestListRoomFiles: + @pytest.mark.asyncio + async def test_reaches_the_newest_files_past_the_first_page(self) -> None: + """The agent messages index is oldest-first and offers no descending order. + + Taking one page per status therefore returns the OLDEST messages in the + room, so in any room with more than a page of history the newest files — + the ones an agent is nearly always asking about — are the ones missing. + Reversing a page of the oldest messages cannot recover them. + """ + oldest = { + "id": "m-old", + "sender_name": "Bob", + "content": "ancient history", + "attachments": [ + {"id": "file-old", "name": "old.txt", "content_type": "text/plain", "bytes": 1} + ], + } + newest = { + "id": "m-new", + "sender_name": "Bob", + "content": "the one you want", + "attachments": [ + {"id": "file-new", "name": "new.txt", "content_type": "text/plain", "bytes": 2} + ], + } + + class Paging(FakeHttp): + """Page one is the oldest and says there is more; page two is newest.""" + + async def _dispatch(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + self.requests.append({"method": method, "url": url, **kwargs}) + if "cursor=" in url: + return FakeResponse( + payload={"data": [newest], "metadata": {"has_more": False}} + ) + return FakeResponse( + payload={ + "data": [oldest], + "metadata": {"has_more": True, "next_cursor": "c1"}, + } + ) + + http = Paging([]) + tools = AgentTools(ROOM_ID, FakeRest(http), participants=[]) + + rendered = await tools.list_room_files() + + assert "file-new" in rendered + assert "new.txt" in rendered + @pytest.mark.asyncio async def test_queries_every_delivery_status_view(self) -> None: tools, http = make_tools([]) @@ -162,6 +212,50 @@ 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 From d9599a2b63672383dc32d16e546b8d86d3966450 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:39:31 +0800 Subject: [PATCH 03/20] =?UTF-8?q?fix(files):=20GREEN=20=E2=80=94=20ask=20t?= =?UTF-8?q?he=20platform=20for=20the=20newest=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index is oldest-first by default, so the four listing queries returned the FIRST fifty messages ever addressed to this agent and reversing that page could not recover the newest — the tool's primary use, 'find the file someone just sent me', silently answered with the oldest files. Two tests in this repo have asserted sort_order=desc since the feature commit and have been red ever since; the platform now accepts the parameter on the cursor path. With the server returning newest-first the local reversal is removed: it would put the oldest of the page on top and truncation would keep the wrong end. The paging test added alongside this work is retired — the two sort_order tests already pin the contract, and walking cursors is no longer how the newest page is reached. Co-Authored-By: Claude Opus 5 --- src/band/runtime/tools.py | 18 +++++++--- tests/runtime/test_file_tools.py | 58 +++----------------------------- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index dbaa950f1..508f1a7fd 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -1743,11 +1743,16 @@ async def list_room_files(self) -> str: 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&status=processing", - "?limit=50&status=processed", - "?limit=50&status=pending", - "?limit=50", + "?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}", @@ -1759,8 +1764,11 @@ async def list_room_files(self) -> str: 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 reversed(entries): + 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] diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index e1b01c692..3dccb045c 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -109,66 +109,16 @@ def test_send_room_file_counts_as_a_room_post(self) -> None: class TestListRoomFiles: - @pytest.mark.asyncio - async def test_reaches_the_newest_files_past_the_first_page(self) -> None: - """The agent messages index is oldest-first and offers no descending order. - - Taking one page per status therefore returns the OLDEST messages in the - room, so in any room with more than a page of history the newest files — - the ones an agent is nearly always asking about — are the ones missing. - Reversing a page of the oldest messages cannot recover them. - """ - oldest = { - "id": "m-old", - "sender_name": "Bob", - "content": "ancient history", - "attachments": [ - {"id": "file-old", "name": "old.txt", "content_type": "text/plain", "bytes": 1} - ], - } - newest = { - "id": "m-new", - "sender_name": "Bob", - "content": "the one you want", - "attachments": [ - {"id": "file-new", "name": "new.txt", "content_type": "text/plain", "bytes": 2} - ], - } - - class Paging(FakeHttp): - """Page one is the oldest and says there is more; page two is newest.""" - - async def _dispatch(self, method: str, url: str, **kwargs: Any) -> FakeResponse: - self.requests.append({"method": method, "url": url, **kwargs}) - if "cursor=" in url: - return FakeResponse( - payload={"data": [newest], "metadata": {"has_more": False}} - ) - return FakeResponse( - payload={ - "data": [oldest], - "metadata": {"has_more": True, "next_cursor": "c1"}, - } - ) - - http = Paging([]) - tools = AgentTools(ROOM_ID, FakeRest(http), participants=[]) - - rendered = await tools.list_room_files() - - assert "file-new" in rendered - assert "new.txt" in rendered - @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&status=processing", - "limit=50&status=processed", - "limit=50&status=pending", - "limit=50", + "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 From f4578e64ae7782d2684e9271f122c39a9c393d28 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:40:15 +0800 Subject: [PATCH 04/20] =?UTF-8?q?test(files):=20RED=20=E2=80=94=20an=20ima?= =?UTF-8?q?ge=20read=20becomes=20base64=20prose=20on=20three=20of=20four?= =?UTF-8?q?=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_room_file returns MCP content so a bridge can hand the model real vision input. Only the Claude bridge forwards that shape: CrewAI json-dumps the tool result and pydantic-ai stringifies it, so up to the 3 MiB inline limit — about 4.2 million characters once base64-encoded — arrives in the model's context as prose. The shared tool description promises 'Images are shown to you directly' on all four adapters that now advertise Capability.FILES. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_file_tools.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index 3dccb045c..2f95f3702 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -11,6 +11,7 @@ from band.runtime.tools import ( FILE_TOOL_NAMES, AgentTools, + describe_tool_result_as_text, is_room_posting_tool, iter_tool_definitions, ) @@ -285,3 +286,33 @@ async def test_unknown_mention_fails_before_any_upload(self) -> None: 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 From e3d4ab1c3cc5d653d0c5b74f58fe194fb4e6d81e Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:42:24 +0800 Subject: [PATCH 05/20] =?UTF-8?q?fix(files):=20GREEN=20=E2=80=94=20describ?= =?UTF-8?q?e=20images=20on=20adapters=20that=20cannot=20show=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe_tool_result_as_text renders MCP content as plain text, keeping the text blocks (which already name the file, its type and its size) and replacing the image block with a line saying the picture cannot be shown here. CrewAI and pydantic-ai now call it around read_room_file. Both serialize whatever the tool returns, so the base64 payload — about 4.2 million characters at the 3 MiB inline limit — used to arrive in the model's context as prose: no picture, an unusable context, and the bill for both. The Claude bridge keeps the MCP shape and keeps real vision. Co-Authored-By: Claude Opus 5 --- src/band/adapters/pydantic_ai.py | 7 ++++- src/band/integrations/crewai/tools.py | 7 ++++- src/band/runtime/tools.py | 41 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 034cdcbce..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__) @@ -696,7 +697,11 @@ async def band_read_room_file( file_id: str, ) -> Any: try: - return await ctx.deps.read_room_file(file_id) + # 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}" diff --git a/src/band/integrations/crewai/tools.py b/src/band/integrations/crewai/tools.py index fb974dcdd..435d90ba5 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -72,6 +72,7 @@ get_tool_description, is_terminal_success, serialize_tool_result, + describe_tool_result_as_text, ) logger = logging.getLogger(__name__) @@ -1021,7 +1022,11 @@ async def execute(tools: AgentToolsProtocol) -> str: await reporter.report_call( tools, "band_read_room_file", {"file_id": file_id} ) - result = await tools.read_room_file(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) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index 508f1a7fd..f6419d60f 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -105,6 +105,47 @@ 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) + + def _normalize_handle(value: str) -> str: """Strip leading ``@`` so ``@alice`` and ``alice`` compare equal.""" return value.lstrip("@").lower() From b48c2a5142e32518cf1370f82f237be125ea237e Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:42:56 +0800 Subject: [PATCH 06/20] =?UTF-8?q?test(crewai):=20RED=20=E2=80=94=20sharing?= =?UTF-8?q?=20a=20file=20is=20not=20counted=20as=20replying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replied gates the adapter's 'the agent said nothing this turn' error, and it is keyed to the single name band_send_message. band_send_room_file posts an attachment message to the room — a real, room-visible response — so an agent that answers by sharing a file is reported as having produced no reply, and the room gets a spurious error after a successful share. is_room_posting_tool already knows the answer and is the shared vocabulary for exactly this question. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_file_tools.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index 2f95f3702..36cd92f65 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import inspect import json from typing import Any @@ -316,3 +317,27 @@ def test_text_rendering_keeps_the_description_and_drops_the_payload(self) -> Non 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" + ) From 60b08ce38c97d317e56f7dbd8444b1264d6dc374 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:43:25 +0800 Subject: [PATCH 07/20] =?UTF-8?q?fix(crewai):=20GREEN=20=E2=80=94=20a=20fi?= =?UTF-8?q?le=20share=20counts=20as=20replying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply gate now asks is_room_posting_tool instead of comparing against one name, so band_send_room_file — which posts an attachment message to the room — stops being reported as 'the agent said nothing this turn'. That error used to land in the room right after a successful share. _SEND_MESSAGE_TOOL stays: it still names the tool whose arguments carry mentions to resolve, which is a different question from 'did this reach the room'. Co-Authored-By: Claude Opus 5 --- src/band/integrations/crewai/tools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/band/integrations/crewai/tools.py b/src/band/integrations/crewai/tools.py index 435d90ba5..80c293ba2 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -73,6 +73,7 @@ is_terminal_success, serialize_tool_result, describe_tool_result_as_text, + is_room_posting_tool, ) logger = logging.getLogger(__name__) @@ -316,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 From a9e6833d51f18596342417a083b1aff5c40878c1 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:43:50 +0800 Subject: [PATCH 08/20] =?UTF-8?q?test(files):=20RED=20=E2=80=94=20a=20plat?= =?UTF-8?q?form=20with=20no=20file=20API=20reports=20every=20file=20as=20m?= =?UTF-8?q?issing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phoenix answers an unrouted path with 404, and read_room_file maps every 404 to 'no such file in this room (check the file_id with band_list_room_files)'. On a deployment without the agent file routes that is false for every id the agent tries: it concludes each file is gone, while the listing tool — which reads a different endpoint — keeps showing them. The agent then loops list, read, list. The second test pins the discrimination: a genuine missing file must still be sent to the listing tool. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_file_tools.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index 36cd92f65..e0fcb4855 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -341,3 +341,40 @@ def test_the_reply_gate_uses_the_shared_room_posting_vocabulary(self) -> None: "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_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 From 917af3a7a1e6bca81645a1556a862dcc368fc5f7 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 15:47:05 +0800 Subject: [PATCH 09/20] =?UTF-8?q?fix(files):=20GREEN=20=E2=80=94=20a=20mis?= =?UTF-8?q?sing=20file=20API=20says=20so,=20and=20a=20text=20file=20has=20?= =?UTF-8?q?a=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two answers that were wrong in the same direction, both about telling the agent what actually happened: - A 404 from an unrouted path is no longer reported as 'no such file'. Phoenix answers an unrouted path with 404 too, so on a build without the agent file routes every id read as missing while the listing tool kept showing them — an endless list, read, list. The discriminator is the body: a real answer from the file routes goes through the API error view and carries an 'error' object, the framework's own not-found does not. - send_room_file bounds text_content at 256 KiB. Unbounded, a model could materialise megabytes of string and hold it encoded and raw before the platform's own cap ever answered. The pre-existing missing-file test carried a bodyless 404, which no real file route returns; it now uses the platform's actual error shape, which is what makes the discrimination testable. Co-Authored-By: Claude Opus 5 --- src/band/runtime/tools.py | 47 ++++++++++++++++++++++++++++---- tests/runtime/test_file_tools.py | 19 ++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index f6419d60f..a7cc66d68 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -146,6 +146,33 @@ def describe_tool_result_as_text(result: Any) -> Any: 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: # noqa: BLE001 - a non-JSON body is equally undiagnostic + 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() @@ -239,6 +266,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. @@ -310,7 +343,14 @@ 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") + 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=( @@ -1847,10 +1887,7 @@ async def read_room_file(self, file_id: str) -> Any: headers=headers, ) if response.status_code == 404: - return ( - "No such file in this room " - "(check the file_id with band_list_room_files)." - ) + return _describe_404(response) response.raise_for_status() content_type = ( diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index e0fcb4855..ff5e93176 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -250,8 +250,25 @@ async def test_unknown_binary_is_described_not_dumped(self) -> None: @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))] + [ + ( + "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) From e122a3702f014570b923cfdd79eada2b7ed21e52 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 16:01:17 +0800 Subject: [PATCH 10/20] test(claude-sdk): pin the FILES capability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability check is what puts the three room-file tools in front of a model. Nothing asserted it, so deleting the check left every suite green while the tools shipped to agents that never asked for them — and the gate is the whole opt-in story for a feature that reads a room's files. Co-Authored-By: Claude Opus 5 --- tests/adapters/test_claude_sdk_adapter.py | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 5bb8ef653..29c0c5315 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -85,6 +85,43 @@ 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 + + class TestOnStarted: """Tests for on_started() method.""" From 234694648b439a81fbc9c0ef376734fe36a4b071 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 17:22:02 +0800 Subject: [PATCH 11/20] =?UTF-8?q?test(runtime):=20RED=20=E2=80=94=20the=20?= =?UTF-8?q?SDK=20assumes=20every=20platform=20serves=20room=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability.FILES is the operator saying "this agent may use files"; it cannot say "this deployment has them". One build meets SaaS nodes, on-prem nodes granted room files and on-prem nodes without them, and today it advertises the tools to all three — so on the third the model spends real turns discovering a 404. Only an explicit false counts as a refusal: a platform that predates the capability block still serves the endpoints, and reading its silence as a no would strip the tools from a deployment that works today. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_platform_capabilities.py | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/runtime/test_platform_capabilities.py diff --git a/tests/runtime/test_platform_capabilities.py b/tests/runtime/test_platform_capabilities.py new file mode 100644 index 000000000..8c0503eb2 --- /dev/null +++ b/tests/runtime/test_platform_capabilities.py @@ -0,0 +1,153 @@ +"""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 + +import pytest + +from band.agent import Agent +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() + 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 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() From b6f5f59a4c87d23dc03beb97fef1fac248d4b899 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 17:33:16 +0800 Subject: [PATCH 12/20] =?UTF-8?q?feat(runtime):=20GREEN=20=E2=80=94=20ask?= =?UTF-8?q?=20the=20platform=20what=20it=20serves,=20once,=20at=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent already fetches its own identity at connect to validate its key, and the platform now answers that call with the optional features this deployment serves. Reading it there costs no extra round trip. A refused capability is removed from the adapter's feature set before on_started, because that is where adapters build their tool lists — and the capability set is the one seam all of them read, so dropping an entry hides its tools in every framework at once. Only an explicit false refuses. Silence is not a no: a platform that predates the capability block still serves the file endpoints. Co-Authored-By: Claude Opus 5 --- src/band/agent.py | 35 +++++++++++++++++ src/band/core/types.py | 17 +++++++++ src/band/runtime/capabilities.py | 42 +++++++++++++++++++++ src/band/runtime/platform_runtime.py | 15 +++++++- tests/runtime/test_platform_capabilities.py | 13 +++++-- 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/band/runtime/capabilities.py 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/types.py b/src/band/core/types.py index 0ce0f5d95..c8ed1b101 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -298,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/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/tests/runtime/test_platform_capabilities.py b/tests/runtime/test_platform_capabilities.py index 8c0503eb2..bb346a56e 100644 --- a/tests/runtime/test_platform_capabilities.py +++ b/tests/runtime/test_platform_capabilities.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest @@ -37,6 +37,9 @@ def make_agent( ) -> 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 @@ -58,7 +61,9 @@ def test_a_capability_the_deployment_turned_off_is_refused(self) -> None: 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() + 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. @@ -70,7 +75,9 @@ def test_a_platform_that_never_answered_refuses_nothing(self) -> None: """ assert capabilities_the_platform_refuses(None) == frozenset() - def test_a_platform_that_never_heard_of_the_capability_refuses_nothing(self) -> None: + 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: From d694f4ecccc43b485794ae83f8a741ba336840ad Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 17:35:14 +0800 Subject: [PATCH 13/20] docs: record the room file tools and how capability negotiation works The invariant that is easy to break and expensive to rediscover: the prune has to land before on_started, because that is where every adapter builds its tool list, and silence from the platform is not a refusal. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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: From 823ac2556dc422a47aed3bedef4b254be7b3594f Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Mon, 10 Aug 2026 17:38:00 +0800 Subject: [PATCH 14/20] test(runtime): pin the generated client passing the capability block through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read works only because the Fern models accept extra keys — they were generated before this field existed. Regenerated with extra="forbid" the block would be dropped in transit, every deployment would look like one that said nothing, and the file tools would stay advertised against nodes that 404 them with nothing erroring anywhere. Co-Authored-By: Claude Opus 5 --- tests/runtime/test_platform_capabilities.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/runtime/test_platform_capabilities.py b/tests/runtime/test_platform_capabilities.py index bb346a56e..33d33ec11 100644 --- a/tests/runtime/test_platform_capabilities.py +++ b/tests/runtime/test_platform_capabilities.py @@ -8,6 +8,7 @@ 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 ( @@ -86,6 +87,34 @@ def test_every_sdk_capability_the_platform_can_gate_names_its_flag(self) -> None 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( From f8e5f8852d0f0e29123b8146d4889b9721ff0f77 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:18:38 +0800 Subject: [PATCH 15/20] fix(claude-sdk): an empty content list is not MCP content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _is_mcp_content accepted any dict whose "content" key held a list, including an empty one — all() is true over nothing. A payload that merely happens to carry that key then passed through as a tool result with no blocks in it, and everything else the payload said was dropped on the way to the model instead of being serialized as text. Require a non-empty list of typed blocks, which is the shape the image path actually produces. --- src/band/integrations/claude_sdk/tools.py | 17 +++++++++++------ .../claude_sdk/test_mcp_content_passthrough.py | 8 ++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/band/integrations/claude_sdk/tools.py b/src/band/integrations/claude_sdk/tools.py index d7576da35..0996eed5e 100644 --- a/src/band/integrations/claude_sdk/tools.py +++ b/src/band/integrations/claude_sdk/tools.py @@ -73,13 +73,18 @@ def __getattr__(name: str) -> Any: def _is_mcp_content(data: Any) -> bool: - """True when a tool result is already an MCP content payload.""" + """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(data, dict) - and isinstance(data.get("content"), list) - and all( - isinstance(block, dict) and "type" in block for block in data["content"] - ) + isinstance(blocks, list) + and bool(blocks) + and all(isinstance(block, dict) and "type" in block for block in blocks) ) diff --git a/tests/integrations/claude_sdk/test_mcp_content_passthrough.py b/tests/integrations/claude_sdk/test_mcp_content_passthrough.py index 7181a28db..9d0a56d81 100644 --- a/tests/integrations/claude_sdk/test_mcp_content_passthrough.py +++ b/tests/integrations/claude_sdk/test_mcp_content_passthrough.py @@ -33,6 +33,14 @@ def test_content_key_holding_non_blocks_is_not_mistaken_for_mcp(self) -> None: 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.""" From 56453362f92b7d1c057f81af0de2198e611582b5 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:20:03 +0800 Subject: [PATCH 16/20] feat(tools): let the typed schema helpers ask for the file tools get_tool_schemas gained include_files with the file tools; its two typed front doors did not, so the anthropic- and gemini-shaped callers had no way to say what Capability.FILES decided. The Slack teeing wrapper already forwards the flag through get_tool_schemas, and dead-ended at the same two helpers. Adds the parameter to both, to AgentToolsProtocol and to the shipped fake. It stays default-off, so nothing advertises a file tool that did not ask for one. --- src/band/core/protocols.py | 12 ++++++++++-- src/band/runtime/tools.py | 4 ++++ src/band/testing/fake_tools.py | 3 +++ tests/runtime/test_file_tools.py | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index f5fcabf3b..766bc4192 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -142,13 +142,21 @@ async def send_room_file( ... 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/runtime/tools.py b/src/band/runtime/tools.py index a7cc66d68..c62a78452 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -2781,6 +2781,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( @@ -2789,6 +2790,7 @@ def get_anthropic_tool_schemas( "anthropic", include_memory=include_memory, include_contacts=include_contacts, + include_files=include_files, ), ) @@ -2797,6 +2799,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( @@ -2805,6 +2808,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 531a91547..8a9d55f1e 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -411,6 +411,7 @@ def get_tool_schemas( *, include_memory: bool = False, include_contacts: bool = True, + include_files: bool = False, ) -> list[dict[str, Any]]: return [] @@ -419,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 [] @@ -427,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/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index ff5e93176..64f25c674 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -97,6 +97,11 @@ def make_tools( 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()} @@ -109,6 +114,24 @@ def test_include_files_exposes_all_three(self) -> None: 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 From 4bb14ad2d7f71aa80870beee74167c491ecc4992 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:20:48 +0800 Subject: [PATCH 17/20] fix(files): say why a 404 body could not be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which of the two 404s the agent is looking at — a missing file, or a platform with no agent file API — is decided by the response body. A body that will not parse was swallowed, so the tool guessed "no file API" and left no trace: an operator seeing agents insist files are unavailable had nothing to work from. Logs the endpoint and the parse failure's type and message. The body stays out of it — this is the file-download response, so it may be the file. --- src/band/runtime/tools.py | 13 ++++++++++++- tests/runtime/test_file_tools.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index c62a78452..db56f3224 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -158,7 +158,18 @@ def describe_tool_result_as_text(result: Any) -> Any: def _describe_404(response: Any) -> str: try: payload = response.json() - except Exception: # noqa: BLE001 - a non-JSON body is equally undiagnostic + 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) diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index 64f25c674..54e7f4066 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -5,6 +5,7 @@ import hashlib import inspect import json +import logging from typing import Any import pytest @@ -407,6 +408,34 @@ async def test_a_missing_route_is_not_reported_as_a_missing_file(self) -> None: 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( From 09c82cb5fd76349b4cb0de44c1ce329ea3db346c Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:21:36 +0800 Subject: [PATCH 18/20] fix(files): clip an inline text excerpt on a character boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline cap counts bytes, and a file of multi-byte text has no reason to put a character boundary exactly there. The slice cut one in half and the decoder replaced the stump, so every clipped excerpt of non-English text ended in U+FFFD — a corrupted last character the model reads as content. Decodes the head incrementally, which holds an unfinished trailing sequence back. Genuinely invalid bytes still replace, since those are what the file says. --- src/band/runtime/tools.py | 19 ++++++++++++++++--- tests/runtime/test_file_tools.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index db56f3224..ff4d38221 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -7,6 +7,7 @@ from __future__ import annotations import base64 +import codecs import hashlib import logging import re @@ -1242,6 +1243,20 @@ def is_room_posting_tool(tool_name: str) -> bool: _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: @@ -1913,9 +1928,7 @@ async def read_room_file(self, file_id: str) -> Any: size = len(response.content) if content_type.startswith(_FILE_TEXTLIKE_PREFIXES): - text = response.content[:_FILE_TEXT_INLINE_LIMIT].decode( - "utf-8", errors="replace" - ) + 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}" diff --git a/tests/runtime/test_file_tools.py b/tests/runtime/test_file_tools.py index 54e7f4066..4b751df3c 100644 --- a/tests/runtime/test_file_tools.py +++ b/tests/runtime/test_file_tools.py @@ -248,6 +248,26 @@ async def test_text_file_returns_named_inline_content(self) -> None: 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( From a897e31e7482878bc42d72938c06b1b9a93de5dc Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:23:44 +0800 Subject: [PATCH 19/20] test(adapters): pin the FILES gate where the adapters actually apply it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing gate tests re-derive the tool list from iter_tool_definitions themselves, so they pin the registry rather than either adapter: deleting the capability check in _create_mcp_backend or in the pydantic-ai registration block left both green. Assert instead on what each adapter hands its framework — the MCP backend's allowed-tool list, and the agent's registered tools — plus the other half of the pydantic-ai seam, that a read answers in words rather than forwarding a base64 image the framework cannot show. --- tests/adapters/test_claude_sdk_adapter.py | 19 +++++++++ tests/adapters/test_pydantic_ai_adapter.py | 47 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 29c0c5315..62e2ecff9 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -121,6 +121,25 @@ def test_the_capability_is_what_exposes_them(self): } 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.""" 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.""" From 967d6185bd12c84cf40ac5503867e8f61d29aca3 Mon Sep 17 00:00:00 2001 From: Eric Lefrancois des Courtis Date: Wed, 12 Aug 2026 00:24:17 +0800 Subject: [PATCH 20/20] style: ruff format the new seam test --- tests/adapters/test_claude_sdk_adapter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 62e2ecff9..25f46ff7d 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -132,9 +132,7 @@ async def test_the_backend_the_model_talks_to_honours_the_gate(self, granted): 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 - ) + 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))