feat: Add tool context and chat store#1023
Conversation
Adds an internal dynamic-scope stack on `the` and the exported `tool_context()` reader plus `with_tool_context()` / `local_tool_context()` helpers and the internal `tool_context_factory()` seam used by later wiring. Part of #871.
`Chat` gains a lazily-created per-conversation `store` environment, mutable by reference and reseedable. A custom `deep_clone` (plus the internal `clone_store_env()` helper) gives `clone(deep = TRUE)` an independent store. Part of #871.
`invoke_tool()` / `invoke_tool_async()` now push the per-call context around tool execution, and the chat tool loop builds it from the chat's `store` and turn history. The async path brackets only the synchronous `do.call` so the context pops before `await()` (B1). Part of #871.
Each `parallel_chat()` conversation now gets its own store seeded from the seed chat, threaded into `invoke_tools()` by index and installed into the returned clone. `batch_chat()` and `create_tool_def()` clone with `deep = TRUE` so they don't share the caller's store. Part of #871.
Adds NEWS entries and a Tool context section to the tool-calling vignette. Part of #871.
…docs $turns now includes the system prompt (if any) as the first turn in both sync and async tool loops, matching parallel_chat() and the documented "conversation history". Also clarify that clone(deep = TRUE) copies the store's top-level bindings into a fresh env while nested reference objects remain shared. Part of #871.
clone_store_env() now builds the fresh environment with the original store's parent, so reseeded stores that inherit from a non-empty parent keep their inherited bindings through clone(deep = TRUE), parallel_chat(), batch_chat(), and create_tool_def(). Part of #871.
R6's shallow clone() shares environments by reference, which means chat$store would be shared across clones. Since the person cloning a chat may not be the person who wrote the tools, a shared store is a silent cross-contamination bug. Override clone() via $set(overwrite = TRUE) to always call the real R6 clone with deep = TRUE, so deep_clone() eagerly forks store_env at clone time. This makes chat$clone() produce an independent store by default — no deep = TRUE required. Simplify internal clone sites (batch-chat, tools-def-auto) that previously needed clone(deep = TRUE) explicitly. Remove saveRDS() caveats from docs. See: r-lib/R6#178
| bound <- chat_real_clone | ||
| environment(bound) <- self$.__enclos_env__ | ||
| bound(deep = TRUE) | ||
| }) |
There was a problem hiding this comment.
This is the R6 clone override hack (from r-lib/R6#178). R6 reserves the clone name so you can't define it in R6Class(), but you can replace it post-creation via $set(overwrite = TRUE).
We save R6's real clone implementation, then install a wrapper that always calls it with deep = TRUE. Our private deep_clone() method handles the actual store forking; all other fields pass through unchanged. The env rebinding (self$.__enclos_env__) is needed because R6's clone uses the calling environment to find self and private.
Worth discussing: should we also add a $fork() public method as the advertised, documented way to copy a chat? It would be more discoverable than $clone() (which is auto-generated R6 boilerplate), and would let us eventually sunset the clone hack if R6 adds proper clone customization (r-lib/R6#273).
The VCR cassette wasn't replaying correctly during R CMD check (stream parameter mismatch). Switch to eval: false with inline expected output instead.
| Here is a simple counter that tracks how many times it has been called: | ||
|
|
||
| ```{r} | ||
| #| eval: false |
There was a problem hiding this comment.
I couldn't get vcr to work here, but it's possible Claude and I were just confused about how it works.
| #| eval: false | |
| #| label: tool-context | |
| #| cassette: true |
with_tool_context() and local_tool_context() now accept a plain list in addition to an ellmer_tool_context object. Missing fields default to NULL (request), a fresh environment (store), or an empty list (turns), making it easier to fabricate contexts for testing.
|
@hadley would you mind taking a look at the overall shape of the |
For consistency with local_tool_context().
|
I think my overall concern with this approach is that it's based on mutation, which is potentially going to make it harder to reason about and impossible to replay previous conversations. I'd approach it by flipping your design decision and instead of relying on a mutable object accessed through a magic function, I'd explicitly respect a context argument in the tool call. (I don't think signature inspection is that bad). Something like this: tool_without_context <- function(x) {
runif(1)
}
tool_with_context <- function(x, context = NULL) {
x <- runif(1)
context <- (context %||% 0) + x
result_with_context(x, context)
}The |
Thanks for the feedback @hadley. I think we can release the mutate-by-reference chat state aspect of this PR. As you mentioned to me on Slack, an R6 class with tool methods could be a reasonable way to organize a set of tools with shared state. I've explored that idea and can confirm that it's a great approach to build on and it moves the problem of state storage into the space where tool authors can handle it. I'm thinking I can either write up a blog post or a vignette in ellmer depending on where we want to document/share that approach. If we remove the chat state idea, what remains is just sharing the tool request and possibly some chat context (current set of turns) with the tool call. For this, we could either have tool authors call Two things to consider about signature inspection:
I still lean slightly toward tool authors calling |
Closes #871
Summary
Adds
tool_context(), a dynamic-scope mechanism that lets tool functions access their calling context during execution — theContentToolRequest, a shared per-conversation store, and conversation history — without polluting the tool's LLM-facing signature.Key design decisions:
tool_context()is opt-in by calling — tools that don't need context don't declare anything. This keeps the tool's arguments clean and avoids signature inspection.chat$storeis a per-conversation environment. Tools read and mutate it by reference (ctx$store$n <- ctx$store$n + 1). It persists across$chat()calls and is independently copied byclone().clone()shares environments by reference, which would silently cross-contaminate stores across clones. Since the person cloning a chat may not be the person who wrote the tools, we overrideclone()(via the r-lib/R6#178 workaround) to always deep-clone, eagerly forking the store at clone time. Worth discussing: should we also add a$fork()public method as the advertised API for copying a chat, with clone() as the safety net?tool_context()is valid only during the synchronous prefix (before the firstawait()). The tool author capturesctx <- tool_context()at the top; since$storeis an environment, the handle remains live for mutation after awaits.New exports:
tool_context(),with_tool_context(),local_tool_context()Verification