Skip to content

Latest commit

 

History

History
491 lines (432 loc) · 41.3 KB

agent-session-spec.md

File metadata and controls

491 lines (432 loc) · 41.3 KB

Agent Session Specification

An agent session is an ongoing, multi-turn conversation between a user and a single agent. It is Relavium's agent-first entry point — a first-class peer of a workflow run that reuses the same engine substrate (the AgentRunner, the ToolRegistry, the @relavium/llm seam, and the event bus) rather than a parallel implementation; see ADR-0024 for the decision and agent-sessions.md for how it is built. This document is the one canonical home for the session contract; it cites the event schema, the DB schema, and the seam rather than restating them.

Enforced source of truth. The TypeScript shapes below are illustrative. The runtime-validated source of truth is the Zod schema set in @relavium/shared, from which the types are inferred (ADR-0020). This document is the canonical human-readable contract; if the two diverge, this spec wins and the schema is corrected to it.

What a session is (and is not)

  • A session binds one agent (an .agent.yaml, agent-yaml-spec.md) for the whole conversation. There is no mid-session agent switching; multi-agent orchestration remains a workflow concern. The model and its memoized fallback_chain, by contrast, belong to the AgentSession instance: the CLI's mid-chat reseat (/models) resumes a new instance on the chosen model and rebuilds the chain for it — the session id is unchanged, the instance is not (ADR-0059; behaviour in chat-session.md). Each instance still has exactly one model.
  • A session is multi-turn and stateful: each user message produces an assistant turn that may include tool-call round-trips, exactly like a workflow agent node — the difference is the entry point and lifetime, not the execution.
  • A session is auto-persisted and resumable (below); it is not a workflow run and does not appear in run history. It can be exported to a workflow (export).

Request data handed to the controlled provider chain follows the seam's supported inert-data and ownership contract. The shared turn core captures before admission/money awaits and reuses its owned round through pre-attempt hooks and dispatch, retaining live cancellation and creating fresh owned tool rounds. The factory-measured first request can be reused by exact identity as described in the runner contract. Core reuse and Step 8's session measurement, atomic compaction and overflow recovery have independent acceptance; final wave review is accepted separately.

Lifecycle

Hosts select primary delivery when constructing the session handle, following the stream consumption contract. The CLI attaches live subscribers before production; standalone iterated hosts must drain the primary stream and wire its readiness to the session. Session cancellation closes delivery without inventing a replayable transient event log.

stateDiagram-v2
    [*] --> Idle: start(agentRef, context)
    Idle --> Streaming: sendMessage(text)
    Streaming --> Streaming: tool-call round-trip
    Streaming --> Idle: assistant turn complete
    Streaming --> Idle: abort (Esc) — turn ends, session lives (ADR-0057)
    Idle --> Idle: setTurnPolicy (reseat-less mode change, ADR-0057)
    Streaming --> Streaming: setTurnPolicy (stored; no effect this turn, applies next, ADR-0057)
    Idle --> Idle: resume (reload from history.db)
    Idle --> [*]: cancel / end
    Idle --> Exported: export → .relavium.yaml
