Skip to contents

deputy (development version)

  • Agent$add_note() adds a note to a turn of the conversation: content such as a label saying whether an answer’s citations were found in its sources. Notes are part of the conversation $get_turns() returns, after their turn’s other contents, so shinychat’s history saves and restores them, but they are never sent to the model or counted in its context. Give the Agent a notes function that recognizes your notes (Agent$new(notes = ), also on LeadAgent): $set_turns() and $add_turn() use it to keep the notes in the turns you give them, such as a restored conversation, out of the model’s context. A note can go on a turn that compaction removed from the context. Saved sessions keep notes, and moving to another Chat keeps each on its turn.

  • Agent$clear_tool_results() deletes the tool results an agent, and the agents it retains, saved outside the model context, for example when a conversation ends. Nothing deleted them before, and ContextPolicy()’s default offload_dir is the user cache, so they stayed on disk.

  • A ContextPolicy() with max_tool_result_bytes = NULL and both image limits NULL writes nothing to disk. A subagent answer longer than 8 KiB used to be saved there regardless; it now reaches the lead cut at 8 KiB, without a reference to the rest.

  • Agent$continue_agent() and $continue_agent_async() gain queue: a task sent to a retained agent that is busy waits for the tasks already sent instead of failing with “The conversation is busy”. Waiting tasks are sent in order, each checked as if it were sent then. delegation_tool() queues, so a model that asks one specialist for two things in one response gets both answers, one after the other; it used to get an error for the second. Routes between the agents of a retain_agent_graph() still refuse a busy agent.

  • Agent$set_system_prompt() keeps the compaction summary at the end of the new prompt, as compaction put it there, so changing the prompt after a compaction no longer makes the model forget what the removed turns said. It used to drop the summary unless the new prompt contained it. Pass keep_summary = FALSE to drop it.

  • subagent_activity_info() says which subagent call a tool card shown by subagent_chat_activity() is, including root_tool_call_id, the lead’s tool call that delegated the work. tool_result_reference() finds a tool result that a ContextPolicy saved outside the model context, whether it held a value, an error or a list of content objects, and the new Agent$restore_tool_result() puts back what the tool returned. With them, code that checks an answer against its tools’ results needn’t depend on how either is marked or stored.

  • Agent$get_turns() gains activity and notes, which leave out the subagent tool calls shown in the conversation and the notes added with $add_note(). Code that checks an answer against the tool results the model was given can read the conversation without them.

  • A subagent’s runs, and the tasks a retained agent is sent with $continue_agent(), are traced under the conversation of the run that delegated them (gen_ai.conversation.id), so a trace reader files them with the conversation the user sees. They used to carry the subagent’s own session ID. A conversation_id the host sets on the retained agent itself still wins.

  • A run is traced under the host’s conversation. When the host sets Agent$conversation_id (shinychat’s chat_server() does before each reply), the deputy.run span and the run’s model spans carry it as gen_ai.conversation.id, so a trace reader files the run with the conversation the user sees. Before, they carried the Agent’s session ID, which every conversation answered by the same Agent shares, so a reader that keeps one trajectory per ID merged them. Without a host ID the session ID is used, as before; deputy.run now also carries it as deputy.session.id. A run keeps the ID it started with, even if the host clears it mid-run and a fallback provider takes over. Compaction summary requests, in a run or from $compact(), stay under the session ID, and a clone ($clone()) starts without the host’s ID.

  • Agent$set_system_prompt() keeps the prompts of skills loaded with $load_skill(). It used to replace them along with the rest of the prompt, while $skills() still listed the skills as loaded. A skill prompt that the new value already contains is not added twice. A skill that is loaded again moves to the end, so $skills() lists skills in the order they were last loaded.

  • subagent_chat_activity() and subagent_chat_history() work in apps that run the lead’s replies themselves instead of through shinychat::chat_server(). Leave out chat: subagent tool calls then appear in the lead’s $stream_async() stream that the app appends to its chat, and subagent_chat_history() returns on_save and on_restore callbacks to pass to shinychat::chat_enable_history(). Such an app sets lead$conversation_id before each reply, as chat_server() does. Records are kept under that ID unless you pass conversation_id, a function returning the open conversation’s ID (#252).

  • Agent$set_turns(list()) sets conversation_id back to NULL, since an emptied conversation is a new one. shinychat’s “new chat” does this, so an app with its own loop no longer shows, or saves new subagents under, the conversation it just left.

  • DelegationPolicy() gains chat, a function that builds each subagent’s chat, so a host can choose a subagent’s provider, credentials and request parameters, for example a lower reasoning effort than the lead’s on its own gateway client. It builds $parallel_delegate() responders’ chats too (#237).

  • agent_definition() gains params, request parameters made with ellmer::params(). They apply to "provider/model" models and are saved in definition files. A definition with params whose model copies the lead’s chat needs a DelegationPolicy(chat = ) function (#237).

  • DelegationManifest records the provider and params each subagent started with, beside its model, and moves to schema version 2.

  • ask_user takes its questions as a typed array, not as JSON text; a string goes back to the model with a request to send the array. Questions are checked before anyone is asked: 1 to 4 questions with different texts, 2 to 4 options with different labels, a header of at most 12 characters, and every text a non-empty string. A question that breaks a rule goes back to the model with what to fix, rather than failing with an R error. A handler returns a named list keyed by each question’s exact text, each value a character vector holding the chosen labels and at most one answer the person typed; an ordinary question takes one of these. The model receives, for each question in order, selected (an array of the chosen labels) and other_text (what the person typed, or null), and a tool called from R returns that JSON text. Answers in any other shape are a handler error. At the console, option numbers that don’t match an option, such as 1.5 or 9, are asked for again (#218).

  • LeadAgent$register_sub_agent(replace = TRUE) no longer adds a newline to the lead’s system prompt each time it’s called. Replacing a definition with an identical one leaves the prompt unchanged, so the provider’s prompt cache stays valid (#236).

  • multi_edit takes edits as an array of objects with old_text, new_text and an optional replace_all. It no longer accepts a JSON string or the old_string, new_string and replaceAll names. Parsing that string with jsonlite::fromJSON() read a string that wasn’t JSON as a file path or URL, so a model could make the tool read a local file or fetch a URL. The RSession tool bridge now reads its messages as JSON text only.

  • AgentResult has an ending: a RunEnding value that says how the run ended and holds only what that ending has. RunEndingAnswered(), RunEndingStopped() (who stopped it and the reason they gave), RunEndingLimit() (which limit and its figures), RunEndingCutOff() (the provider’s finish reason) and RunEndingFailed() (the error and where it came from). Every stop reason maps to exactly one of them, so a host no longer parses stop_reason. An answer the provider cut short keeps stop_reason "complete"; its ending is where that shows. The "stop" event carries the ending too.

  • Hook callbacks may return a promise of their result. Deputy waits for it, up to the hook’s new promise_timeout (60 seconds by default). A promise that rejects or doesn’t settle in time is recorded as a hook error and counts as no answer, which for PreToolUse denies the call. A hook that returns a plain value behaves as before. Notification and ConfigChange hooks, and $compact(), don’t wait. An $interrupt() that arrives while a hook’s promise is pending takes effect when the promise settles.

  • A run that has been stopped starts no more tools. A PreToolUse, PermissionRequest or permission callback that calls $interrupt() itself now stops the tool call it was asked about, a tool call that comes in after a stop is refused, and a SessionStart callback that interrupts keeps UserPromptSubmit from firing. The run was stopped before, but the tool still ran. A hook’s own “stop after this tool” still lets that tool run.

  • A Stop hook may return HookResultStop(message) to send an answered run on: the model is asked again with message in the same run, with the same run ID, limits, usage and stream. Stop now fires at each stop of a run, with context$stop_hook_active set once a hook has sent the run on, and SessionEnd fires once, when the run has ended. Both contexts carry the run’s ending. The result’s response is the last pass’s text. approval_review_server() now refreshes on SessionEnd.

  • New hook event PostModelRequest, fired after each model request of a run with the run’s usage so far (context$usage, context$usage_limits, context$request_number), so a host can show what a run is spending while it runs, tools or no tools. Structured requests and the summary requests of automatic compaction count.

  • $stream() and $stream_async() take usage_limits for one run, merged over the agent’s limits as $run_async() merges them.

  • UsageLimits() gains on_unknown_cost. "continue" lets a run with max_cost_usd go on when the cost of a response is unknown. The limit is then enforced against the new AgentUsage field known_cost_usd: the cost of the responses whose cost is known. The budget a subagent or a retained conversation is given is worked out from it too. A run that was stopped or failed now reports the cost of the requests that finished there, where cost_usd alone is NA.

  • stream_result() takes the stream that $stream_async() returned and gives a promise of that run’s AgentResult. It resolves when the run has finished, however it ended, and is rejected when the run could not start.

  • $interrupt() called after $stream_async() but before the stream is first read now stops the run when it starts. It was ignored, and the run started.

  • Saved limits and usage records gain a field each, so a durable job saved by an earlier version is refused when it is bound again, as a job from any other version is.

  • Agent$initialize() with a different Chat drops the previous conversation’s subagent tool cards and pending activity, so they cannot appear in the new conversation or its saved session. Reinitializing with the same Chat keeps its activity (#249).

  • Subagent records keep the SQL behind a Commons query. Where Commons stores the SQL a metric or calculation ran, and the values bound into it, with the result, $inspect_subagents(), $observe_subagents(), $export_subagents(), delegation_history() and saved conversations now keep them beside its card and tag, within fixed sizes. Saved records that carry them use a new format version, which earlier deputy versions ignore; records without them are saved as before (#238).

  • A conversation saved with shinychat’s history now reopens with every turn. A failed tool call inside a Shiny session carries Shiny’s call stacks, which shinychat’s file store can’t read back, so the turn holding it was dropped when the conversation was reopened. $get_turns() now returns tool errors as plain conditions with the same message; the model still sees the error as it was raised (#238).

  • New example commons-subagents: a Shiny app in which a root agent asks Commons specialists concurrently, one of them asks another, their tool calls and trusted measures appear in one conversation, and a reopened conversation shows them again without running anything. It runs against a local test server, with no API keys (#238).

  • TrustedResults() accepts the trusted tool itself, as in TrustedResults(forecast = get_forecast); only that tool object may then produce the result, wherever it is registered. Each "trusted_result" event now also carries tool_fingerprint, a digest of the producing tool’s code, argument schema and metadata, not of a call’s inputs (#238).

  • An agent with a TrustedResults() policy can now retain agents ($retain_agent(), adopt_chat(), $retain_agent_graph()) and call them through delegation_tool() or graph routes. Each retained agent’s tools are checked against the policy when it is retained and before every task, other tools that delegate are still refused, and every trusted result from a retained agent or graph member reaches the root’s on_result once, with the producing agent’s IDs. A retained agent’s own policy applies alongside, without weakening either, also to the graph members it asks through its routes, and two retained agents can’t give one result type different tools. The policy must give its trusted tools as tool objects (#238).

  • $release_agent() now also removes the owner’s delegation_tool() tools for the released agent, which could only fail once it was released (#238).

  • New subagent_chat_history() saves the subagents of each conversation in a shinychat chat with that conversation, through shinychat’s history, and brings them back read-only when it is reopened: tasks, outcomes, conversations with their tool results and cards, usage and which subagent ran for which. Nothing runs again and no subagent resumes. subagent_chat_server() gains conversation, which shows the open conversation’s subagents, live or saved (#238).

  • Agent$conversation_id holds the host conversation an agent is answering in; shinychat’s chat_server() sets it before each reply. Delegations record it, and $inspect_subagents() reports it as host_conversation_id; the summary a lead’s model reads leaves it out (#238).

  • New subagent_chat_activity() shows the tool calls a lead’s subagents make, with their results, in the lead’s shinychat conversation as they happen. Each call is a tool card in the reply that delegated it, labelled with the subagent’s name, and looks as it did in the subagent’s chat. The cards are saved with the conversation and shown again when it is reopened, without running anything; the lead’s model still sees only each subagent’s summary. $get_turns() includes the cards, $get_context_turns() doesn’t, and $set_turns() and $add_turn() separate them again. $save_session() keeps them beside the conversation and $load_session() brings them back (#238).

  • A tool call whose provider ID repeats an earlier call’s in the same run, as with providers that number calls per response, now gets its own "tool_start" event, and a delegation tool called that way runs. Before, the later call was matched to the finished one: its start event was dropped and a delegation failed with “Delegation tools require their owner’s active governed run”. Agents with an approval_dir still refuse a repeated ID within one run, since their saved effects are kept by tool-call ID (#238).

  • Subagent tool results keep the card they showed in shinychat. Inspection, $observe_subagents(), $export_subagents() and delegation_history() used to drop everything stored with a tool result, so a Commons table, plot or “Ran a trusted calculation” card, and its provenance tag, disappeared from the child panel and from saved history. They now keep the result’s display fields and Commons’ commons_tag, within fixed sizes; other stored data is still left out and named in the record. delegation_history() checks a saved card before showing it, and subagent_chat_server() removes scripts, event handlers, forms, external resources and data-* attributes from a card’s HTML and Markdown and keeps what is left inside the card (#238).

  • Tools with an ellmer::type_ignore() argument can be registered on an agent. Registration used to fail with “Names of arguments must match formals of fun”, which stopped adopt_chat() from adopting a Commons chat with a single data source. The argument still isn’t offered to the model, and the tool receives its default (#238).

  • ContextPolicy() gains estimator. With the default "auto", automatic compaction now runs when the provider cannot count tokens: it adds a conservative estimate of later content to the usage reported for the latest response. Previously such providers, including gateways whose token-counting route returns HTTP 404, never compacted. "provider" keeps the old behaviour. A token-counting endpoint that returns HTTP 404, 405 or 501 is asked only once per provider class and base URL (#213).

  • New LeadAgent$set_delegation_sources() replaces the sources a subagent can be given as evidence, so sources that change during a conversation (a drawing revised, a document added) can be offered. The scope can change only between runs; earlier subagent records belong to the old conversation, so the change is refused while they exist unless clear_records = TRUE discards them. An evidence reference that doesn’t match now lists the sources available (and a stale revision names the current one) instead of “Requested evidence is unavailable.”

  • RSession, tool_run_r_code and tool_run_bash no longer pass the host’s environment variables to the process that runs model-written code, and R there no longer reads ~/.Renviron or a project .Renviron. The process gets the variables that locate programs, libraries, locales and temporary files. Name others, such as proxy settings, with the new env argument to RSession$new(), tools_code(), tools_preset() and tools_all(), or the command-line app’s --code-env; env = "inherit" passes everything, as before. This keeps credentials out of what the code is given, not out of its reach: code running as the same user can still read the starting environment of the R process through the operating system, and any file that user can read (#224).

  • RSession$new() gains libpath, the library directories the R process loads packages from, in order, so a host can give its worker a library of its own ahead of the site library while keeping it off its own search path.

  • New Agent$set_chat() replaces the Chat an agent sends requests to, so a host can continue a conversation with a model from another provider. The conversation, system prompt and tools move to the new Chat, reasoning content is dropped from the history, and the agent’s permissions, hooks, tool observers and usage limits keep applying.

  • New Agent$set_context_policy() replaces the ContextPolicy between runs, for example to compact at a different size after changing model. The policy must keep the same offload_dir.

  • LeadAgent$register_sub_agent() gains replace. With replace = TRUE it replaces a registered definition with the same name and updates the lead’s prompt; delegations already running keep the definition they started with.

  • $stream() and $stream_async() reset a cancelled stream controller when a new run starts, as ellmer does. A host such as shinychat that reuses its controller no longer sees the previous run’s cancellation when the next run starts.

  • A write restricted to a file_write directory is checked again just before write_file, edit_file or multi_edit runs. If a file or symbolic link in the path changed after the permission check so that the path now resolves outside the directory, the call is refused instead of writing there. A write that a PermissionRequest hook allowed outside the directory is not checked again (#219).

  • File writes restricted to a directory no longer refuse names that merely contain two dots, such as notes..v2.md. Only a .. path segment counts as path traversal (#219).

  • Native tool names no longer grant native permission treatment to other tools (#216). A host, skill or package tool named read_file, ask_user, web_fetch or any other native name (including variants such as "Read-File") is now checked like any other custom tool: missing annotations take the conservative defaults, readonly mode does not treat it as a known read, and plan mode’s prompt-tool shortcut applies only to Deputy’s own ask_user. The name’s restrictions, such as readonly’s denial of write tools and write-path limits, still apply. A host-chosen prompt tool name keeps its shortcut, and direct permissions_check() calls, including ones built from tool_metadata(), keep name-based classification.

  • edit_file and multi_edit now change only the replaced text. They previously rewrote the whole file through readLines()/writeLines(), converting CRLF line endings to LF and adding a final newline. Matching still reads every CRLF as LF, so multi-line edits written with "\n" apply in Windows and mixed files; new lines take the edited line’s ending. Files are compared and written as bytes, so non-UTF-8 content is kept (#217).

  • A can_use_tool permission callback can no longer allow a call that the rest of the policy denies. In standard mode its allow used to skip the capability checks, so a callback that allowed everything it didn’t block let the model run R code with r_code = FALSE or write outside the file_write directory. Plan and full modes ignored the callback. It is now called in every mode, for each call the policy allows, and can deny the call or pause it for approval. To allow a call the policy denies, use a PermissionRequest hook.

  • Read-only and plan policies now allow an agent’s own delegation tools: a LeadAgent’s delegate_to_agent, and the delegation_tool() and retain_agent_graph() route tools that call its retained agents. An agent created with permissions_readonly() or permissions_plan() can therefore delegate, and each tool call its subagents and retained agents make is still checked against its policy, so they can’t write or run code either. A custom tool doesn’t qualify by using one of these names (#227).

  • hook_log_tools(), hook_block_dangerous_bash() and hook_limit_file_writes() now return NULL when they don’t deny a call, so hooks added after them for the same event still run.

  • Agent$usage() and Agent$cost() now include turns that compaction removed from the model’s context, and Agent$usage()$tool_calls counts the tool calls the model asked for instead of always being 0.

  • The command-line app now reports how many model requests a run made, instead of “NA turn(s)”.

  • edit_file and multi_edit now change only the text they replace, instead of rewriting the file, which converted CRLF to LF and added a final newline. Non-UTF-8 bytes are kept too. Search text written with "\n" still matches CRLF and mixed files, and new lines take the ending of the line they replace (#217).

  • run_bash now treats a non-zero exit status, including “command not found”, as an error and tells the model the status. Standard error is returned after a [stderr] line instead of being discarded (#215).

  • tools_interactive() handlers can now answer without blocking, for apps such as Shiny. A handler may return a promise for the answers, or AskUserDeferred() to show the questions and have the model end its turn so the answers arrive as the user’s next message. AskUserDeferred(extra = ) attaches display data to the tool result. Subagents accept promises but not deferral (#215).

  • New Agent$microcompact() replaces old tool results in the model’s context with a short marker, like Posit Assistant’s /microcompact. Results in the last keep_last turns, or from tools in keep_tools, are kept. It makes no model call, keeps any earlier compaction summary, and leaves the original results in $get_turns(), $last_turn() and saved sessions (#209).

  • agent_definition(model = ) can now be a bare model id such as "gpt-6-luna", which runs the subagent with that model on the lead’s provider, endpoint and credentials, including gateway clients built outside ellmer. "inherit" and "provider/model" work as before; ids containing / still need the "provider/model" form (#210).

  • New TrustedResults() supports the trusted mini-agent pattern from Will Landau and Sam Parmar’s Trusted Mini-Agents: pass it to Agent$new(trusted_results = ) to name the one local tool that may produce each kind of result. That tool’s return value reaches your app unchanged, as a "trusted_result" event (see result_trusted_results()) and through an optional on_result callback, before the model sees it; with model_receipt = TRUE the model gets only a receipt. Registration fails if another tool could produce results: code execution and delegation tools always, and tools that may write or reach the open world unless listed in exempt_tools (#197).

  • Permission callbacks and PreToolUse hooks now get the tool’s argument types in context$tool_arguments. New tool_input_review() turns a proposed tool input into a table of each field’s type, description and value (#197).

  • LeadAgent$new(trusted_results = ) applies the policy to all subagents: each definition’s tools must pass the same check, a designated tool must be the same tool everywhere, and subagents’ trusted results reach the lead’s on_result and run events (#197).

  • New approval_review_ui() and approval_review_server() are a Shiny module for reviewing a pending durable approval. They show each argument’s type, description and value, let the reviewer edit simple fields, and approve or deny. Pass decide to carry out the decision elsewhere, such as in a background process (#197).

  • New inst/examples/trusted-results/ app, adapted from Landau and Parmar’s R template and weather example, shows chat, input review and results side by side. Only the forecast tool can fill the results panel (#197).

  • New vignette("trusted-mini-agents") explains Landau and Parmar’s trusted mini-agent pattern and how Deputy enforces each of its rules (#197).

  • New mcp_console_connection() connects an agent to an MCP Console 0.0.4 server, a sandboxed workbench that keeps R, Python and DuckDB SQL state for a conversation, and mcp_console_control() interrupts or restarts it (#190). Deputy refuses --no-sandbox, options that widen file access, add a proxy or select a remote target, and an unreviewed .agents/console/config.yaml, and closes the connection unless the server reports its sandbox with restricted networking. send counts as shell code execution and needs the bash and web permissions; installing dependencies runs outside the sandbox and also needs dependencies = "allow" and install_packages = TRUE. timeout_ms is capped at 2500 ms, so long cells return a running marker and the model polls. MCP Console records every call and result, unredacted, under .agents/console/sessions/ in the agent’s working directory.

  • Deputy temporarily requires coro >= 1.1.0.9000 from GitHub: CRAN coro 1.1.0 recompiles every generator, costing 0.3 to 0.7 seconds of CPU per model request. Deputy will return to CRAN coro once the fix is released (#192).

  • McpConnection now checks the JSON-RPC id of every MCP stdio reply. The supported mcptools releases wait about 4 seconds for a reply, then take the next output line without checking its id, so a slow reply could become the answer to the next call. A missing or mismatched reply now stops the server, losing its session state, and signals a deputy_mcp_desynchronized error; tools_mcp() tools, which can’t see reply ids, stop the server on the first lost reply. mcp_repl_connection() and tools_mcp_repl() cap mcp-repl’s timeout_ms at 3000 ms, also when it is omitted, so long cells return a busy result and the model polls (#196).

  • McpConnection, tools_mcp() and mcp_repl_connection() now support CRAN mcptools 1.0.3 as well as 1.0.2. Other versions are refused with an error that lists the supported ones (#195).

  • Model requests and tool calls use less CPU (#185).

  • New job_create(), job_run(), job_read() and job_cancel() save an agent task to disk for your own scheduler to run later, in any R process. A job keeps the definition and context revisions you supply, its budgets, completed tool effects and any pending approval. A job interrupted mid-run is marked indeterminate instead of retried, so side effects aren’t repeated; job_cancel() asks a running job to stop at its next checkpoint (#42).

  • New ContextFork() and fork_agent() start a retained specialist from a copy of turns you select from another conversation, either its transcript or its current model context. Each continuation rechecks that the source may still be read. The copy is inert: tool bindings and private provider data are dropped, and current permissions apply (#62).

  • Subagent inspection and observation now keep difftime values, their units and missing values, including in data-frame columns. The subagent chat panel shows content nested in tool results, labels where it came from, notes anything left out, and keeps the original for replay (#166, #167).

  • New Agent$retain_agent_graph() sets up retained specialists that delegate to each other along routes you declare. The graph shares one lifetime budget with limits on depth, delegations and concurrency; cancelling a run cancels everything beneath it; and the root controls who may view each specialist’s conversation. See inst/examples/recursive-agents/ (#169).

  • New adopt_chat() turns a configured ellmer chat into a retained specialist, copying its provider, system prompt, tools and optionally its history, and replacing its callbacks so the owning agent’s permissions and hooks apply. delegation_tool() gives the owning agent a tool for sending it tasks (#168).

  • New Agent$retain_agent() keeps a specialist agent and its conversation for repeated use: continue it with $continue_agent() or $continue_agent_async(), stop it with $cancel_agent() and free it with $release_agent(). Its budget is cumulative, the handle works only with the agent that retained it, and continuing it while it runs is an error. Plain Agents now have the subagent inspection and observation methods too (#152).

  • New inst/examples/trusted-mini-agent/ app: a subagent proposes inputs for a small plant-weight analysis, a person reviews them, and only a designated R tool produces the result (#154).

  • New subagent_chat_ui() and subagent_chat_server() add a read-only Shiny panel showing each subagent’s live activity and conversation, with shinychat tool cards and attachments. It can replay saved history and shows a cancel button if you supply on_cancel. See inst/examples/subagent-chats/ (#158).

  • New LeadAgent$observe_subagents() returns a subscription for following subagent activity: a snapshot, then new events each time you poll, with access checked on every read. Events dropped from the limited buffer, or too large to keep, are reported as gaps. Closing a subscription doesn’t stop anything; $interrupt_subagent() cancels a subagent (#157).

  • Delegation now returns a DelegationOutcome, keeping the subagent’s reply separate from its history. DelegationDisclosure() decides who may inspect subagents with $inspect_subagents(), $read_subagent_result() and $export_subagents(), which return redacted views. delegation_history() replays exported history without running tools or changing the lead’s context. Long replies are offloaded like large tool results (#153).

  • New DelegationPolicy() controls subagents’ tools and human input. Tools can be shared, held exclusively (overlapping use is an error), or built per delegation by a factory returning DelegationResources(), which are cleaned up when the subagent finishes or fails to start. A subagent using ask_user needs a handler from the policy, and durable approvals in subagents are an error for now. Each delegation’s manifest records the policy (#151).

  • New DelegationInput() describes a delegated task as a brief (task, constraints, evidence, deliverable and stop conditions); plain strings still work. Evidence names exact revisions of records passed to LeadAgent$new(delegation_sources = ) and is checked before any request; each delegation’s DelegationManifest records what the subagent received, and text in a brief can’t pose as the evidence list. Directly invoked subagents now stop when the lead is interrupted, parallel delegation enforces the lead’s token limit, and a malformed brief no longer loses the other tasks in a batch (#149).

  • LeadAgent$list_subagents() now lists queued and running delegations in the order they were accepted, with exact stop reasons and any hook error; stopped runs no longer appear completed. Interrupting the lead interrupts its running subagents, and queued parallel tasks stay listed after cancellation (#150).

  • Agent$chat(), $stream() and their async versions accept dynamic dots (!!!), so shinychat can pass its input straight through. The Shiny chat example now shows compaction progress and the summary.

  • Agent$get_turns() and Agent$turns() now return the whole conversation after compaction, so shinychat history keeps every reply; new Agent$get_context_turns() returns what the model receives. Saved sessions (schema 3) store both, and sessions saved with earlier development schemas can’t be loaded (#146).

  • RSession$new(agent, tools = ) lets model-written R code call selected registered tools as tools$name(...), through the agent’s permissions, hooks and limits, and keep the results as R data. The tools must use convert = FALSE and can’t pause for durable approval (#186).

  • New RSession gives a conversation a persistent R process for run_r_code: variables persist between calls, output and plots come back in order, and calls queue. $cancel() or a timeout discards the variables, and the next call says so. The code runs with your account’s access and is not sandboxed. New ContextPolicy() arguments max_tool_result_images and max_tool_result_image_bytes limit images in tool results separately from text; the full result stays available for display (#143).

  • New mcp_repl_connection() gives an agent its own sandboxed mcp-repl session; mcp_repl_control() interrupts or resets it and reports the outcome. Interrupting the agent cancels its active MCP connections. Plots and output previews come back as ellmer content (#69).

  • New McpConnection connects one agent to an MCP server through a client in a separate R process. You can page through the server’s tools, resources and prompts, and must allow each one before use. Calls return promises; after a cancellation, timeout or close() the connection’s tools stop working. It supports mcptools 1.0.2 and is a stopgap until mcptools has a public client API (#48, #99).

  • New durable approvals: in an agent with an approval_dir, a permission callback can return PermissionResultPending() to pause a tool call and save it to disk for a person to decide later, even in another R process. approval_read() shows the pending call, and Agent$resume_approval() approves it (optionally with edited input or a higher budget) or denies it, checking both the saved and the current permissions. The tool must use convert = FALSE. Completed tool effects are logged, a call interrupted mid-run can’t be resumed and must be checked by hand, and your app tracks which conversation each approval belongs to (#43).

  • Hook and permission result constructors, such as HookResultPreToolUse() and PermissionResultAllow(), now return read-only S7 objects. Check their class with S7::S7_inherits() (for example against HookResult or PermissionResult) and use S7::props() for a plain list. continue and interrupt must be a single TRUE or FALSE, and text fields must be strings; suppress_output is still coerced with isTRUE() (#129).

  • Skill() now returns a read-only S7 object. skill$check_requirements() is replaced by skill_check_requirements(skill); to change a skill, build a new one from S7::props(skill). skill_create() and skill_load() are unchanged (#127).

  • agent_definition() and AgentDefinition() are now the same constructor and return a read-only S7 object; build a changed definition from S7::props(). Names, YAML files and delegated limits work as before (#125).

  • AgentUsage() and UsageLimits() now return read-only S7 objects. $ still reads fields; use S7::props() instead of [[ or unclass() for a plain list (#121).

  • AgentResult() and Permissions() now return read-only S7 objects, and their $new() and methods are removed. Inspect results with result_n_turns(), result_tool_calls(), result_tool_results(), result_text_chunks() and result_is_success(), and test a policy with permissions_check(). $ still reads fields. Permissions() now rejects malformed flags and callbacks (#117).

  • AgentEvent() and HookMatcher() now return read-only S7 objects. Create matchers with HookMatcher(...) instead of HookMatcher$new(), and test a tool name with hook_matches(). Check an event’s type with event$type instead of its S3 class; event$data holds the payload, and $ still reads its fields directly (#59).

  • ContextPolicy() and DeputyCompaction() now return read-only S7 objects. Read fields with $, or use S7::props() for a plain list (#123).

  • print() methods now format with cli, wrap to the console width, show braces in your values literally, and write to stdout so capture.output() works (#58).

  • Compaction summaries now include tool results, formatted by ellmer so structured results keep their field names. Previously a source returned by a tool could be missing from the summarizer’s input.

  • New inst/examples/history-recovery/ experiment tests whether a compacted agent answers better when it can also search and read the earlier conversation, including after a later instruction replaces an earlier rule (#112).

  • Automatic compaction now runs within the current run: it shares the run’s usage limits, stops when the run is interrupted, and can happen between tool rounds without losing completed tool calls or usage. New ContextPolicy(summary_fallback_chats = ) lists chats to try in order when the summary request fails with a transient error, separately from the agent’s fallback_chats. A failed or interrupted compaction leaves the context unchanged, an accepted summary survives a later task fallback, and each summary attempt is recorded (#111).

  • Deputy now requires ellmer 0.5.0 or later.

  • Structured output now uses ellmer types. Pass type to $run(), $run_sync() or $run_async() and the agent finishes its tool calls, then returns structured data within the same budget. An optional validate function can ask for up to max_corrections corrections, and each attempt is recorded. The output_format argument, its JSON parsing and validation helpers, and the jsonvalidate dependency are removed.

  • Agent$new() and LeadAgent$new() accept fallback_chats, tried in order when a request fails with a transient error before any response or tool request. A request isn’t retried once output has arrived or a tool has run. Failed requests still count toward usage, and unknown costs stay unknown.

  • With otel installed and a tracer configured, each run gets a deputy.run span around ellmer’s spans, including for async runs and subagents, with events for permission decisions, hooks, compaction, fallbacks and delegation. These spans contain no prompts, tool arguments or results; ellmer’s message capture stays opt-in. The standalone 10-evaluation.R example joins evaluation cases to run IDs.

  • After a run stops with a limit error, Agent$last_run() still returns its result.

  • The deputy command-line tool now defaults to OpenAI’s gpt-5.6-luna. Models you choose, other providers’ defaults and chats you supply are unchanged (#106).

  • New standalone example 09-debate.R: two subagents argue for and against a question in parallel, and a moderator weighs their arguments with the bundled debate skill. If either side fails, the script stops before the moderator (#40).

  • New LeadAgent$parallel_delegate() and $parallel_delegate_async() ask several tool-free subagents for one reply each, in parallel, at most max_active at a time. Results come back by name, including partial results when some fail. Each request is reserved from the lead’s budget in advance, failed requests count toward limits, and batches can be cancelled. Each subagent starts from a fresh chat without the lead’s history, tools or callbacks (#39).

  • New tool_metadata() reports each tool’s origin, declared and missing annotations, and the defaults used, including after cloning and delegation. MCP tools now keep the server’s annotations, connect only to the servers you name, and stop working after their connection reconnects. An MCP tool named like a built-in tool gets none of its privileges, and its path arguments are not rewritten. Subagents take their tools from their definition instead of inheriting the lead’s (#50).

  • Registering a tool whose name is taken is now an error unless you pass replace = TRUE; duplicate names within a batch are always an error. Each batch is checked in full before any tool is added, from Agent$new(), $register_tools(), $set_tools() or a skill. A custom tool without annotations is treated as possibly writing, destructive and reaching outside the workspace (#49).

  • New agent_definition_read(), agent_definition_write() and agent_definitions() read and write agent definitions as YAML files, by default in .deputy/agents/. Tools and skills are looked up by name in registries you supply; reading a file never runs code (#41).

  • A missing suggested package now triggers the standard install prompt, naming the feature that needs it. Loading a skill with YAML front matter now requires yaml instead of silently dropping the metadata (#57).

  • Agent can now stand in for an ellmer chat: $chat(), $chat_async(), $stream() and $stream_async() work like ellmer’s, and they, $run_sync() and $run_async() all apply the agent’s permissions, hooks, limits, checkpoints and usage tracking. shinychat can use agent$stream_async() directly, attachments included. run_shiny() and public access to the wrapped chat are removed, and LeadAgent delegation no longer blocks the R process.

  • New ContextPolicy() compacts the conversation automatically before a request when it gets too long, reporting whether it used the model, a PreCompact hook or the text fallback, and its own usage. It also stores large tool results on disk behind a short reference the model can read in chunks; PostToolUse hooks still see the full result, and a relative offload_dir is resolved when the policy is created. Saved sessions keep the summary and offloaded results, and loading a session replaces the agent’s offloaded results so they can’t leak into later saves. $set_turns() clears the summary but keeps the rest of the system prompt, and LeadAgent passes the policy to its subagents.

  • File and code tools now run in the agent’s working_dir without changing R’s working directory.

  • Concurrent delegations from a LeadAgent now share its remaining usage limits instead of each receiving the full balance. A cloned LeadAgent now delegates with the clone’s own subagents, hooks and run history.

  • The deputy command-line tool is now a Rapp 0.4 executable: run it once with rx or install a launcher with ir tool install. Its task and interactive modes now stream correctly, report tool failures and can resume saved sessions.

  • Agent$new() and every run method accept run_context, a JSON-compatible list of your own identifiers, such as a user or conversation ID, which is attached to results, hook contexts, saved sessions and subagents. ID fields set when the agent was created can’t be changed per run. Tool events and subagent results also carry agent, run, parent, tool call and delegation IDs, and generating them no longer advances R’s random number generator.

  • The public API is now smaller, centred on agents, tools, permissions, hooks, skills, delegation and usage. Removed: the pre-release Agent SDK and Claude compatibility functions, the Claude settings loader, automatic session stores, vendor tool and permission aliases, deprecated run arguments, the todo tools and redundant convenience exports. Each agent has a stable session_id, and conversations are saved and loaded only with Agent$save_session() and Agent$load_session(); sessions and file checkpoints from earlier versions can’t be loaded.

  • Agent$provider() no longer fails with “Can’t get S7 properties with $” on current ellmer, which moved the model from Provider to a new Model class.

  • Agent$set_permission_mode() can now only keep or narrow the permissions the agent was created with. Subagents follow the same rule and keep all the lead’s restrictions: capability flags, tool allow and deny lists, permission callback and write directory.

  • agent_definition() now validates its fields and lowercases names. LeadAgent rejects duplicate names, and $sub_agent_defs is a read-only copy: add definitions with $register_sub_agent() so delegation and the lead’s prompt stay in sync (#79).

  • Agent now rejects a tool request with a missing or malformed tool name before it reaches usage accounting, permissions, hooks or the tool (#26).

  • hook_limit_file_writes() now checks paths the same way permissions do, blocks escapes through symlinks and through paths that only share a prefix (/data2 when /data is allowed), and covers every built-in file-writing tool (#75).

  • HookMatcher() callbacks now run in your R process by default; a positive timeout runs them in a subprocess and reports their full error messages. HookMatcher() also validates timeout, and rejects callbacks that can’t accept the event’s arguments and patterns that aren’t valid regular expressions (#35, #36, #74).

  • skill_check_requirements() now treats malformed or unknown provider names as not matching, while a skill and chat that name the same generic provider still match (#30).

  • Agent$compact() now summarizes with a copy of the agent’s own chat, with tools and callbacks removed. Previously, providers other than OpenAI, Anthropic and Google fell back to ellmer::chat_openai("gpt-4o-mini"), which sent the conversation to OpenAI whenever OPENAI_API_KEY was set.

  • run_r_code and run_bash now tell the model when a command timed out or its subprocess failed, instead of failing internally or reporting a generic “Command failed” (#27).

  • Agent$cost() now returns NA when the cost of any request is unknown; its complete and missing fields tell a real zero from an unknown total. A run with a cost limit stops with reason "cost_unavailable" when its cost can’t be known, rather than enforcing an understated total (#29).

  • A run now stops with reason "tool_loop" after three consecutive identical tool calls that return the same result. Small differences in the model’s surrounding text don’t reset the count, but a changed result does, so polling still works (#34).

  • permissions_standard(), and Permissions() policies that don’t set r_code, no longer allow running R code, and tools_preset("standard") no longer includes run_r_code. The built-in R and shell tools run code with your account’s access and are not sandboxed. For sandboxed R, new tools_mcp_repl() loads mcp-repl only with the read-only or workspace-write sandbox, and refuses missing, inherited, external or unrestricted sandbox settings (#32).

  • tools_web() now behaves as documented. Provider-run web search and fetch tools are checked against the web permission at registration, because Deputy can’t see their individual calls (so a can_use_tool callback can’t approve them either); other provider-side tools are refused. Narrowing permissions to remove web access removes these tools.

  • tools_interactive() now takes a callback and context for its ask_user tool, so concurrent agents, such as one per Shiny session, each get their own handler. Without a handler, ask_user signals deputy_human_input_unavailable. set_ask_user_callback() remains as a process-wide fallback for single-agent scripts (#76).

deputy 0.0.0.9000

  • Streaming now emits tool_start, tool_end, usage and file_checkpoint events. Each run has a stable ID, Agent$interrupt() asks a running agent to stop, and AgentResult includes the run’s usage.
  • New UsageLimits() and AgentUsage() track and limit requests, tool calls, input, output and total tokens, and estimated cost for each run. Subagents inherit the lead’s remaining limits, and their usage is added to the lead’s.
  • New file checkpoints: Deputy’s write and edit tools save each file’s exact bytes before changing it, so the agent can rewind them. The lead and its subagents share one checkpoint journal per workspace, which has a size limit and is kept in saved sessions.
  • Stricter permission checks: every file-writing tool respects the allowed write directory, and "readonly" mode denies unknown, writing, destructive and disallowed open-world tools. Loading a saved conversation keeps the agent’s own permissions, workspace and tools.
  • When a PostToolUse hook replaces or suppresses a tool’s output, streamed tool events show the change too. PermissionResultDeny(interrupt = TRUE) now stops an active stream.
  • New run_shiny() runs an agent for Shiny with the agent’s limits, file roots and checkpoints. It starts lazily, can be cancelled, and recovers from incomplete tool calls. While a run is active, loading a session, rewinding files or compacting is an error.
  • MCP status reporting and more detailed subagent run metadata.
  • Initial development version.
  • Core Agent class with streaming run() and blocking run_sync() methods.
  • Built-in tools: tool_read_file, tool_write_file, tool_list_files, tool_run_r_code, tool_run_bash, tool_read_csv.
  • Tool bundles: tools_file(), tools_code(), tools_data(), tools_all().
  • Permissions with permissions_readonly(), permissions_standard(), permissions_full() and custom Permissions.
  • Hooks with HookMatcher for the PreToolUse, PostToolUse, Stop, UserPromptSubmit and PreCompact events.
  • Delegation with agent_definition() and LeadAgent.
  • Skills with skill_load(), skill_create() and Skill.
  • Saving and loading conversations with Agent$save_session() and Agent$load_session().
  • Works with any chat provider that ellmer supports.