Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b1ab2f6
feat: room file tools behind Capability.FILES
eric-descourtis-thenvoi Aug 7, 2026
f4808c1
test(files): RED — the room listing never reaches the newest files
eric-descourtis-thenvoi Aug 10, 2026
d9599a2
fix(files): GREEN — ask the platform for the newest messages
eric-descourtis-thenvoi Aug 10, 2026
f4578e6
test(files): RED — an image read becomes base64 prose on three of fou…
eric-descourtis-thenvoi Aug 10, 2026
e3d4ab1
fix(files): GREEN — describe images on adapters that cannot show them
eric-descourtis-thenvoi Aug 10, 2026
b48c2a5
test(crewai): RED — sharing a file is not counted as replying
eric-descourtis-thenvoi Aug 10, 2026
60b08ce
fix(crewai): GREEN — a file share counts as replying
eric-descourtis-thenvoi Aug 10, 2026
a9e6833
test(files): RED — a platform with no file API reports every file as …
eric-descourtis-thenvoi Aug 10, 2026
917af3a
fix(files): GREEN — a missing file API says so, and a text file has a…
eric-descourtis-thenvoi Aug 10, 2026
e122a37
test(claude-sdk): pin the FILES capability gate
eric-descourtis-thenvoi Aug 10, 2026
2346946
test(runtime): RED — the SDK assumes every platform serves room files
eric-descourtis-thenvoi Aug 10, 2026
b6f5f59
feat(runtime): GREEN — ask the platform what it serves, once, at startup
eric-descourtis-thenvoi Aug 10, 2026
d694f4e
docs: record the room file tools and how capability negotiation works
eric-descourtis-thenvoi Aug 10, 2026
823ac25
test(runtime): pin the generated client passing the capability block …
eric-descourtis-thenvoi Aug 10, 2026
f8e5f88
fix(claude-sdk): an empty content list is not MCP content
eric-descourtis-thenvoi Aug 11, 2026
5645336
feat(tools): let the typed schema helpers ask for the file tools
eric-descourtis-thenvoi Aug 11, 2026
4bb14ad
fix(files): say why a 404 body could not be read
eric-descourtis-thenvoi Aug 11, 2026
09c82cb
fix(files): clip an inline text excerpt on a character boundary
eric-descourtis-thenvoi Aug 11, 2026
a897e31
test(adapters): pin the FILES gate where the adapters actually apply it
eric-descourtis-thenvoi Aug 11, 2026
967d618
style: ruff format the new seam test
eric-descourtis-thenvoi Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions src/band/adapters/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/band/adapters/crewai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down
4 changes: 3 additions & 1 deletion src/band/adapters/crewai_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 49 additions & 1 deletion src/band/adapters/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
is_terminal_success,
missing_reply_error,
serialize_tool_result,
describe_tool_result_as_text,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -217,7 +218,7 @@ class PydanticAIAdapter(SimpleAdapter[PydanticAIMessages]):

SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION, Emit.USAGE})
SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset(
{Capability.MEMORY, Capability.CONTACTS}
{Capability.MEMORY, Capability.CONTACTS, Capability.FILES}
)

