Skip to content

feat(rig-core): ToolCallExtensions — per-call tool context through the agent loop, MCP & sub-agents (supersedes #1537, #1953) - #1954

Merged
gold-silver-copper merged 11 commits into
mainfrom
feat/tool-call-context-integration
Jun 25, 2026
Merged

feat(rig-core): ToolCallExtensions — per-call tool context through the agent loop, MCP & sub-agents (supersedes #1537, #1953)#1954
gold-silver-copper merged 11 commits into
mainfrom
feat/tool-call-context-integration

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Per-call runtime extensions for tools — a type-erased type-map that lets callers attach values (auth tokens, session IDs, A2A context_id/task_id, conversation state) to a tool call and lets tools read them by type, threaded all the way through the agent run loop, MCP tools, and sub-agents. The model never sees them.

This supersedes #1537 (rebased onto current main) and now also incorporates the strengths of the parallel #1953, so the surviving design has both the better naming/type and the full integration. Credit to @redroy44 for the original #1537 design (first commit replays it).

Closes #1536. Supersedes #1537 and #1953.

use rig_core::tool::ToolCallExtensions;

#[derive(Clone)]
struct SessionId(String);

let mut ext = ToolCallExtensions::new();
ext.insert(SessionId("session-123".into()));

let answer = agent.prompt("do the thing").tool_extensions(ext).await?; // also on stream_prompt(..)

A tool reads them by overriding call_with_extensions:

async fn call_with_extensions(&self, args: Self::Args, ext: &ToolCallExtensions)
    -> Result<Self::Output, Self::Error>
{
    let session = ext.require::<SessionId>()?; // or ext.get::<SessionId>()
    // ...
}

What's here

The type (tool/extensions.rs) — a port of http::Extensions' AnyClone pattern: type-erased, cloneable, zero-alloc when empty (Option<Box<HashMap>>), no-op IdHasher over TypeId keys (no SipHash; hardened with a non-panicking write fallback), insert returns the displaced value, len/is_empty, plus a require::<T>() -> Result<&T, MissingExtension> accessor and an assert_send_sync compile guard. The wasm/non-wasm split is deduped into one AnyClone definition via WasmCompat{Send,Sync}.

The dispatch chain gains call_with_extensions on Tool → ToolDyn → ToolType → ToolSet → ToolServerHandle, each defaulting to the existing extension-free call (fully backward compatible; the now-dead ToolType::call is removed, not #[allow]'d).

The agent loop — the piece #1537 was missing: a tool_extensions field on AgentRunner is threaded into the single run_single_tool dispatch site (borrowed, not cloned) for both the blocking and streaming drivers, and surfaced via tool_extensions(..) on PromptRequest, TypedPromptRequest, and StreamingPromptRequest.

Reaches the real destinations (what makes this end-to-end usable for the motivating A2A/MCP case):

  • MCP toolsMcpTool::call_with_extensions forwards an rmcp::model::Meta from the extensions as the request _meta (SEP-1319): per-call auth/session to MCP servers. Re-exported as rig::tool::rmcp::Meta.
  • Sub-agentsAgent-as-tool propagates the extensions into the inner run, so delegated agents' tools observe them.

Relationship to #1953

#1953 (ToolCallExtensions) independently rebuilt #1537 with a better type (the IdHasher, len/is_empty, and the collision-free name). This PR adopts all of those (last commit) on top of its own deeper integration — MCP _meta, sub-agent propagation, the TypedPromptRequest entry point, require(), deduped wasm, and broader tests — so #1954 is now a strict superset and #1953 can be closed in its favor.

Test plan

  • cargo test -p rig-core --lib1019 pass (type + IdHasher round-trip + len/is_empty + deep-clone + require; blocking & streaming end-to-end through the real agent loop; sub-agent propagation; sentinel/default paths).
  • cargo test -p rig-core --lib --features rmcp1029 pass, incl. a mock-server test asserting _meta reaches the server.
  • cargo clippy -p rig-core --all-features --all-targets clean; cargo fmt clean; cargo check --features wasm --target wasm32-unknown-unknown clean; doctests pass.

Generated with AI assistance (Claude).

gold-silver-copper and others added 6 commits June 24, 2026 21:14
… of #1537)

Faithful replay of redroy44's PR #1537 onto current main, with no behavioral
changes. Adds `ToolCallContext` (a type-erased, cloneable type-map) and the
`call_with_context` dispatch methods threaded through
ToolServerHandle -> ToolSet -> ToolType -> ToolDyn -> Tool.

Subsequent commits harden the type and wire the context into the agent run
loop (which #1537 did not do).

Co-authored-by: Piotr Bandurski <redroy44@gmail.com>
- Collapse the duplicated wasm/non-wasm cfg blocks into one AnyClone trait
  using the existing WasmCompatSend/WasmCompatSync markers.
- `insert` now returns the displaced value (`Option<T>`), matching
  `http::Extensions`/`HashMap` instead of silently dropping it.
- Add `require::<T>()` + `MissingContextValue` so tools that depend on a
  context value get an actionable error instead of a silent `None`.
- Make read-accessor bounds symmetric with `insert` (Send + Sync on native).
- Add a compile-time `Send + Sync` assertion (the property the agent loop
  relies on) and tests for deep-clone independence, insert-returns-previous,
  and `require`.
- Remove the now-dead `ToolType::call` (was `#[allow(dead_code)]`); route the
  empty-context delegations through the shared `EMPTY` constant.
- Consolidate to a single public path `rig::tool::ToolCallContext` (drop the
  crate-root re-export; make the `context` module private).
- Fix the doctest crate path (`rig::` -> `rig_core::`) so it compiles.
- Document the `call`/`call_with_context` override contract.
This is the integration #1537 was missing: the new context was plumbed
through ToolServer but never reached tools executed by an Agent, because the
run loop called the context-less `call_tool`.

- Add a `tool_context: ToolCallContext` to `AgentRunner` (empty by default)
  and a `tool_context(..)` builder, mirrored on `PromptRequest`,
  `TypedPromptRequest`, and `StreamingPromptRequest`.
- `run_single_tool` now takes the context and dispatches via
  `call_tool_with_context`, so both the blocking and streaming drivers thread
  it identically.
- Add a shared `MockContextProbeTool`/`SessionId` test fixture and end-to-end
  tests proving a context set on `agent.prompt(..)` / `agent.stream_prompt(..)`
  reaches the executed tool, and that the empty-context default still works.

Callers now do: `agent.prompt(p).tool_context(ctx).await`.
McpTool implements ToolDyn directly, so the default call_with_context
dropped the context for MCP tools — the exact A2A/auth path the feature
targets.

Override it: an `rmcp::model::Meta` placed in the ToolCallContext is now
attached as the MCP request's `_meta` (SEP-1319), the idiomatic channel for
per-call metadata (auth tokens, session ids, A2A context_id/task_id). The
shared `execute` helper backs both `call` (no meta) and `call_with_context`,
so behavior is unchanged when no Meta is supplied.

Adds a mock-server test asserting the `_meta` reaches the server (and is
absent when the context carries none).
`Agent<M>` implements `Tool` (the agent-as-tool / sub-agent pattern) but only
implemented `call`, so a context set on the outer run was dropped at the
sub-agent boundary and the inner agent's tools never saw it.

Override `call_with_context` to forward the context into the inner
`prompt(..).tool_context(ctx)`, enabling context-carrying sub-agent
delegation / A2A chains. Adds a nested-agent test.
…ent MCP _meta

- Add a streaming empty-context test mirroring the blocking one (driver symmetry).
- Add a direct `call` test pinning MockContextProbeTool's "call-no-context"
  sentinel, and tighten its docstring to explain the sentinel's purpose.
- Document the McpTool `_meta` forwarding in the rmcp module docs with an
  example, and re-export `rig::tool::rmcp::Meta` for convenience.
…p_used)

CI runs clippy --all-features --all-targets, enabling the test-utils
feature so the fixture compiles as library code where the workspace's
deny(clippy::unwrap_used) applies. Recover poisoned locks via
into_inner(), matching the existing test_utils convention.
…s parity

Adopts the stronger type-map decisions from the parallel #1953 so the
surviving design carries both the better name and the full integration.

- Rename the public surface to the "Extensions" vocabulary the maintainers
  prefer, resolving the name collision with rmcp's own `ToolCallContext` and
  rig's `InvalidToolCallContext`/`Tool::Context`:
    ToolCallContext      -> ToolCallExtensions
    MissingContextValue  -> MissingExtension
    Tool/ToolDyn/ToolType/ToolSet::call_with_context -> call_with_extensions
    ToolServerHandle::call_tool_with_context         -> call_tool_with_extensions
    PromptRequest/TypedPromptRequest/StreamingPromptRequest/AgentRunner
        ::tool_context                               -> with_tool_extensions
  (module tool/context.rs -> tool/extensions.rs)
- Port the no-op `IdHasher` over `TypeId` keys (http::Extensions parity;
  avoids re-hashing TypeIds with SipHash on the dispatch path) with a
  non-panicking byte-fold `write` fallback, plus a round-trip stress test.
- Add `len()`/`is_empty()` for full http::Extensions API parity.

Keeps this PR's deeper integration: MCP `_meta` forwarding, sub-agent
propagation, the TypedPromptRequest entry point, require()/MissingExtension,
deduped wasm cfg, the assert_send_sync guard, and the broader e2e tests.
@gold-silver-copper gold-silver-copper changed the title feat(rig-core): complete ToolCallContext — thread per-call context through the agent loop (supersedes #1537) feat(rig-core): ToolCallExtensions — per-call tool context through the agent loop, MCP & sub-agents (supersedes #1537, #1953) Jun 25, 2026
… entry

Addresses review feedback on #1954:

- Drop the `with_` prefix from the builder to match the convention the
  CHANGELOG documents for this same unreleased cycle (runner/request builders
  dropped `with_history`->`history`, `with_tool_concurrency`->`tool_concurrency`):
  `with_tool_extensions(..)` -> `tool_extensions(..)` on PromptRequest,
  TypedPromptRequest, StreamingPromptRequest, and AgentRunner.
- Add the missing `### Added` CHANGELOG entry for the whole ToolCallExtensions
  surface (the project keeps a strict per-change changelog).
- Doc/nit polish: name the trait-default param `_extensions` (not `_ctx`),
  and tighten the `call_with_extensions` override contract to say *dynamic*
  dispatch (a direct `Tool::call` still runs its own body).
…e test

Review follow-ups on #1954:

- Finish the ToolCallExtensions rename in user-facing text: the struct/new()/
  require docs and — most visibly — the MissingExtension runtime error string
  ("required tool-call context value of type ..." -> "required tool-call
  extension of type ..."), plus the internal comments/test names. These render
  in published rustdoc and logs under a type named ToolCallExtensions.
- Add an end-to-end test that extensions persist across MULTIPLE tool-call
  rounds in one run (the headline value prop): MockExtensionsProbeTool now
  records every call (observations()), and the model scripts two probe rounds;
  both observe the same injected SessionId.
- Document that get_mut/remove are owner-side (tools get a shared ref).
- Disambiguate the AgentRunner.tool_extensions field doc link to the builder
  method (field and method share the name).
Final review nit: the de-context rename updated the type docs but left
'context' in the prose of four public method docs (AgentRunner::tool_extensions,
ToolServerHandle/ToolSet/ToolDyn ::*_with_extensions) plus two doc comments.
Now uniformly 'extensions'. Docs-only; no API or behavior change.
@gold-silver-copper
gold-silver-copper added this pull request to the merge queue Jun 25, 2026
Merged via the queue into main with commit 1bee2a1 Jun 25, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 25, 2026
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.

feat(rig-core): Per-call runtime context for tool dispatch (ToolCallContext)

1 participant