Structured context updates from tools.
When tools need to update the agent's context (e.g., storing data for later use), they can return a ContextUpdate along with their result. This provides a clear, explicit way to modify context state without magic keys.
Example
defmodule MyTools do
alias Nous.Tool.ContextUpdate
def add_todo(ctx, %{"text" => text}) do
todo = %{id: generate_id(), text: text, done: false}
todos = [todo | ctx.deps[:todos] || []]
{:ok, %{success: true, todo: todo},
ContextUpdate.new() |> ContextUpdate.set(:todos, todos)}
end
def increment_counter(ctx, _args) do
count = (ctx.deps[:counter] || 0) + 1
{:ok, %{count: count},
ContextUpdate.new() |> ContextUpdate.set(:counter, count)}
end
def add_note(ctx, %{"note" => note}) do
{:ok, %{added: note},
ContextUpdate.new() |> ContextUpdate.append(:notes, note)}
end
endOperations
set/3- Replace a key's valuemerge/3- Deep merge a map into an existing map keyappend/3- Append an item to a list keydelete/2- Remove a keylog_event/3- Record a bookkeeping session event (touches no deps)
Integration
The AgentRunner applies these updates to the context deps after tool execution:
case execute_tool(tool, args, ctx) do
{:ok, result, %ContextUpdate{} = update} ->
new_ctx = ContextUpdate.apply(update, ctx)
{:ok, result, new_ctx}
{:ok, result} ->
{:ok, result, ctx}
end
Summary
Functions
Append an item to a list key in context deps.
Apply all operations to a context, returning the updated context.
Apply all operations to a RunContext, returning the updated context.
Delete a key from context deps.
Check if this ContextUpdate has any operations.
Record a bookkeeping session event alongside this update.
The {type, data} pairs added by log_event/3, in the order they were added.
Deep merge a map into an existing map key in context deps.
Create a new empty ContextUpdate.
Get the list of operations in this update.
Set a key to a value in the context deps.
Fold this update's deps operations into a map, starting from initial.
Types
Functions
Append an item to a list key in context deps.
If the key doesn't exist or is nil, creates a new list with the item.
Example
ContextUpdate.new()
|> ContextUpdate.append(:history, %{action: "search", query: "elixir"})
@spec apply(t(), Nous.Agent.Context.t()) :: Nous.Agent.Context.t()
Apply all operations to a context, returning the updated context.
Deps operations are applied first, in the order they were added, then
log_event/3 events are appended to the session log, also in order.
ctx.messages is untouched: a bookkeeping event projects to no message,
which is the whole point of recording a tool's side effect this way.
Example
update = ContextUpdate.new()
|> ContextUpdate.set(:key, "value")
|> ContextUpdate.append(:list, "item")
|> ContextUpdate.log_event(:tool_call, %{name: "search"})
new_ctx = ContextUpdate.apply(update, ctx)
@spec apply_to_run_context(t(), Nous.RunContext.t()) :: Nous.RunContext.t()
Apply all operations to a RunContext, returning the updated context.
For backwards compatibility with tools using RunContext.
log_event/3 operations are dropped here. A Nous.RunContext carries
deps and the run seam, not a session log, so there is nowhere for an event to
go. The drop is logged at warning level naming the types lost, because a
silently discarded audit record is worse than none: it looks like it worked.
A tool whose events must survive has to run through Nous.AgentRunner, which
applies the same update to a Nous.Agent.Context.
Delete a key from context deps.
Example
ContextUpdate.new()
|> ContextUpdate.delete(:temp_data)
Check if this ContextUpdate has any operations.
Record a bookkeeping session event alongside this update.
For a tool that did something auditable which must NOT enter the model's
history — a sub-dispatch made from inside a code run, say. The event is
appended to the session log through Nous.Agent.Context.log_event/3, which
projects to no message, so ctx.messages is unchanged.
type and data are the same pair Nous.Agent.Context.log_event/3 takes: a
bookkeeping Nous.Session.Event type and its payload. A surface type
(:user_message and friends) is refused there, with a warning, so this
cannot be used to smuggle content into the transcript.
Only a Nous.Agent.Context has a log. Applied to a Nous.RunContext these
operations are dropped — loudly; see apply_to_run_context/2.
Example
ContextUpdate.new()
|> ContextUpdate.log_event(:tool_call, %{id: "sub_1", name: "file_read"})
The {type, data} pairs added by log_event/3, in the order they were added.
Deps operations are skipped. A caller that owns a session log uses this to append them; a caller that does not uses it to say exactly what it dropped.
Deep merge a map into an existing map key in context deps.
If the key doesn't exist, it will be created with the map value.
Example
ContextUpdate.new()
|> ContextUpdate.merge(:settings, %{theme: "dark"})
@spec new() :: t()
Create a new empty ContextUpdate.
Example
update = ContextUpdate.new()
|> ContextUpdate.set(:key, "value")
Get the list of operations in this update.
Set a key to a value in the context deps.
Replaces any existing value for the key.
Example
ContextUpdate.new()
|> ContextUpdate.set(:user_id, 123)
Fold this update's deps operations into a map, starting from initial.
This is the single reducer for ContextUpdate operations: apply/2,
apply_to_run_context/2 and Nous.AgentRunner.ToolExecution all fold
through here, so an operation's meaning is defined in exactly one place.
There used to be three hand-synchronised copies, and they had already drifted
— the runner's did a shallow merge and could not see a new operation type at
all.
:log_event operations touch no deps and are skipped: only the caller knows
whether it holds something with a log to put them in.
Example
update = ContextUpdate.new() |> ContextUpdate.append(:log, :b)
ContextUpdate.to_deps(update, %{log: [:a]})
#=> %{log: [:a, :b]}