Conversations and history¶
Purpose¶
Chain runs into multi-turn conversations and persist them anywhere with
encoding/json.
When to use¶
Any follow-up question, chat interface, or workflow where later runs need earlier context.
How it works¶
A run returns the full normalized conversation in result.Messages. Pass
it back as history: it is sent before a fresh user prompt, and the new
result carries the full reconstructed conversation, so runs chain.
The agent's current instructions govern every request: system messages in history are replaced by the instructions resolved for the current run, so guidance never duplicates or goes stale. Instructions may depend on runtime state through a per-run function.
model.Message JSON is a durable, additive-only contract: field names
and shapes only gain fields, never rename or drop them. Tool messages
carry their evidence on parts and a definitive failure on
failed (see tools); storage stays the
application's job — there is no session object.
History repair¶
Providers reject a conversation with broken call/result pairing: a tool
call without a result, or a result without its call. A run that crashed or
was cancelled partway — or hand-built history — can leave exactly that.
RunWithHistory and RunStreamWithHistory repair history before each
request:
- A tool call with no result receives a synthesized, neutral result ("interrupted before execution; no result was produced"), placed right after the assistant message that requested it. It states the absence of an outcome, not a tool failure, so the model can re-request the call.
- A result whose call is absent — including a result placed before its call — is dropped; no provider accepts it.
Repair is deterministic and idempotent: synthesized results carry no
timestamps, so repairing already-repaired history leaves it unchanged and
repeated resumes stay prompt-cache friendly. Repaired messages become part
of the run's canonical result.Messages.
Normalizing history explicitly¶
The in-run repair is silent by design. When the history crosses an
application boundary — a client-supplied conversation, stored history
being resumed, a context-evicting pipeline — run
golem.NormalizeHistory first and read the report:
SynthesizedandDroppedname what the pass changed, so logging or telemetry can show a client exactly which turns were repaired.Truncatednames tool calls whose arguments are not a valid JSON object — a stream that died mid-arguments. This is detection, not a change: the bytes stay verbatim in the returned history (repairing stored arguments would corrupt evidence), and the same call stays listed on every pass.
Truncated arguments never block a run: every adapter serializes a
non-object call as {"truncated_args": "<verbatim bytes>"} on the
wire, deterministic so replays stay prompt-cache friendly. The model
sees a shaped call naming the failure instead of a rejected request.
The deciding design record is ADR 0026.
Sanitizing untrusted history¶
A history that arrives over a trust boundary — a browser request
resuming a conversation, another service's transcript, a
client-submitted paused run — can assert anything: a system prompt
carrying operator authority, an image URL naming file:///etc/passwd,
a fabricated tool call awaiting "approval". Runs already neutralize
the model-visible part silently (instructions replace history system
prompts; repair fixes pairing), but a boundary that cannot see the
attempt cannot log, reject, or bill for it — and the URL-scheme case
was never handled at all.
golem.SanitizeHistory(history) is the explicit boundary pass, before
Run, RunWithHistory, or RunWithDeferredResults on history you
did not author. It drops system messages, drops URL parts whose
scheme is not http or https (inline-data parts are untouched),
repairs pairing — and reports everything:
SystemPromptscounts the dropped system messages — an attempt reported, since the model never saw them anyway.UnsafePartsnames each dropped URL part with its original message index, kind, and rejected scheme. A user message left empty by the drops is removed; tool results are never removed, so pairing evidence survives.Repairis the pairing report — a fabricated dangling call is synthesized and named here rather than surfacing as a resume-time approval prompt.
The pass never rewrites content: thinking blocks, failure flags, call arguments, and identity stamps pass through, and it is deterministic and idempotent. Runs never sanitize automatically — the boundary is the application's, and trusted server-side history has nothing to strip. What sanitization narrows is reach, not trust. The rules that actually keep a server honest live outside the history:
- Authenticate and authorize at the transport; treat every caller as able to submit any history it likes.
- Scope the toolset to the caller — build the tools per run from the authenticated user.
- Re-validate high-stakes effects inside the tool against server-side state; an approval in a client's history attests nothing.
- Never read history framing as proof — a fabricated message is ordinary content wearing a uniform.
The deciding design record is ADR 0029.
Run and conversation identity¶
Every run carries two identifiers, stamped on the run's events, its
Result, and every message the run adds to the conversation — never on
the history it received:
Result.RunIDis minted fresh per run and never inherited. Passgolem.WithRunID(id)to align a run with a trace or request ID your infrastructure already has; a shared agent's interleaved event streams stay attributable because every event repeats it.Result.ConversationIDidentifies the conversation:WithConversationIDwins when set; otherwise the most recent identified message of the supplied history donates its own; otherwise one is minted, starting a new conversation. Because the identifier ridesmodel.MessageJSON (runId,conversationId— additive fields),RunWithHistorychains runs into one conversation by construction, and the association survives storage round-trips with no session object.
Both come from golem.NewID(), a time-ordered UUID version 7. Fork a
conversation — continue the same history under a new identity — by
passing a fresh golem.NewID() to WithConversationID; a failed run's
RunError.Partial reports the same pair, so telemetry can join a
failure to the conversation it interrupted. The deciding design record
is ADR 0027.
Resuming a failed run¶
A failed run does not have to be a dead end. When a run errors after it
began producing evidence — completed model turns, reported usage,
executed tools — the RunError carries it as Partial: the
conversation through the last completed model turn, the usage those
turns reported, and the counts of model requests and tool executions.
Cancellation and disconnects are included, which is why they ride a
RunError too.
Partial.Messages is resume-ready history: pass it to RunWithHistory
to continue the conversation. A failure inside a tool batch leaves the
evidence ending at the assistant turn that requested the batch — repair
synthesizes results for its unanswered calls, exactly as for a crashed
run. Partial is nil when the run failed before completing anything,
so a first-call failure needs no recovery path.
A tool's deliberate stop (&tool.Canceled, the canceled stage) is
the one kind whose batch survives: results recorded before the stop
stay in the transcript, and every call left unanswered — the stopping
call, its concurrent siblings, everything after it — is closed with the
synthesized no-result result at once. Repair finds nothing to do, so a
cancelled run resumes with zero repair. See
Tools and dependencies.
result, err := agent.Run(ctx, runCtx, "go")
var runErr *golem.RunError
if errors.As(err, &runErr) && runErr.Partial != nil {
log.Printf("run failed at %s after %d requests, %d input tokens — resuming",
runErr.Stage, runErr.Partial.Requests, runErr.Partial.Usage.InputTokens)
result, err = agent.RunWithHistory(ctx, runCtx, runErr.Partial.Messages, "continue")
}
History processing¶
Long conversations outgrow every context window. WithHistoryProcessor
installs a function that rewrites the supplied history once per run —
before validation and repair — so its output, not its input, reaches the
provider. A processor error fails the run before any model call.
golem.TrimHistory(maxMessages) is the builtin: it keeps the newest
messages, then advances past anything that cannot open a request — tool
results whose requesting call was trimmed, and assistant tool-call turns
whose results were trimmed. Repair would otherwise reattach synthesized
results to those turns, paying tokens for evidence the trim meant to
drop. The processor applies to the history only; the fresh prompt and
resolved instructions always join in full. When a message count is the
wrong unit, golem.BudgetHistory(counter, maxTokens) bounds the history
by token budget under the same boundary rule — see
Token counting.
Example¶
Run examples/conversation for an interactive chat loop:
OPENAI_API_KEY=sk-... go run ./examples/conversation
first, _ := agent.Run(ctx, runCtx, "first question")
next, _ := agent.RunWithHistory(ctx, runCtx, first.Messages, "follow-up")
golem.WithInstructionsFunc[MyDeps, string](
func(ctx context.Context, runCtx golem.RunContext[MyDeps]) string {
return "The player's name is " + runCtx.Deps.Name + "."
})
examples/conversation bounds every run to the newest 20 messages:
golem.WithHistoryProcessor[struct{}, string](golem.TrimHistory(20))
examples/run-ids shows run and conversation identity across chained
and forked runs (offline):
go run ./examples/run-ids
first, _ := agent.Run(ctx, runCtx, "hi")
continued, _ := agent.RunWithHistory(ctx, runCtx, first.Messages, "and then?")
// continued.RunID is fresh; continued.ConversationID == first.ConversationID
fork, _ := agent.RunWithHistory(ctx, runCtx, first.Messages, "fork this",
golem.WithConversationID(golem.NewID()))
API surface¶
(*Agent).RunWithHistory(ctx, runCtx, history []model.Message, prompt) (Result[Output], error)(*Agent).RunStreamWithHistory(ctx, runCtx, history, prompt, onDelta)golem.WithInstructions[Deps, Output](string)golem.WithInstructionsFunc[Deps, Output](InstructionsFunc[Deps])golem.WithHistoryProcessor[Deps, Output](HistoryProcessor)golem.TrimHistory(maxMessages int) HistoryProcessorgolem.NormalizeHistory(history []model.Message) ([]model.Message, HistoryRepair)— see Normalizing history explicitlygolem.SanitizeHistory(history []model.Message) ([]model.Message, SanitizeReport)andgolem.UnsafePart{MessageIndex, Kind, Scheme}— see Sanitizing untrusted historygolem.NewID() stringgolem.WithRunID(id string) RunOption,golem.WithConversationID(id string) RunOptionResult.RunID,Result.ConversationID,PartialResult.RunID,PartialResult.ConversationIDmodel.Message.RunID,model.Message.ConversationID(runId,conversationIdin JSON)
Gotchas¶
- Instructions are resolved once per run, before the request is built; correction rounds do not re-evaluate them.
- A dynamic function's result joins static instructions — static first, separated by a blank line; an empty result contributes nothing.
- Never reshape serialized messages yourself; rely on the additive
contract (
json.Marshal/json.Unmarshalround-trips). - Repair pairs calls and results by call ID; calls without an ID cannot be paired and pass through unrepaired. Duplicate results for one call keep the first and drop the rest.
- Run identity stamps only what a run adds: re-supplied history keeps the stamps it carried in, and repair-synthesized results carry none.
- Identifiers are opaque values: match and store them, don't parse meaning out of them beyond the UUID's timestamp.
- History a client submitted is untrusted until
SanitizeHistoryhas run on it, and even then sanitization narrows reach, not trust: the endpoint's authentication is the real boundary. - The history processor runs on exactly what the caller supplies and once per run: nothing re-runs it, so a summarizing processor cannot build on its own earlier output within one run.
TrimHistoryreturns short histories unchanged — the boundary rule applies only when a cut actually happens — and fails the run when a cut leaves nothing that can open a request, such as a history of only tool results.- Decisions live in
docs/adr/0005-message-history.md.