Loading
Operation Meaning
start Open a session for an agentRef with an initial SessionContext. Allocates a sessionId and persists the session row.
sendMessage Append the pending user to the working transcript, run one assistant turn through the shared turn core (streaming + tool-call loop), and retain the final assistant text on success. The host atomically persists the completed turn's durable messages, including content-free tool structure and an explicit terminal even for empty final text. Working context excludes tool pairs; carrying them to a later request is deferred (ADR-0095 §2).
setTurnPolicy Set/clear the reseat-less mode policy (ADR-0057) — the advertise-filter + the interactive approval hook — on the same session instance (no reseat, no tool-context loss). Snapshotted at each turn start, so a change applies on the next turn. The ask / plan / accept-edits / auto enum lives in the host; this is its mode-agnostic engine projection. Callable in any state, including mid-turn; inert once cancelled (a cancelled session runs no further turn, so the policy is never read again).
abort Mid-turn abort (ADR-0057 EA7): end the in-flight turn via its AbortSignal but keep the session alive — settle one session:turn_completed{stopReason:'aborted'} (no error), roll the pending user message back, and return to idle. Distinct from cancel (which is terminal): no session:cancelled, no new status. No-op when no turn is in flight; a concurrent cancel wins. A late abort that lands after the turn already resolved is also a no-op — that turn completes normally and its reply is kept (abort interrupts an in-flight turn only, never discards a finished one).
cancel Abort the in-flight turn via AbortSignal and end the session (the terminal session:cancelled); the session stays resumable from its persisted transcript.
runUserCommand Run a USER-invoked !-shell command (2.5.D, ADR-0061) — the additive method that routes a shell escape through the one run_command dispatch boundary, reusing the same dispatch-context construction as a turn (toolPolicy allowlist, fsScope, gateApproved:false, the mode-aware confirmAction): enforcePolicy(allowedCommands) before approval → spawn/shell:false. The caller pre-tokenizes the line into command + args (no shell expansion); the user grant of run_command is scoped to this one-off dispatch and never reaches the model. Returns a classified UserCommandOutcome (ran | denied{allowlist} | failed | cancelled) — no raw error escapes. Callable only when started + idle (a ! never races a turn); leaves the session idle.
resume Reload a persisted session (messages + context) and continue.
compact Summarise the older working context, retain the latest complete exchange and append a durable boundary marker. Callable only when started and idle; returns a typed CompactionResult. Memory-policy refusal precedes no-op detection, provider planning, events and egress.
trimHistory Keep a suffix under a message-unit bound, starting at a user boundary; append a marker without an LLM call. Callable only when started and idle; returns a typed TrimResult. Memory-policy refusal precedes bound/no-op checks and mutation.
export Serialize the session to a .relavium.yaml scaffold (export).

The turn loop, tool dispatch, streaming, and fallback are the same code paths a workflow agent node uses: the session is a thin wrapper over the correlation-agnostic turn core — the runAgentTurn path the AgentRunner (1.O) also wraps for a workflow node — managing conversation state and context. (1.V drives that turn core directly; it does not route through the run-only NodeExecutor the AgentRunner exposes. "Same AgentRunner path" means the shared turn-core execution, not the NodeExecutor surface.) The lifecycle emits the session:* event namespace — defined, with the run namespace, in sse-event-schema.md (this spec does not enumerate event names). 1.V keeps the conversation in-memory (the in-flight LlmMessage/ContentPart form) and emits session events through an injected sink; wiring that sink onto the shared RunEventBus (per-session sequenceNumber + gap/resync) is 1.W, and the durable SessionMessage schema + persistence is 1.X.

Request projection and history operations

The authored memory contract selects one request projection, also used by the session's context estimate. It applies to fresh, resumed and model-reseated instances. Selection and summary placement happen before same-role folding; generated summaries remain untrusted user-role data and cannot change the authored system prompt. Each request owns its message/content arrays and text parts, so request consumers cannot mutate the working transcript.

AgentSession.memoryPolicy returns the copied, frozen policy. automaticCompactionAllowed exposes its resolved permission independently of whether a trigger or budget permits a call. compactionRefusal and trimRefusal expose the same MemoryPolicyRefusal that the operations return: { kind: 'policy_refused', memory: 'none' | 'window', message: string }. The message is a fixed, secret-free explanation. Lifecycle misuse still throws SessionStateError before a policy outcome. Hosts use these getters before progress or bound validation; the engine remains the authority.

reconstructSessionState(record, messages) returns a text-only, unfolded SessionResumeState. Alongside its messages, costs and turn counter, it supplies required completedTurnSpans: ordered, non-overlapping { start, end } indices into that message array, with exclusive end. Each span starts with a user and contains either that user alone for an empty final, or the user plus its final assistant text. Structural durable rows are excluded. Legacy text remains outside spans rather than being invented into a completed turn. The current pending user is tracked separately during a send. Build resume state with this helper; AgentSession.resume validates span bounds, roles and text-only parts before host cost callbacks, refuses invalid metadata with SessionStateError code invalid_resume_state, and copies the admitted state. A compact/trim rebases surviving whole spans; a failed, aborted or cancelled turn creates none.

Measured compaction and one-shot recovery

Automatic work is permitted by the resolved memory policy, independently of funding. Pre-send and after-turn triggers measure the actual next owned request (system, projected history, tools, kept exchange and pending user), using the first attemptable entry's authoritative window and shared output reservation. Billed input/cache/tool-round usage does not drive the trigger. Unknown/custom windows and providers managing their own context skip automatic work. A trigger compares input with compactThreshold × window, and input plus output reservation with the full window. The irreducible kept exchange/pending user, tools, system and maximum summary wrapper are compared with the full window; if they cannot fit, pre-send compaction is skipped and that turn cannot start another recovery cycle. A failed/skipped pre-send operation sends the original owned request. Budget refusal or cancellation sends no main request and settles the active turn. SessionDeps.afterTurnCompaction: false disables only unused after-turn work; the CLI one-shot uses it while retaining explicit summary permission at active entry points.

