Astrolabe / ajxpublic /korvus
Repository

Agent Session State, Resume and Event Broadcasting

reference scope internal/agent Verification failed
  • internal/agent/session.go
  • internal/agent/resume.go
  • internal/agent/broadcast.go
  • ExitReason
  • AuditCounters
  • TurnBroadcaster
  • ToolCallStart
  • Event
  • ResumeRequest
  • rebuildMessages
  • storedToolCalls
Relevant source files · 6

The internal/agent package owns three closely related but distinct concerns that together make a Korvus session feel like one continuous conversation rather than a series of independent model calls. session.go holds the live state of a running investigation: the message history, the audit counters, and the single ExitReason that explains how the run ended. resume.go holds the logic for reopening a finished session from its durable record, so a follow-up investigation continues the same thread even after a process restart. broadcast.go holds the live event stream that lets a watching client see the conversation unfold in real time while the agent works.

The three are connected through the store: the session's transcript is persisted as store.SessionTurn rows, the same rows a resumed session is rebuilt from, and the same rows a watcher replays when it connects late. That shared substrate is why the layer keeps such strict rules about ordering — persistence must precede publication, and the rebuilt history must be a valid provider transcript even though it was reconstructed rather than observed.

For the wider story of how a session is created, driven and landed, see Agent Run Loop, Tool Execution and Provider Calls; for the caller side that owns the session row, see Session Manager and Lifecycle and Session Persistence, Reopen and Scratch Seeding.

How it works

The live session

Session is the agent-layer counterpart of store.Session: the same concept at a different layer, but a different lifetime. The row is the durable record; the in-memory struct is discarded when the run returns Session. It holds conversation state only — incident and session status live in the store, never here.

The struct carries an explicit split between session-scoped state (Turn, Messages, Audit, providerIdx) and burst-scoped state (pruned, emptyRetries, badToolStrikes, burstTurn, plus the iteration seq range). beginBurst is the single place that knows which fields belong to which scope, and it resets the burst-scoped ones while deliberately leaving the session-scoped ones alone beginBurst. The reasoning matters: the cumulative iteration budget, the history, the audit counters and a sticky provider failover all span the session by design, whereas the no-progress rails must start over at every human-initiated boundary.

Burst itself exists to disambiguate a number that looks the same in two very different shapes — "20 turns in one burst" (the pathological case) versus "20 turns across six bursts" (the healthy case) Session.

Two more pieces of session state are worth calling out because they are easy to get wrong. toolServers snapshots the tool-name-to-MCP-server mapping once at session start, so attribution of a call to a server costs nothing per call and cannot leak across incidents Session. startedAt lives on the session rather than on a context because no single context spans the bursts: a follow-up arrives minutes later on its own request context, so a deadline set at Run could not reach it Session.

markServerUsed is the attribution point, and it is called only after a call actually reached its server — an unknown tool name or unparseable arguments never got that far and attribute to nothing markServerUsed.

Exit reasons

ExitReason is a string type with one rule: every exit path sets exactly one value, and that value lands in the report JSON, the reports row, the final log line and a span attribute ExitReason.

Reason Value Meaning
ExitCompleted completed the model stopped calling tools; the investigation is over
ExitMaxIterations max_iterations a budget (per-burst or per-session) ran out; finalized anyway
ExitTimeout session_timeout the session wall clock or the burst deadline expired
ExitCancelled cancelled the caller cancelled the session
ExitRepeatHalt repeat_call_halt an identical call-plus-result loop was detected
ExitEmptyResponse empty_exhausted empty responses or a content filter
ExitBadToolUse bad_tool_use too many unknown-tool or bad-argument strikes
ExitProviderFailed provider_failed retries and the fallback were exhausted, in any phase
ExitOverflow context_overflow the single mechanical prune did not save the request
ExitAwaitingUser awaiting_user the burst finished and the session is idle, waiting for a follow-up
ExitReportAuthorFailed report_author_failed the author phase produced no verified document

ExitReason constants

The interesting member is ExitAwaitingUser, the one non-terminal reason. A burst driven by a follow-up lands there instead of ExitCompleted, because "the model stopped calling tools" means something different mid-session than it does at the end of one — conflating them would report a still-open session as finished. IsTerminal exists so callers branch on that fact rather than on "the burst returned" IsTerminal.