def __init__(
Expand Down Expand Up @@ -677,6 +678,53 @@ async def band_archive_memory(
band_archive_memory.__doc__ = get_tool_description("band_archive_memory")
agent.tool(band_archive_memory)

# Room file tools (opt-in via Capability.FILES)
if Capability.FILES in self.features.capabilities:

async def band_list_room_files(
ctx: RunContext[AgentToolsProtocol],
) -> str:
try:
return await ctx.deps.list_room_files()
except Exception as e:
return f"Error listing room files: {e}"

band_list_room_files.__doc__ = get_tool_description("band_list_room_files")
agent.tool(band_list_room_files)

async def band_read_room_file(
ctx: RunContext[AgentToolsProtocol],
file_id: str,
) -> Any:
try:
# pydantic-ai has no MCP content path, so the image
# block would be stringified into the turn as base64.
return describe_tool_result_as_text(
await ctx.deps.read_room_file(file_id)
)
except Exception as e:
return f"Error reading room file: {e}"

band_read_room_file.__doc__ = get_tool_description("band_read_room_file")
agent.tool(band_read_room_file)

async def band_send_room_file(
ctx: RunContext[AgentToolsProtocol],
filename: str,
text_content: str,
mention: str,
message: str = "",
) -> str:
try:
return await ctx.deps.send_room_file(
filename, text_content, mention, message
)
except Exception as e:
return f"Error sending room file: {e}"

band_send_room_file.__doc__ = get_tool_description("band_send_room_file")
agent.tool(band_send_room_file)

# Register custom tools (user-provided PydanticAI-compatible functions) on
# the path their signature calls for — pydantic-ai keeps the two apart.
for custom_tool in self._custom_tools:
Expand Down
35 changes: 35 additions & 0 deletions src/band/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 25 additions & 2 deletions src/band/core/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,41 @@ def get_tool_schemas(
*,
include_memory: bool = False,
include_contacts: bool = True,
include_files: bool = False,
) -> list[dict[str, Any]] | list["ToolParam"]:
"""Get tool schemas in provider-specific format (openai/anthropic)."""
...

async def list_room_files(self) -> str:
"""List files shared in this room, newest first."""
...

async def read_room_file(self, file_id: str) -> Any:
"""Read a room file: text inline, images as MCP content, rest described."""
...

async def send_room_file(
self, filename: str, text_content: str, mention: str, message: str = ""
) -> str:
"""Upload a text file and attach it to a new message in this room."""
...

def get_anthropic_tool_schemas(
self, *, include_memory: bool = False, include_contacts: bool = True
self,
*,
include_memory: bool = False,
include_contacts: bool = True,
include_files: bool = False,
) -> list["ToolParam"]:
"""Get tool schemas in Anthropic format (strongly typed)."""
...

def get_openai_tool_schemas(
self, *, include_memory: bool = False, include_contacts: bool = True
self,
*,
include_memory: bool = False,
include_contacts: bool = True,
include_files: bool = False,
) -> list[dict[str, Any]]:
"""Get tool schemas in OpenAI format (strongly typed)."""
...
Expand Down
18 changes: 18 additions & 0 deletions src/band/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Capability(str, Enum):

MEMORY = "memory"
CONTACTS = "contacts"
FILES = "files"


class Emit(str, Enum):
Expand Down Expand Up @@ -297,6 +298,23 @@ def __init__(
tuple(include_categories) if include_categories is not None else None,
)

def without_capabilities(
self, capabilities: Iterable[Capability]
) -> AdapterFeatures:
"""These settings minus `capabilities`, leaving the rest untouched.

Used when the deployment serves less than the operator asked for: the
capability set is the one seam every adapter's tool gating reads, so
removing an entry here removes the tools everywhere at once.
"""
return AdapterFeatures(
capabilities=self.capabilities - frozenset(capabilities),
emit=self.emit,
include_tools=self.include_tools,
exclude_tools=self.exclude_tools,
include_categories=self.include_categories,
)


@dataclass(frozen=True)
class PlatformMessage:
Expand Down
37 changes: 35 additions & 2 deletions src/band/integrations/claude_sdk/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,32 @@ def __getattr__(name: str) -> Any:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def _is_mcp_content(data: Any) -> bool:
"""True when a tool result is already an MCP content payload.

The shape is a non-empty list of blocks, each tagged with a ``type``. An
empty list is not it: a payload that merely happens to carry a ``content``
key would otherwise pass through as a tool result with nothing in it, and
everything else the payload said would be lost on the way to the model.
"""
blocks = data.get("content") if isinstance(data, dict) else None
return (
isinstance(blocks, list)
and bool(blocks)
and all(isinstance(block, dict) and "type" in block for block in blocks)
)


def _make_result(data: Any) -> dict[str, Any]:
"""Format tool result for Claude SDK MCP responses."""
"""Format tool result for Claude SDK MCP responses.

A result that is already MCP-shaped passes through untouched — that is
how a tool returns non-text content (an ``image`` block reaches the model
as vision input; json-dumping it here would demote it to base64 prose).
Everything else is wrapped as a single text block, as before.
"""
if _is_mcp_content(data):
return data
return {"content": [{"type": "text", "text": json.dumps(data, default=str)}]}


Expand Down Expand Up @@ -246,6 +270,12 @@ def _build_custom_sdk_tool(
input_model, _ = tool_def
tool_name = get_custom_tool_name(input_model)
schema = _build_sdk_schema(input_model, include_room_id=include_room_id)
# ``room_id`` is normally a schema-injected extra the model must echo so
# the bridge can resolve the room — it is not part of the tool's own
# contract, so it gets stripped before validation. A tool that declares
# ``room_id`` as a real field opts out of the strip: for it, dropping the
# key would make validation fail on every call.
declares_room_id = "room_id" in input_model.model_fields

@tool(
tool_name,
Expand All @@ -254,7 +284,10 @@ def _build_custom_sdk_tool(
)
async def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tool_args = {k: v for k, v in args.items() if k != "room_id"}
if declares_room_id:
tool_args = dict(args)
else:
tool_args = {k: v for k, v in args.items() if k != "room_id"}
result = await execute_custom_tool(tool_def, tool_args)
return _make_result(result)
except Exception as error:
Expand Down
Loading