Skip to content

feat: Add tool context and chat store#1023

Draft
gadenbuie wants to merge 15 commits into
mainfrom
feat/871-tool-context
Draft

feat: Add tool context and chat store#1023
gadenbuie wants to merge 15 commits into
mainfrom
feat/871-tool-context

Conversation

@gadenbuie

Copy link
Copy Markdown
Collaborator

Closes #871

Summary

Adds tool_context(), a dynamic-scope mechanism that lets tool functions access their calling context during execution — the ContentToolRequest, a shared per-conversation store, and conversation history — without polluting the tool's LLM-facing signature.

Key design decisions:

  • Dynamic scope over signature injection. Unlike OpenAI/PydanticAI SDKs that inject context via a magic first parameter, 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$store is 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 by clone().
  • Clone override for store isolation. R6's 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 override clone() (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?
  • Async: capture-before-await. In async tools, tool_context() is valid only during the synchronous prefix (before the first await()). The tool author captures ctx <- tool_context() at the top; since $store is an environment, the handle remains live for mutation after awaits.

New exports: tool_context(), with_tool_context(), local_tool_context()

Verification

library(ellmer)

counter_tool <- tool(
  function() {
    ctx <- tool_context()
    ctx$store$n <- (ctx$store$n %||% 0L) + 1L
    paste("Counter is now", ctx$store$n)
  },
  description = "Increment and return the call counter"
)

chat <- chat_openai()
chat$register_tool(counter_tool)
chat$chat("Call the counter three times.")
chat$store$n
#> [1] 3

# Clone gets independent store
chat2 <- chat$clone()
chat2$chat("Call the counter once.")
chat2$store$n
#> [1] 4
chat$store$n
#> [1] 3

gadenbuie added 11 commits June 16, 2026 12:37
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
Comment thread R/chat.R
bound <- chat_real_clone
environment(bound) <- self$.__enclos_env__
bound(deep = TRUE)
})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get vcr to work here, but it's possible Claude and I were just confused about how it works.

Suggested change
#| 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.
@gadenbuie

Copy link
Copy Markdown
Collaborator Author

@hadley would you mind taking a look at the overall shape of the tool_context() and chat $store() API in this proposal?

For consistency with local_tool_context().
@hadley

hadley commented Jun 17, 2026

Copy link
Copy Markdown
Member

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 context object is opaque from ellmer's perspective, but it's still a regular copy-on-modify object that you can store in the chat history.

@gadenbuie

Copy link
Copy Markdown
Collaborator Author

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).

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 tool_context() inside the tool function, or we could ask tool authors to include a context argument in the tool function signature.

Two things to consider about signature inspection:

  • The tool function signature is generally the interface between the tool and the model. Hiding an argument from the model requires marking the argument with type_ignore(), without which the context argument ends up being shown to the model.
  • Authors may already be using context as an argument, but we could combine both the presence of context in the signature with the type being "ignore" as the complete signal.

I still lean slightly toward tool authors calling tool_context() inside the tool function, but I think either approach is workable.

@gadenbuie gadenbuie marked this pull request as ready for review July 8, 2026 21:29
@gadenbuie gadenbuie marked this pull request as draft July 8, 2026 21:30
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.

Lazy question: how to achieve something like OpenAI sdk RunContext or hooks with ellmer

2 participants