One inner operation shares the active turn's signal; idle compact() owns its own controller. It greedily folds older whole messages through at most four budget-gated passes, keeping the latest completed exchange, following legacy text and the explicit pending user verbatim. Every summariser construction fits every known attemptable entry's measured input-plus-output bound. An individually oversized foldable message uses disclosed head-and-tail truncation; system text, running summary, kept exchange and pending user are never truncated. All passes must succeed with nonempty text before one summary/history replacement and one boundary event. Cancellation, refusal or failure installs no partial summary; engaged spend remains accounted. keptTurnCount includes retained legacy user slots and empty-final exchanges, excluding the pending user, so the persister advances only the dropped completed-history boundary.

For manual unknown/mixed-window compaction, each pass additionally respects the fixed 16,384-token soft input bound. This is an operational bound, not model metadata or a fit guarantee. The surface acknowledges a fixed unknown-window disclosure through SessionDeps.onCompactionStart before the first admitted call can egress. A failed acknowledgement sends nothing. The callback receives the current operation's signal and checks it before queued publication. Cancellation races a pending acknowledgement, releases unused admission and returns the session to idle without waiting for the terminal sink. Late acknowledgement/rejection is observed and grants no egress authority. Automatic paths never use the soft bound. All opened moments end with success, one failed-moment event, or terminal session cancellation; first-admission refusal opens no moment.

CompactionResult distinguishes budget_refused {message, momentOpened} from ordinary failed, cancelled, nothing_to_compact and policy refusal. Manual refusal returns its safe cap message without a turn terminal. After-turn refusal preserves the successful reply and all history; first-pass refusal emits a standalone side notice, later refusal closes the open moment once. Budget refusal never trims. Ordinary after-turn failure retains the configured deterministic trim fallback; cancellation does not trim. Active pre-send/recovery refusal settles one budget_exceeded turn terminal naming the cap. Event shapes have one home in sse-event-schema.md.

Financial request inputs

SessionDeps.maxTokensEstimate is an optional estimate-only fallback copied at construction. CLI fresh/resumed/reseated chat and one-shot entry points pass the resolved config value, including when no budget governor exists. The shared turn core estimates every current tool round before cross-provider reasoning stripping and forwards required cap-plan/request identity, input estimate and the same frozen fallback to admission. The canonical estimator, cap precedence, handoff and failure-evidence contract lives in llm-provider-seam.md. The same owned construction and cap authority govern measured session context and each compaction pass.

Classified context-window failures

An unrecovered official, fixture-matched overflow settles the turn with context_overflow, never a provider-authored message. The engine names the attempted model, its authoritative window (or fixed size unknown wording) and whether tools already ran. The internal recoverability fact is true only before any tool round or committed content; it is not persisted or emitted. Before content/tool commitment, one genuine overflow may invoke compaction and one fresh measured retry when automatic permission and an authoritative applicable window allow it. Observer failures cannot forge this private invocation-bound evidence, even by rethrowing a genuine error from a previous call. A failed/skipped prior pre-send operation suppresses recovery. Nothing to fold or ordinary recovery failure retains the original overflow; budget refusal settles budget_exceeded. Retry overflow is final. Main-attempt usage is accumulated across the two attempts; summariser spend remains separate and never consumes a user-turn slot. A turn that already dispatched tools is never restarted by this classification. Native Anthropic context stops retain their actual usage through the failed turn and money ledger. The LLM seam owns the dialect criteria, custom-endpoint downgrade and admission-release evidence; the CLI owns command-specific remedies.

Hard turn cap

A session carries a hard turn cap — a finite DoS fail-safe on the number of turns it will run (engine default 50, overridable at construction; 0/absent ⇒ the default). It is distinct from two other limits and must not be conflated with either: [chat].max_messages (a history-trim threshold that silently continues the session — config-spec.md) and the turn core's within-turn maxToolTurns tool-loop guard. A sendMessage past the cap ends loudly, with no egress: session:turn_completed carries stopReason: 'error' + error.code: 'turn_limit' (sse-event-schema.md) — never a silent stop; the within-turn maxToolTurns guard surfaces the same turn_limit code through the same event. The cap is an engine-API knob (SessionDeps.maxTurns); a surface maps the [chat].max_turns config field onto it at construction time — that surface field was added in build-phase 2 (workstream 2.M); see the [chat] block in config-spec.md.