ExitReportAuthorFailed is set only when the investigation had nothing else to report: a run that already exited for a real cause keeps that cause, because an author-phase failure is a downstream symptom and the primary cause is the more actionable of the two.

Audit counters

AuditCounters accumulates the numbers that feed the report's audit summary: tool calls and input/output tokens, plus two skill lists AuditCounters. SkillsLoaded is the set injected into the system prompt at session start — prompt weight, known before the first model call. SkillsUsed is the MCP-attributable subset: a skill counts as used when a tool belonging to its declared MCP server was invoked, and it is only filled at report time, so it stays empty until the run lands.

Resuming a finished session

ResumeRequest is everything Resume needs, and every field comes from the store — nothing from the session's first run is held in memory, so a reopen behaves identically after a process restart and in any replica ResumeRequest.

validate enforces the invariants before anything is touched: the session must have an id, must belong to the named incident, must have stored turns, those turns must all belong to the session, their sequence numbers must strictly increase, and the last turn must be the non-empty user message that reopens the session ResumeRequest.validate. The last check is what makes the reopen reproducible: the opening message is already persisted by the caller before the run starts.

Resume then builds a fresh session from the request, restores the stored transcript into it, and runs one more chat iteration — burst, author, finalize — exactly as Run does, always landing a report Resume. The provider failover deliberately starts again from the primary: a sticky switch is scoped to one chat iteration, and a provider that failed hours ago is worth asking again.

restore is where the session's cumulative state comes back. It seeds the turn count and audit counters from the session row, replays the stored turns to count the bursts (every burst opens with exactly one stored user turn), remembers the last assistant turn's sequence, and re-attributes the MCP servers whose tools were actually invoked Session.restore.

It also computes activeBefore — the active time earlier bursts spent — from the stored turns activeTime. The gaps between bursts are deliberately excluded: a session that sat finished for two hours must not have that idle time charged against the session-wide wall clock when it is reopened.

Rebuilding a valid transcript

rebuildMessages turns the stored transcript back into the history the model is sent, and it has to solve a problem the live loop never faces: the stored record is a transcript of what happened, not necessarily a transcript that the provider APIs will accept rebuildMessages.

The invariant it enforces is that every tool call gets exactly one result, in call order, before the next non-tool message. A stored result is used as-is; a call with no stored result is answered with the danglingToolResult marker, because the run ended (a crash, a lost write) between persisting the assistant turn and its tool turn, and both provider APIs reject an unanswered tool call; a result that answers no open call is dropped with a warning, for the symmetric reason — a tool result without its tool call is refused just the same danglingToolResult.

Roles need no repair. Leaving out the loop's unsaved prompts (the nudges, the author prompt, the finalize prompt) puts assistant turns next to each other and puts the two user turns of a run that never got an answer next to each other — which is fine, because the Anthropic adapter folds same-role neighbours into one message and the OpenAI-compatible API accepts them.

storedToolCalls decodes an assistant turn's tool_calls column — the inverse of the assistantTurn encoder in the run loop — and unwraps arguments that were stored as a JSON string because they were not valid JSON, so the model is shown what it actually sent storedToolCalls. A turn whose stored calls do not decode is kept as plain text rather than dropped.

Broadcasting live events

TurnBroadcaster is the in-process pub/sub between the agent loop and the WatchSession stream TurnBroadcaster. A watcher subscribes, drains the historical turns from the database, then streams live events; the agent publishes each turn after its database write succeeds and closes the session's channels when the run ends.

Event carries exactly one of two payloads, and they deliberately share a single channel rather than travelling on two Event. The relative order is the whole point: a tool-start must reach the client before the turn carrying that tool's result. Two channels would let the reader interleave them arbitrarily and show a result before the call that produced it.

ToolCallStart announces a call that is about to be dispatched, before its result exists. It is not persisted — the matching result arrives later as an ordinary tool turn, and that turn is the durable record ToolCallStart. It carries the sequence of the assistant turn that requested the call, which is what lets a watcher attach a running tool to the turn that asked for it.

Publication is non-blocking. Each subscriber channel is buffered, and a full buffer drops the event for that subscriber only publish. The agent must never stall on a watcher; a dropped turn is durable in the database and is recovered by reconnecting with after_seq. This is why persistence must precede publication — otherwise the turn would be lost outright.

