feat: Claude code execution tool and programmatic tool calling - #1020
feat: Claude code execution tool and programmatic tool calling#1020klin333 wants to merge 83 commits into
Conversation
Replaces 7 identical copies of the 11-line ProviderAnthropic constructor with a shared helper function.
- Add `is_error` and `content` properties to ContentToolResponseMcp so callers can distinguish tool errors from successes without digging into the raw JSON blob. - Improve format() to show error/result status and the response text instead of just the opaque tool_use_id. - Drop defensive `%||% ""` fallbacks on required API fields (id, name, server_name, tool_use_id) to match existing patterns and surface malformed responses loudly.
Add a `label` parameter to format(ContentToolRequest) and format(ContentToolResult) so MCP format methods can delegate to them while using "mcp tool request" / "mcp tool result" labels. MCP output now shows IDs and arguments, matching the regular tool display style.
Wrap non-list input with list() before passing to ContentToolRequest constructor, which requires arguments to be a list. Add format tests for MCP tool request and result display.
Multiline tool results now render with cli_rule header (bold label, cyan tool ID) and a closing rule, making long results easier to read. Single-line results keep the inline format.
Record a cassette against the DeepWiki MCP server to test the full round-trip: mcp_tool_use and mcp_tool_result parsing through chat_anthropic with the mcp-client-2025-11-20 beta header.
Rename ContentToolRequestMcp -> ContentMcpToolRequest and ContentToolResponseMcp -> ContentMcpToolResult for consistent naming. Add new ContentMcpListTools class for OpenAI's mcp_list_tools output (tool discovery caching).
Parse mcp_list_tools, mcp_call, and mcp_approval_request output types from the OpenAI Responses API. mcp_call is split into a ContentMcpToolRequest + ContentMcpToolResult pair, reusing the same S7 classes as Anthropic. mcp_list_tools is preserved in turn contents for round-trip caching. mcp_approval_request errors with guidance to use require_approval = "never".
Add server-side MCP tools section to ?chat_openai with usage example and auth token instructions. Add VCR-recorded integration test against the DeepWiki MCP server verifying mcp_list_tools, mcp_call, and message output types are parsed correctly.
Include chat_openai() alongside chat_anthropic() in the MCP connector NEWS entry.
Add the core infrastructure for provider-hosted MCP server connections. The McpConnector S7 class stores URL, name, credentials, and extra provider-specific args. The mcp_connector() constructor validates inputs and is registered as an exported function. Updated check_tool() to accept McpConnector objects and added a check_mcp_connector_tool() generic with a default Provider method that errors, so unsupported providers fail fast at registration time.
ProviderAnthropic now accepts McpConnector objects via register_tool(). chat_body() extracts connectors to build the mcp_servers body field, and as_json() returns mcp_toolset tool entries. A chat_request() override injects the mcp-client-2025-11-20 beta header automatically when MCP connectors are present, merging with any existing beta headers.
ProviderOpenAI now accepts McpConnector objects via register_tool(). as_json() maps name to server_label, url to server_url, and credentials() to authorization. Extra args (require_approval, allowed_tools, etc.) are merged into the tool entry.
The tools list is named (keyed by tool name), so lapply() preserved those names on mcp_servers. jsonlite serializes named lists as JSON objects, but the Anthropic API expects mcp_servers as a JSON array.
ContentMcpToolRequest now inherits from ContentToolRequest and ContentMcpToolResult inherits from ContentToolResult, letting downstream consumers rely on existing tool request/result patterns. Key changes: - Add mcp_tool_def() helper to create a no-op ToolDef for MCP tools - Add local_only parameter (default TRUE) to is_tool_request() and is_tool_result() so MCP content doesn't enter invoke_tools() - Add provider-specific as_json() methods for MCP content on both ProviderAnthropic and ProviderOpenAI to prevent S7 multi-dispatch from falling through to parent ContentToolRequest/Result methods - Echo MCP tool requests, results, and tool lists during echo="output" via maybe_echo_tool() and echo_server_tool_contents() - Yield MCP content during streaming when yield_as_content=TRUE - Use "__" separator for MCP tool display names (server__tool)
Add truncation helpers for formatted tool output: - `truncate_lines()` limits multi-line output to a max number of lines with a "[and N more lines...]" suffix - `truncate_id()` shortens tool IDs longer than 12 chars (first 8 + ellipsis + last 4) `print(Chat)` now passes `max_lines = 5` so tool results are truncated when reviewing conversation history. `maybe_echo_tool()` is simplified to use `truncate_lines()` instead of inline truncation logic.
Remove cli_rule() header/footer from format(ContentToolResult) in favor of the same inline "[tool result (id)]:" header used for single-line results.
format(ContentToolResult) gains tool_style = c("plain", "reprex") and
show = "value" options. The reprex style renders values with italic
#> prefixed lines. print(Chat) and maybe_echo_tool() now use the reprex
style, simplifying maybe_echo_tool() by delegating formatting to
format(). New args use tool_ prefix to avoid conflicts with other
formatting options.
OpenAI can return mcp_call errors as structured objects with `type`
("mcp_tool_execution_error") and `content` (list of content blocks)
instead of a plain string. Parse both forms to extract the error text.
maybe_echo_tool() was hardcoding "tool call" for all tool requests. Now checks for ContentMcpToolRequest and uses "mcp tool call" instead.
Users no longer need to pass require_approval = "never" explicitly. The default is set in as_json(ProviderOpenAI, McpConnector) and can still be overridden via ... in mcp_connector().
claude_tool_code_execution() now documents that the default "code_execution_20250825" should be preferred for programmatic tool calling: the newer "code_execution_20260120" returns "unavailable" errors when Claude issues several programmatic tool calls in parallel, which was confirmed against the live API. Also adds the previously missing man page for this exported function. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
link_server_tool_results() only matched server-side tool requests to their results within a single API response. Programmatic tool calling pauses a turn after the code-execution request and returns the matching result in a later turn (once the local tool runs), so the result arrived without its request and kept an empty @request@name/@arguments. shinychat builds tool cards from those fields, so the resumed result rendered with an empty title and call. Add relink_server_tool_results(), which fills any still-empty result by searching the whole conversation for the request with the same id, and call it from TurnAccumulator$value_turn() so it runs before contents are yielded for display (covering streaming/non-streaming, sync/async). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stream() and stream_async() returned the chat_impl generator directly, bypassing the rollback added in 960ff1c. A request that failed partway through the tool loop left a dangling tool-request turn, poisoning every later call on the chat. Because the generator is iterated by the caller, the rollback must live in the iteration itself: wrap the generator so it snapshots the turns when iteration begins and restores them if a chunk errors. Factor the shared restore-and-re-raise logic into a helper used by all four rollback wrappers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
text-editor `create` and `str_replace` results carry no stdout, so code_execution_result_body() produced an empty value and the tool card / echo rendered nothing. Summarise them instead: `str_replace` shows its unified-diff `lines` hunk and `create` reports "File created." / "File updated." from `is_file_update`. `view` results (file content under `content`) and bash/Python stdout are unchanged. Result shapes confirmed against the live API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
register_tool() warned "allowed_callers is only supported by Anthropic" for any non-ProviderAnthropic provider. That message is misleading for chat_aws_bedrock(), which runs Claude but reaches it through the AWS Converse API (no programmatic tool calling). Replace the hardcoded S7_inherits() check in the generic Chat class with a check_programmatic_tool() provider generic, mirroring check_mcp_connector_tool(): the base Provider method warns that the field is ignored and points to chat_anthropic(), while ProviderAnthropic overrides it with a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
last_container_id() resent the freshest container id from history on every request, even after it had expired. A stale container id isn't rejected by the API, but code execution then silently fails because the sandbox is gone. Skip a container whose `expires_at` is in the past so the API provisions a fresh one instead. Containers without a timestamp are still treated as usable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_json(ProviderAnthropic, ContentToolResult) dereferenced x@request@id unconditionally, so a result whose @request is NULL (reachable from user-supplied history, since the property allows NULL) aborted turn serialization with a cryptic "no applicable method for `@` applied to NULL". Raise an actionable error instead. This also makes the @request access in last_block_is_programmatic_result() safe, since that line is only reached for results that serialized successfully. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_json(ProviderAnthropic, ContentToolRequest) passed @arguments through as `input`. A bare empty list() serializes to a JSON array (`[]`) under auto_unbox, but the API requires `input` to be an object; coerce an empty arguments list to an empty named list (`{}`), mirroring the existing pattern in provider-aws.R. The round-trip path was unaffected (it parses `{}` into a named list), but a synthetically constructed argument-free request would otherwise be rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tool results holding Content objects are normally unrolled into a forward pointer plus sibling user blocks, but a programmatically-called tool delivers its result to code running in Claude's sandbox, which only sees the pointer text. Raise a tool-author-facing error instead of silently sending a useless placeholder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API rejects a request that carries a container id without the code execution tool, so requests that drop the tool (structured extraction, batches, set_tools()) must not reuse the conversation's container. Also reuse the new is_programmatic_tool_result() helper in last_block_is_programmatic_result(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…roviders An assistant turn replayed to an OpenAI-compatible provider used to echo raw Anthropic code-execution result blocks into the content array (and put the request's tool call there too), which chat-completions APIs reject. Route server-side tool requests (code execution, MCP) into tool_calls and emit their results as role 'tool' messages. Drop the Provider-level as_json methods for the Code classes: the request method was shadowed by every concrete provider's ContentToolRequest method, and the response method was the source of the raw JSON leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…version ?tool recommended code_execution_20260120 while claude_tool_code_execution() defaults to (and recommends) code_execution_20250825. Recommend the default everywhere and state that allowed_callers must match the registered tool's type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot for 'claude_tool_code_execution() rejects a non-string type' was never committed, so the error text was not actually verified on a fresh checkout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'direct' is the API default and an explicit opt-OUT of programmatic calling, yet any non-empty allowed_callers triggered the 'enable programmatic tool calling' warnings at register time and in chat_body(). Both gates now share an is_programmatic_tool() helper that requires a non-'direct' caller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
last_block_is_programmatic_result() re-serialized trailing blocks via as_json() to mirror the list serializer's compaction, and its is.null() test already disagreed with compact()'s drop-zero-length semantics. Serialize each block once, find the last emitted one by index, and inspect the source content directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l result The cache breakpoint was skipped entirely when the last block was a programmatic tool result, so every continuation request in a programmatic tool-calling loop re-billed the full history uncached. Move breakpoint placement from the per-turn serializer into chat_body(), which walks the serialized messages backwards and puts the breakpoint on the newest block that can carry one (skipping programmatic requests/results, thinking, and raw server-tool blocks). This also drops the is_last machinery from as_json(ProviderAnthropic, Turn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MCP and code-execution request/result classes were enumerated independently in is_tool_request(), is_tool_result(), is_server_tool_content(), and relink_server_tool_results(), and link/relink duplicated the id-map/copy logic. A server_tool_pairs registry now feeds all of them via is_server_tool_request()/is_server_tool_result() and a shared fill_result_requests() helper, so the next server-side tool type is added in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rolling back discards turns whose tool calls already executed (so a retry can re-run non-idempotent tools) and removes their billed token usage from get_tokens()/get_cost(). Mention both consequences and the ellmer_preserve_turns_on_error escape hatch in ?Chat and NEWS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ization The serialization-time abort fired after the tool had already run, so a side-effecting tool would re-execute on every retry and die the same way, and a replayed conversation containing such a result failed as_json on every provider forever. new_tool_result() now converts a rich value from a programmatic call into an error tool result the model can react to, and serialization falls back to the normal unrolling for turns recorded before this check existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
all() over a zero-length logical is TRUE, so a tool returning list() — a perfectly JSON-convertible empty result — was classified as rich content and rejected when called programmatically (and pointlessly unrolled into empty <tool-contents> wrappers when called directly). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProviderDeepSeek's Turn override predates the compat replay fix: an assistant turn with no text (the paused programmatic-call shape) crashed as_json dispatch on NULL, and server-side requests/results were silently dropped. Treat them like the compat parent does — requests into tool_calls, results as tool messages — and skip the assistant message when it would be empty. The tool-message construction now lives in a shared tool_result_message() helper instead of four copies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw provider-specific mcp_list_tools block was echoed verbatim into the assistant content array, so any MCP-connector conversation (whose first MCP use emits one) failed to replay on an OpenAI-compatible provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A replayed assistant turn containing only server-side tool results (e.g. a
code execution result with no accompanying text) serialized as
{role: 'assistant'} with neither content nor tool_calls, which
chat-completions APIs reject.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against the live API: a chat_structured() call on a conversation that used code execution or programmatic tool calling succeeds even though the request carries neither the code_execution tool nor the container — only the container field requires the tool to be enabled, and that is already gated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
any(NA != 'direct') is NA, so registering a tool whose allowed_callers contained NA killed register_tool() with a bare 'missing value where TRUE/FALSE needed'. tool() now rejects NA up front, and is_programmatic_tool() guards with isTRUE() for hand-constructed ToolDefs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat_request() appends provider@extra_args[['tools']] after chat_body() has already decided whether to send the container, so enabling code execution through api_args silently lost container reuse across turns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| content_text <- function(content) { | ||
| switch( | ||
| class(content)[1], | ||
| switch(class(content)[1], |
There was a problem hiding this comment.
[air] reported by reviewdog 🐶
| switch(class(content)[1], | |
| switch( | |
| class(content)[1], |
| test_that("ContentToolResponseCode inherits from ContentToolResult", { | ||
| res <- ContentToolResponseCode( | ||
| value = "ok", | ||
| request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()), |
There was a problem hiding this comment.
[air] reported by reviewdog 🐶
| request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()), | |
| request = ContentToolRequest( | |
| id = "srvtoolu_1", | |
| name = "", | |
| arguments = list() | |
| ), |
| turn2 <- AssistantTurn(list( | ||
| ContentToolResponseCode( | ||
| value = "2", | ||
| request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()), |
There was a problem hiding this comment.
[air] reported by reviewdog 🐶
| request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()), | |
| request = ContentToolRequest( | |
| id = "srvtoolu_1", | |
| name = "", | |
| arguments = list() | |
| ), |
| turn2 <- AssistantTurn(list( | ||
| ContentMcpToolResult( | ||
| value = "ok", | ||
| request = ContentToolRequest(id = "mcptoolu_1", name = "", arguments = list()), |
There was a problem hiding this comment.
[air] reported by reviewdog 🐶
| request = ContentToolRequest(id = "mcptoolu_1", name = "", arguments = list()), | |
| request = ContentToolRequest( | |
| id = "mcptoolu_1", | |
| name = "", | |
| arguments = list() | |
| ), |
|
Thanks for the PR @klin333. I'm going to close it for now, as it's not reviewable in its current state being based off multiple unmerged PRs. Please could you wait for the other PRs to merge before submitting a new one? At this point, it's unclear what changes will be needed to the other PRs, and so you might need to totally rewrite your code here if they change drastically or don't get merged in the end. Once they've merged, then you can rebase off the main branch and submit a new PR. When you do submit a new PR, would you mind writing the PR description yourself? Claude generates a lot of content, and it'd be more useful to hear your own summary about what you've implemented and the design decisions you've made as you interacted with Claude. |
|
No problem. Was going to wait but then Hadley posted about giving LLM a custom calculator tool, and thought to make this PR public so people know it’s not far from writing custom calculate tools to having full server side python execution. |
Summary
This PR adds support for Anthropic's server-side code execution tool and programmatic tool calling (PTC) to ellmer.
claude_tool_code_execution(): a built-in tool definition that enables Claude's server-side code execution sandbox, including downloading/handling of output files, container reuse across conversation turns, and expired-container recovery.tool(allowed_callers = ...): lets a client-defined tool be invoked programmatically from inside Claude's code execution environment, not just by the model directly. Tool results are serialized back into the sandbox.The headline capability is the combination of the two: with programmatic tool calling, data returned by client tools (e.g. a SQL query tool) flows directly into the code execution sandbox without passing through the model's context. Without PTC, the model has to regurgitate tool output token-by-token before any code can use it — prohibitively token-inefficient for things like large SQL result sets. With PTC, Claude writes code that calls the tool, receives the result inside the sandbox, and only the final computed answer needs to surface to the model.
Supporting work included here:
chat()/stream()fails mid-tool-loop, so a failed request doesn't corrupt the turn history.extra_args$toolsis now appended rather than merged withutils::modifyList(), which silently dropped unnamed list entries.Minimal example
A local R tool returns an employee's expense rows as JSON. Opting it in via
allowed_callerslets Claude's code call it from inside the sandbox:Live transcript (echo output, abridged)
Claude writes Python that calls the R tool four times in parallel inside the sandbox; ellmer executes
get_expenses()locally for each call and returns the rows to the sandbox, not to the model's context.Review scope
This branch builds on the MCP connector work (everything before 983f025, related to #985). For review, focus on changes from 983f025 onward — that is where the code execution / programmatic tool calling work starts.
Test plan
testthatsuites for provider-claude, tools-built-in, tools-def, chat, chat-tools, provider-deepseek, provider-openai-compatible: 680 passed, 0 failed (29 skipped pending live API keys)🤖 Generated with Claude Code