Only provider-engaged turns consume a slot, including failed or aborted turns whose trusted host callback throws. Engagement is recorded on actual non-error provider chunks and chain-owned provider invocation evidence, independently of a budget hook, the exception type or a positive token count. Local preparation, credential, deadline setup and provider-method lookup refusals before invocation consume no slot. Accounting uses an internal outcome carrier without mutating the thrown value, including frozen classified exceptions. Terminal usage retains known prior attempts and an observed valid terminal usage chunk; an unreported current attempt adds no invented usage. Ordinary raw callback/money errors remain visible to the API caller after the fixed, secret-free session terminal. If prototype or diagnostic-field inspection during session presentation itself throws, the fixed terminal still uses canonical accounting and the original throwable is rethrown. A successful turn counted before its later durability flush is counted once even if that flush fails; its known quantities outrank unrelated exception metadata for every error class. A later-round budget pause retains earlier engagement and usage. Terminal cancel() retains its sole cancellation event. Provider diagnostic normalization and cause classification follow the normal turn taxonomy, with opaque provider causes kept private. Nested diagnostics must retain actual invocation evidence and cannot permit another send past the hard turn cap.

The shared turn driver records the exact throwable escaping its attempt observer. Inline generated responses preserve that observer failure without entering provider classification, even when the observer throws an LlmProviderError; genuine provider failures retain their normal mapping. A later tool round guards budget-error inspection so a throwing prototype or diagnostic cannot replace the original callback failure. External emit/readiness/record and chain clock/backoff failures have call-local origin distinct from internal admission settlement. Workflow callers refuse retry/gate and failure-writer authority from that origin. Admission/money causes are unwrapped only with exact current pre-attempt provenance, never solely by error class. Session preserves classified-error delivery and raw rejection while using fixed internal, non-retryable observer presentation; the callback diagnostic cannot become public authority. Post-success completion and commitment-flush callbacks carry the same exact observer provenance; readable classified errors use fixed internal, non-retryable presentation, while raw/opaque failures retain their original rejection. Already engaged turns keep their real usage and consume one slot. The session attempts its fixed terminal with canonical accounting; a throwing sink cannot guarantee publication, and a sink recording before throwing may see multiple notification drafts. Compaction uses the same captured outcome and preserves observer origin through its start/finish lifecycle notifications. Readable classified observer failures return a fixed private-safe failed result; ordinary raw or opaque observer failures reject with the original value. Actual cancellation and genuine pre-egress budget refusal retain their existing handling. Controller-factory failures in send, compaction and user commands release running/abort state and reject the original value without classified delivery. A throwing turn-start notification receives the same cleanup before any provider call. Terminal cancellation keeps precedence; a refused initialization consumes no provider-engaged slot. Compaction clears its running/abort state on every exit. If a trusted controller factory raises abort or cancel before returning its controller, the returned signal carries that recorded intent before work begins; terminal cancellation permits no later start/compaction notification or command. Cold compaction planning also rechecks terminal cancellation after provider resolution and before arming a controller. Cancellation there permits no credential or provider call and keeps the session closed; idle abort remains a no-op and a warm memoized plan does not rerun the resolver. If the resolver starts another real session operation, compaction rechecks its idle precondition and throws the existing lifecycle refusal before arming a second controller. The running operation retains the controller targeted by abort and cancel. A successful commitment flush rechecks terminal cancellation before installing the reply or publishing completion. Known usage, cost and provider-engaged turn consumption remain accounted. Compaction estimates the prospective summary/history projection before installing it and rechecks terminal cancellation after the provider-supplied estimator. A cancellation during that estimator retains the previous history and summary. Ordinary estimator failure remains best-effort; the existing EA7 late-abort no-op after successful summarisation remains unchanged.

Session context

SessionContext is the workspace situation a session runs against, auto-detected from the launching surface and overridable by the user.