The broadcaster also keeps a closed latch of finished sessions, bounded to 256 entries in a FIFO latchClosed. A watcher that subscribes after the run ended gets an already-closed channel instead of one that will never close Subscribe. Without that, a watcher losing the race against Close — the agent finishing between the handler's status read and its Subscribe — would wait forever for a close that already happened.

Reopen lifts the latch for a session that is about to run again, and the session manager calls it before SendMessage returns rather than the agent at burst start, so a client that watches as soon as the call answers cannot race the goroutine to the latch Reopen Manager.Reopen. Close ends the stream for every subscriber and latches the session as finished Close, while SubscriberCount exists so that leak regressions — every disconnect must return the count to zero — are assertable SubscriberCount.

How turns get persisted and published

The agent persists a turn and then publishes it, in that order, in a single helper logAndBroadcast. The write uses an independent timeout so turn persistence survives session cancellation — PostgreSQL is the source of truth — and a failed write is one of the few hard failures of a run. On the consuming side, the transport's WatchSession subscribes before draining the database, so a turn landing during the drain is either read from the database or delivered on the channel, possibly both but never neither WatchSession; the rest of that handshake is described in Watch Streams, Read APIs, Webhooks and Proto Conversion.

Relationships

graph TD
  subgraph ag["internal/agent"]
    S["`session.go`"]
    R["`resume.go`"]
    B["`broadcast.go`"]
    AC["`AuditCounters`"]
    RR["`ResumeRequest`"]
    RM["`rebuildMessages`"]
    ST["`storedToolCalls`"]
    TB["`TurnBroadcaster`"]
    EV["`Event`"]
    TC["`ToolCallStart`"]
  end
  S --> AC
  R --> RR
  R --> RM
  RM --> ST
  B --> TB
  B --> EV
  B --> TC
  TB --> EV
  EV --> TC

session.go owns the counters and the exit vocabulary; resume.go owns the reopen contract and the transcript reconstruction, with rebuildMessages calling into storedToolCalls to decode stored tool calls; broadcast.go owns the live stream, with the broadcaster publishing Event values that carry either a persisted turn or a ToolCallStart.

Reference

Symbol Kind What it does Location
ExitReason type The single reason a session or burst ended; IsTerminal separates idle from over src/internal/agent/session.go#L13-L57
AuditCounters struct Tool-call and token counts plus the loaded/used skill lists that feed the report audit summary src/internal/agent/session.go#L60-L72
Session struct Live in-memory conversation state of one run, split into session-scoped and burst-scoped fields src/internal/agent/session.go#L74-L165
Session.beginBurst method Opens a new autonomous stretch and resets everything scoped to a burst src/internal/agent/session.go#L208-L220
Session.restore method Loads a stored session and transcript into a fresh Session, returning the reopening turn src/internal/agent/resume.go#L128-L154
ResumeRequest struct Everything Resume needs, all of it read from the store src/internal/agent/resume.go#L23-L42
Agent.Resume method Reopens a finished session and runs one more burst-author-finalize iteration src/internal/agent/resume.go#L84-L120
rebuildMessages function Turns the stored transcript into a provider-valid history, pairing every tool call with exactly one result src/internal/agent/resume.go#L195-L234
storedToolCalls function Decodes an assistant turn's stored tool_calls column back into model tool calls src/internal/agent/resume.go#L251-L275
activeTime function Sums the active duration of each stored burst, skipping the gaps between them src/internal/agent/resume.go#L159-L176
TurnBroadcaster struct In-process pub/sub between the agent loop and the WatchSession stream src/internal/agent/broadcast.go#L59-L69
TurnBroadcaster.Subscribe method Registers a subscriber channel; returns an already-closed channel for a finished session src/internal/agent/broadcast.go#L88-L121
TurnBroadcaster.publish method Non-blocking fan-out of an event to a session's subscribers src/internal/agent/broadcast.go#L139-L148
TurnBroadcaster.Close / Reopen methods End the live stream and latch the session; lift the latch when it runs again src/internal/agent/broadcast.go#L155-L186
Event struct One live-stream item: either a persisted turn or a tool start src/internal/agent/broadcast.go#L49-L52
ToolCallStart struct Announces a tool dispatch before its result exists; not persisted src/internal/agent/broadcast.go#L34-L41
Agent.logAndBroadcast method Persists a turn, then publishes it — database first, so no watcher misses a turn src/internal/agent/agent.go#L876-L887

Generated by astrolabe from commit 0c2dca73 on branch main.