Skip to content

Concepts · agent

Agent History

How the loop holds conversation history, and the two places a hook can change it

Read this when
  • Seeding or inspecting an agent's conversation history
  • Changing what the model sees without changing what the agent stores
  • Looking for application-defined message types

This page gives you the history model, hook boundaries, and application-defined record path for agents.

An agent’s history is a plain []ai.Message. There is no agent-level message wrapper. The loop stores the same type that the provider receives. ai.Message documents that type.

The lifecycle of the slice

WithHistory seeds the slice at construction. The loop copies what you pass, so the agent never aliases your slice. From there, the loop owns it. Run appends its input messages. Then it appends every message the turn produces, including assistant messages and tool results. Messages() returns a copy, so reading history during a run is safe and gives a stable snapshot.

The loop removes nothing. A turn only grows the slice. That makes the history a faithful record of what the model was told.

Design: what the model sees is derived, not stored

Two different questions look similar and are not the same. One asks what the agent has. The other asks what the model gets on the next call. This separation explains why hooks exist at two points rather than one.

BeforeCall runs while the loop builds the prompt. Whatever it returns becomes the Messages on that request. That return leaves stored history untouched. Use this hook for anything the model must see once. Examples include a reminder built from live state, a pruned window over a long conversation, and a summarized prefix. The next call starts from unmodified history again, so a filter here never compounds.

AfterTurn adds to what the agent holds. What it returns appends, and each message keeps the Injected value it carries. It never replaces. Use it when the change must persist into every later turn.

BeforeStop also appends, and it is the only hook that continues a loop that was about to end.

Design: rewriting is a projection, not a mutation. AfterTurn used to replace the history outright, which made compaction a rewrite. Compaction is now two moves: append a summary marked ai.InjectionMeta, then use BeforeCall to drop the prefix before it. The history stays a faithful record, and the pruning lives where every other prompt-shaping rule lives. The old behavior also lost its own work. A replacement fired no events, so a durable agent never learned about it. The change was gone on resume.

Application-defined records

The agent layer has no custom message type, by design. ai.Message maps to provider wire formats. If it carried artifacts, UI state, or status records, application concerns enter the provider-shaped type.

Those records belong to a session instead. pkg/session defines an entry tree where CustomEntry fills this role. It is an application-defined node that persists in the transcript and never reaches the model. To use it, run the loop through pkg/durable. Durable takes entries rather than messages at its input boundary. One turn can then carry both kinds.

If you only need something in front of the model for one call and nowhere else, BeforeCall is enough.

Injected context is marked on the message

A message that an injector added carries ai.Message.Injected. Use InjectionMeta for context that persists, and InjectionEphemeral for context that is true only now. Origin names the injector. The zero value is ordinary, so an ordinary message needs no thought.

Context can also ride inside an ordinary message. An ai.Reminder block does that. A harness appends one to a tool result: the result itself is for the reader, and the injected block is not.

Message.Text() returns the blocks that carry text — the Text blocks and the Reminder blocks — as []ai.Content, in document order.

Design: Text returns blocks, and the caller decides. It is tempting to return one joined string, or a []string of pieces. Both throw away the one thing a caller needs: which kind each block is. A provider joins them all, because the model reads injected context. A renderer keeps the ai.Text blocks and drops the rest, because a reader must not see the injected ones. Neither rule is the SDK’s to pick, and a caller that receives strings cannot recover the choice.

for _, c := range msg.Text() {
switch v := c.(type) {
case ai.Text:
// a reader keeps this
case ai.Reminder:
// only the model reads this. v.Origin names the injector
}
}

Returning blocks also means a caller that wants the injected context alone filters the same slice, and Origin survives the trip. There is no second accessor for it.

Where a format takes one string — the prompt of a CLI, a summary, a debug line — Message.JoinText(sep) joins the same blocks with the separator you give it. A renderer must not use it: the result holds the injected blocks.

Why this is not the reader projection by default. An earlier revision had Text return the visible text and asked providers to call something else. Nine call sites — across the providers, the CLI transports, and ai.Prompt itself — had all reached for Text and were quietly discarding injected context. Showing a reader something meant for the model is a visible mistake that gets reported. Dropping context the model needed is silent, and it is the one that actually gets made.

  • ai.Message — the message type the history holds
  • Durable Entries — the entry model, including application-defined and non-persisted kinds
  • Agent — the loop that owns the history
  • Agent State — runtime state observability