interface SessionContext {
  workingDir: string;        // workspace root (auto-detected; overridable)
  activeFile?: string;       // the surface's active file, if any
  selection?: { file: string; startLine: number; endLine: number };
  gitRef?: string;           // current branch / commit, for provenance
  fsScopeTier: 'sandboxed' | 'project' | 'full';  // same tiers as workflows; default sandboxed
  variables?: Record<string, string>;             // session-scoped {{ctx.*}} values — plaintext, NO secrets (§ Tools, secrets)
}

fsScopeTier and the command allowlist are the same filesystem-scope tiers and allowedCommands policy a workflow uses (see built-in-tools.md and workflow-yaml-spec.md); the chat-mode defaults (fs_scope, the command allowlist, default_model, max_turns (the hard turn cap → SessionDeps.maxTurns), max_messages, and an optional pre-egress cost cap max_cost_microcents / on_exceed — the same ADR-0028 governor a workflow budget uses) live in the [chat] block of config-spec.md and reference those canonical homes — they are not re-declared here.

Session messages

SessionMessage is the persistence / transcript type for a turn. It is append-only (mirroring the run-event log): messages are never edited or deleted, only appended.

interface SessionMessage {
  id: string;
  sessionId: string;
  sequenceNumber: number;                 // monotonic per session
  role: 'system' | 'user' | 'assistant' | 'tool';
  content: SessionContentPart[];          // session-only durable union: structural tools, handle-only media, signature-less reasoning
  modelId?: string;                       // canonical model id for an assistant turn (fallback-aware; mirrors session_messages.model_id)
  compaction?: { droppedThroughSequence: number };  // ADR-0062: present ONLY on a role:'system' compaction/trim boundary marker — the durable seq through which older messages are superseded (mirrors session_messages.compaction_dropped_through_sequence)
  timestamp: string;                      // ISO 8601
}

The session-only SessionContentPart union is owned by @relavium/shared (ADR-0095). Its exact structural tool fields, byte-size units and engine id form live in the database contract. It refuses raw arguments, results, continuation signatures and unknown fields instead of silently dropping them. Non-tool media remains handle-only; tool attachments carry only handle metadata, never result filenames or transcripts. The generic DurableContentPart used by run/event/IPC positions is unchanged.

SessionMessage is mapped into the seam's LlmMessage, rather than copied. The next turn sees the text-only projection: carrying earlier tool rounds is deferred. Both forms are Relavium-owned, so no vendor SDK type crosses the seam (ADR-0011). A completed turn persists its user row, ordered structural call/result rows, and one terminal assistant row containing a text part, even when that text is empty. The additive toolHistory field on the completed-turn event supplies only structure; streaming tool inputs and result summaries never feed transcript persistence. The persister commits the entire exchange and session totals atomically. A failure latches and stops later model/command egress. Admission checks the live latch at each provider attempt and effect preparation, and immediately before every actual tool dispatch, including unjournaled tools, a cached idle-command key and a cost-write failure after provider admission. The dispatch check runs after asynchronous approval and preparation. Its refusal proves dispatch never started and releases any prepared claim. Settling or discarding an already-started effect remains available to record its outcome. Errors and aborts commit no transcript; their billed cost remains real.

Resume, model reseat, export, boundary mapping and effect disclosure share the pure structural history projection in @relavium/shared; existing core exports remain available. completedSessionTurns selects its completed exchanges; resumableMessageSequences and resumableTurnBoundarySequences supply the matching host boundary seeds. A tool-call row with preamble text is never a terminal, and an unfinished exchange rolls back. An empty-final turn restores its user message alone and counts as a completed turn. Compaction/trim boundaries retain whole turns; /trim still takes message units. Export reads all historical completed turns, including those superseded by a working-context boundary. The engine's internal projection-event emit boundary requires keptTurnCount for both compaction and trimming, so current producers cannot fall back to message count. The public event schema still accepts older events without that additive field; the persister's legacy compatibility does not weaken the current producer requirement.

Legacy empty-final compatibility. Before explicit empty terminals, the writer could persist a successful empty final as a user row alone. A bare user immediately followed by another user retains its text in resumed/reseated context, matching the old projection. It prefixes the next completed export prompt and has its own compaction/trim boundary slot. It does not create a terminal row or add to the reconstructed hard turn cap. The final bare user still rolls back, and interrupted structural exchanges stay excluded. Legacy prefix text without a later terminal remains resumed context and export metadata; it does not invent an exported node. Explicit empty-terminal turns retain their ordinary distinct identity. No historical row is rewritten. The legacy rollback is a read projection: a later append can make a previously trailing bare user nontrailing, preserving the old archive's ambiguity rather than permanently deleting its text.

Relationship to the run messages table. A session's messages are persisted in session_messages, bound to a session — distinct from the existing per-step run messages table, which is bound to a step_executions row within a workflow run. The two are deliberately separate (different lifecycle and FK parent); see the table definitions in database-schema.md. They share a shape family but must not be merged, to avoid coupling the session and run persistence stories.

Tools, secrets, and security scope

A session uses the same tool surface as a workflow agent: the built-in ToolRegistry (built-in-tools.md), the same FS-scope tiers, and the same mandatory guardrails (run_command allowlist; git_commit behind approval). Per ADR-0029:

  • a session inherits the agent's tools and may only narrow them, never escalate;
  • a secret-typed value is never interpolated into a prompt or tool text;
  • context.variables (the {{ctx.*}} map) is plaintext supplied by the surface that is echoed verbatim in the session:started event payload and persisted in the session row — it MUST NOT carry an API key or any secret. Provider keys and MCP named credentials use their keychain-backed resolvers. Workflow secret inputs have a separate caller-supplied admission contract; this does not authorise putting resolved credentials in session context.variables;
  • http_request / MCP egress is subject to the same SSRF policy as a workflow.

The user's own conversational content typed into a session is the user's data: it is persisted in the history.db (on the CLI surface, unencrypted at rest, guarded by 0600/0700 OS permissions per ADR-0050; the desktop's SQLCipher-encrypted store is a separate surface) and is not a managed secret — this boundary is stated in security-review.md.

Events

A session emits on the session:* namespace — its single canonical home is sse-event-schema.md, which defines the SessionEvent union, the shared base envelope, and sequenceNumber gap-detection. The steering events (agent:directive_injected / agent:context_compacted / agent:context_cleared) are reserved in Phase 1; the steering channel narrative lives in agent-sessions.md.

Export to workflow

Per ADR-0026, a session exports to a .relavium.yaml scaffold that the author reviews before committing:

  • completed logical turns become a linear chain of agent nodes, in order, carrying the agent binding, resolved prompts and the deduplicated union of resolved structural tool names, per ADR-0095 §1 and §3. An empty terminal assistant text still completes a turn; tool preambles do not create extra nodes;
  • the full transcript is preserved in the workflow's durable metadata field, including content-free tool structure and user/terminal assistant text. It survives parse → serialize round-trips. Tool arguments/results are excluded, while user-authored and assistant text can contain sensitive content. Prompt interpolation neutralisation does not redact that text;
  • parallel / conditional / loop structure is not auto-inferred — the author adds it on the canvas.

Successful CLI chat-export writes a fixed continuity/review notice to stderr, including in JSON mode. It also warns that historical agent snapshots may need edits to pass current authored validation before running. Live /export renders the same notice in the interactive chat/Home transcript and uses stderr on plain/JSON drivers; the machine event stream is unchanged. A scaffold does not recreate the session's working context: summarised, trimmed or omitted context may not transfer, and no raw model-issued tool arguments/results are available to restore. Historical persisted turns and content-free tool structure are mapped as below. Retained user/assistant text, @ attachments, ! output and authored agent literals still require review before running or committing. The notice does not redact them, and there is no raw-tool-content export opt-in.

The export produces the format owned by workflow-yaml-spec.md; the mapping (session turn → agent node, transcript → metadata) is the contract owned here. The desktop "Export to Canvas" affordance and the CLI relavium chat-export both drive this one contract.

Precise mapping (1.Z). Given a loaded AgentSessionRecord + its ordered SessionMessage[], the exporter builds a WorkflowDefinition deterministically (no wall-clock / randomness, so the artifact is reproducible; snapshots satisfying current authored validation round-trip):

  • Nodes — a single input node (id: input), then one agent node per COMPLETED logical turn in sequenceNumber order (id: turn-1, turn-2, … — 1-based), then one output node (id: output). A logical turn begins at its user row and ends at its terminal assistant text part (empty text counts). Preserved nontrailing legacy bare-user text prefixes the next completed prompt; it creates no extra node. Structural tool preambles/results do not create nodes. An interrupted exchange is omitted from the chain and remains in the full metadata. Each agent node carries agent_ref = the session's agentSlug; prompt_template = the user text, with interpolation openers neutralised (omitted if empty); tools = the deduplicated union of resolved names in its structural calls, including admitted MCP ids. The fixed unresolved unknown_tool marker never becomes a grant. No model/temperature/max_tokens/retry/output_schema are emitted — those are authoring concerns the user adds on the canvas, not replay fields.
  • Edges — a straight linear chain input → turn-1 → … → turn-n → output (just { from, to }); when a session has no completed turn the chain is input → output. No parallel/conditional/loop edges (ADR-0026).
  • Workflow id — deterministic ASCII kebab-case, matching kebabIdSchema. The title undergoes NFKD compatibility decomposition, combining-mark removal, lowercase and explicit dotless-i (ı → i) and sharp-s (ß → ss) mappings; other non-ASCII runs become separators. The title slug is limited to 128 characters with no trailing separator. This is not universal transliteration. An absent or unusable title uses exported-session-<session-id> when the session id is legal lowercase kebab-case and at most 111 characters (ordinary UUIDs survive unchanged). Other external ids use exported-session-id- plus four-digit hexadecimal UTF-16 encoding of their first 24 code units. Generated workflow ids are bounded and renameable, not guaranteed unique: normalisation and truncation can collide. Full original identity stays in metadata; the default export filename still uses the session id, not the generated workflow id.
  • agents — the session's frozen agentSnapshot (an inline Agent) is emitted as the sole agents[] entry so agent_ref resolves. Historical snapshots retain their ordered fallback entries and attempt budgets even when current authored admission rejects them (for example, repeated model/provider tuples). Such a scaffold requires deliberate author repair before it parses/runs; export never silently deduplicates the frozen agent or rewrites history. When no snapshot was captured, agents is omitted and agent_ref resolves against the workspace agent registry at author time (the file still parses — agent_ref resolution is the engine's job, not the schema's).
  • metadata — the full persisted transcript, including content-free tool structure, under a single reserved key: metadata.relaviumExport = { source: 'session', sessionId, agentSlug, title?, createdAt, updatedAt, messages: SessionMessage[] }. It is a real schema field (z.record), so it survives parse → serialize round-trips.
  • Determinism + exclusions — the YAML emitter (1.Z, serializeWorkflow; 1.L is parse-only) sorts map keys alphabetically and preserves array order, so parse → serialize is byte-stable. The strict SessionContentPart transcript refuses raw model-issued tool arguments/results and continuation signatures (ADR-0095). User conversational text remains user data in the export.

Validation and persistence

  • Validated against AgentSessionSchema / strict SessionMessageSchema / SessionContextSchema (Zod, in @relavium/shared) — invalid input fails fast, like every other authored/runtime contract (ADR-0023).
  • Persisted in the global history.db (agent_sessions + session_messages; on the CLI surface unencrypted at rest, 0600/0700-guarded per ADR-0050); the DDL is canonical in database-schema.md. Credential retrieval uses the OS keychain and does not copy application-held keys into rows, messages or event payloads (keychain-and-secrets.md); this is distinct from the sensitive content a user can supply in their conversation.

The session store also validates every denormalized metadata field against the canonical body on write and read. A text projection must equal its canonical text; structural toolCalls must equal the canonical call parts; name and toolCallId must match the appropriate single part; finishReason is a fixed stop-reason value on an assistant row. Unknown fields and malformed JSON are refused with fixed boundary errors. This prevents metadata from becoming a second tool-content channel. User conversational text, including ! output and @ file injection, remains user data at rest.

Usage coverage through session lifetime

A shipping persisted host attaches a required usage writer independently of its optional cost governor. Fresh creation alone establishes a complete-zero basis; resume/reseat explicitly adopt the existing owner and preserve pending, unknown and historical uncertainty. Pending acknowledgement precedes conversational, tool-round and summariser invocation. Complete actual money and coverage resolve atomically through one direct writer, rather than a second bus subscriber. The CLI waits for retained producers and accounting writes before closing that writer or SQLite. One-shot agent runs use an opaque hidden owner without a transcript or resume privilege.

Public cost completion follows that direct writer's acknowledgement. The persisted host refreshes the amount, missing-price count and coverage together at accounting and lifecycle boundaries, including the next turn after resume/reseat; construction-time counters cannot overwrite a newer durable sum. Content frames reuse the cached projection. The event contract owns the acknowledgement, projection-failure and uncertainty rules.