Skip to content

feat: room file tools behind Capability.FILES - #525

Draft
eric-descourtis-thenvoi wants to merge 14 commits into
band-ai:mainfrom
eric-descourtis-thenvoi:feat/file-transfer-tools
Draft

feat: room file tools behind Capability.FILES#525
eric-descourtis-thenvoi wants to merge 14 commits into
band-ai:mainfrom
eric-descourtis-thenvoi:feat/file-transfer-tools

Conversation

@eric-descourtis-thenvoi

@eric-descourtis-thenvoi eric-descourtis-thenvoi commented Aug 7, 2026

Copy link
Copy Markdown

What

Three built-in room file tools behind a new opt-in Capability.FILES:

Tool Does
band_list_room_files Lists files shared with this agent, newest first (name/type/size + sender)
band_read_room_file Text inline (16 KiB cap); images become real vision input; other formats described
band_send_room_file Uploads a text file it wrote and attaches it to a mention-reply

Plus two Claude SDK bridge fixes the vision path needs:

  • _make_result passes through results that are already MCP-shaped ({"content": [...]}) instead of json-dumping an image block into base64 prose.
  • A custom tool that declares room_id as a real input field keeps it; the strip now applies only to the schema-injected extra.

How

  • The generated REST client (band-client-rest==0.0.10) doesn't expose the agent file endpoints yet, so the tools use the client's own transport (same base URL/auth/pool) with a comment marking the migration point once Fern regen lands.
  • Tool-family registries treat files exactly like memory: excluded from BASE/CHAT, present in ALL, own FILE_TOOL_NAMES set and "files" category. band_send_room_file counts as a room-posting tool for reply suppression; the two readers join READ_ONLY_TOOL_NAMES.
  • Wired adapters: claude_sdk (definition-driven, vision capable), pydantic_ai and crewai/crewai_flow (concrete wrappers). Other adapters keep their current capability set — enabling them is a follow-up per adapter.
  • Mentions resolve client-side via the existing participants cache, so the send works against platforms without server-side handle resolution.
  • Listing queries the delivery-status views (processing/processed/pending/unfiltered) because the message that triggered the current turn is still processing — skip that view and "the file you were just sent" doesn't exist yet.

Capability negotiation (added since first review)

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 these endpoints. So
the SDK asks.

GET /api/v1/agent/me now answers with a feature_flags block (Agent API 1.15.0) 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, so there is no
extra round trip, and Agent.start() drops any refused capability from the adapter's
AdapterFeatures before on_started. Every adapter builds its tool list there and they all
read features.capabilities, 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 reading silence as a refusal would take
working tools away from a deployment that has them. src/band/runtime/capabilities.py is the one
place the flag key is spelled.

The platform half is thenvoi-platform #2651, which also puts the whole feature behind
ff_file_transfer, off by default until a deployment asks for it.

Platform dependency

The endpoints ship in thenvoi-platform PLT-1209/1210/1211 (in review). The capability defaults off everywhere, so this is safe to merge ahead of them; agents opt in with AdapterFeatures(capabilities={Capability.FILES}).

Proven end-to-end on a dev platform node: an agent read a text file word-for-word, described a webp book cover from pixels, and authored+attached its own file — the same flows now covered by 18 unit tests (tests/runtime/test_file_tools.py, tests/integrations/claude_sdk/test_mcp_content_passthrough.py).

Tests

  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/: 4578 passed. The only residue is the desktop_app suite, which needs a working node on PATH: in a full run this box's asdf shim answers unknown command: node, and those same tests pass in isolation here and on clean main.
  • ruff check / ruff format / pyrefly check: clean.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@eric-descourtis-thenvoi

Copy link
Copy Markdown
Author

Dogfooding note: while live-testing this branch, agent turns went silent-empty (~1s, $0.00, no tool calls) and the adapter logged nothing. A probe with ClaudeAgentOptions(stderr=...) showed the real cause: RateLimitEvent (five-hour subscription limit) with a synthetic "session limit" assistant message and is_error=True on the result. Two adapter gaps worth follow-ups, not in this PR's scope:

  1. ResultMessage.is_error / RateLimitEvent should be logged (or surfaced as a band error event) instead of reading as a successful $0.0000 turn.
  2. Resuming a dead claude_sdk_session_id doesn't raise — the CLI accepts the id and returns empty turns forever, so the adapter's resume-failure fallback never fires. The ACP adapter's validate-then-fallback pattern (session/load) is the model to copy.

eric-descourtis-thenvoi and others added 13 commits August 10, 2026 15:34
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…r adapters

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…missing

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 <noreply@anthropic.com>
… size

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…through

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant