session

package
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package session is the Agent Session bounded context: the run lifecycle, conversation, turns, the stop conditions, and the domain-owned event taxonomy.

It holds the Session aggregate root plus the shared value objects (ToolCall, ToolResult, Usage, Message) that Governance and Tooling exchange, and the single Event taxonomy shared by the loop and the API.

Allowed imports (ARCHITECTURE.md §3): the standard library only (and other domain packages). This package MUST NOT import adapter, agent, api, os, the OpenAI SDK, or any third-party library.

Package session — EnvironmentRef.

EnvironmentRef is the sole durable identity of a session's execution environment. It names a provider family, an opaque provider-owned ID, and the exact provider revision required for reattachment. Live workspaces and command runners remain runtime capabilities in tool.Environment and are never persisted here.

Index

Constants

View Source
const (
	// MaxMediaBytes is the cap on a single Content part's inline Data. 10 MiB
	// comfortably holds a high-resolution screenshot or photo while bounding the
	// cost of statelessly re-sending it every turn and persisting it on disk.
	MaxMediaBytes = 10 << 20 // 10 MiB
	// MaxPromptMediaBytes is the cap on the SUM of all inline part bytes in one
	// prompt, so many parts cannot collectively blow memory even when each is
	// under MaxMediaBytes.
	MaxPromptMediaBytes = 20 << 20 // 20 MiB
	// MaxPromptMediaParts is the cap on the number of parts in one prompt, so a
	// flood of tiny parts cannot exhaust memory either.
	MaxPromptMediaParts = 16
)

Media size caps. They bound the memory/persistence/per-turn-resend cost of a multimodal prompt (CWE-770). They apply ONLY to inline Data bytes — a part sourced from a URL is fetched by the remote provider, not held here, so its payload is not counted (its URL is validated by ValidateMediaURL instead).

View Source
const (
	// VerdictStringDeny is the EvApproval label for a denied call.
	VerdictStringDeny = "deny"
	// VerdictStringAllowOnce is the EvApproval label for an allow-once verdict.
	VerdictStringAllowOnce = "allow_once"
	// VerdictStringAllowAlways is the EvApproval label for an allow-always verdict.
	VerdictStringAllowAlways = "allow_always"
)

The EvApproval verdict-string passthrough labels. They are the ONE source of truth for the wire-neutral verdict strings (the EvNoProgress/StopBudget precedent: a string passthrough, no proto enum), shared by VerdictString, the emit sites, and any 3b consumer that filters on the verdict — so nobody re-spells the literals.

View Source
const (
	RequestToolSourceCatalog = "catalog"
	RequestToolSourceOverlay = "overlay"
	RequestToolSourceMCP     = "mcp"

	RequestToolAdvertised        = "advertised"
	RequestToolDisclosureHidden  = "disclosure_hidden"
	RequestToolModeFiltered      = "mode_filtered"
	RequestToolAuthorityFiltered = "authority_filtered"
	RequestToolMountUnavailable  = "mount_unavailable"
	RequestToolShadowed          = "shadowed"
)

Request tool source and decision tokens form closed vocabularies.

View Source
const (
	RequestPromptSystem        = "system"
	RequestPromptInstruction   = "instruction"
	RequestProvenanceStable    = "stable"
	RequestProvenanceVolatile  = "volatile"
	RequestProvenanceProject   = "project"
	RequestProvenanceSoul      = "soul"
	RequestProvenanceMemory    = "memory"
	RequestProvenanceRules     = "rules"
	RequestProvenanceUserModel = "user_model"
	RequestProvenanceCustom    = "custom"
	RequestProvenanceUnknown   = "unknown"
)

Request prompt kind and provenance tokens form a closed vocabulary.

View Source
const (
	// RoutingReasonPinnedModel: a per-call `model:` arg pinned the child's engine, so
	// the router never fired (the explicit choice wins by design).
	RoutingReasonPinnedModel = "pinned-model"
	// RoutingReasonAgentDefPinned: a named `agent` (Subagent) or DEFINED team member
	// whose agent def pinned its own model, so the router never fired.
	RoutingReasonAgentDefPinned = "agent-def-pinned-model"
	// RoutingReasonResume: a `resume:` call continues a persisted child on its own
	// engine — the router never fires for a resume.
	RoutingReasonResume = "resume"
	// RoutingReasonFork: a `fork: true` call inherits the parent engine — the router
	// never fires for a fork.
	RoutingReasonFork = "fork"
	// RoutingReasonRouterDisabled: no router is wired (the parentCaps routeTask closure
	// is nil — router absent), or the delegation cannot consume a routed pick (for
	// example an ineligible named agent or an unwired writable/agent model factory).
	RoutingReasonRouterDisabled = "router-disabled"
	// RoutingReasonTargetUnavailable: the router selected a concrete model, but the
	// engine factory could not build that target and the delegation therefore fell
	// back to its inherited/default engine. The router is fail-soft, but the event
	// must not claim the rejected target was actually used.
	RoutingReasonTargetUnavailable = "route-target-unavailable"
	// RoutingReasonBreakerOpen: the per-run router circuit breaker was OPEN (too many
	// consecutive misses), so the classifier was skipped and the delegation inherited
	// the default model.
	RoutingReasonBreakerOpen = "breaker-open"
	// RoutingReasonAborted: the run was already tearing down (the parent run's
	// hardAbort fired), so the classifier was skipped; also a Parallel branch cancelled
	// before it ever started.
	RoutingReasonAborted = "aborted"
)

Routing-reason gate constants (issue #397): the CLOSED set of harness-authored reasons a delegation-start event carries on RoutingReason when the OPT-IN model router did NOT classify it. Empty ("") is the routed-hit sentinel; every non-empty value is a miss/gate. The CLASSIFIER-side miss reasons (the RouterMiss* values in engine/agent) and composition's category-mapping reason codes (e.g. "category-selector-empty") are NOT duplicated here — they flow through the same internal string channel, then the event projection confines them to static codes.

All reasons are metadata ONLY — never the task prompt or the classifier's output (gauntlet #7).

View Source
const (
	CompactionSummaryMarker = "[conversation compacted]"
	Tier4SummaryMarker      = "[earlier turns summarised]"
)

CompactionSummaryMarker prefixes the synthesised paths-summary message BOTH compactors emit (a RoleUser message). Tier4SummaryMarker prefixes the cascade's tier-4 LLM summary message (also a RoleUser message). BOTH are harness-authored context, not real user instructions, so a genuine-user-prompt test (and the compaction user-turn back-snap) SKIPS them: a re-compaction must not treat a prior compaction's summary as a user turn. These were promoted from the unexported engine/agent constants (compactionSummaryMarker / tier4SummaryMarker) so the domain leaf owns the genuine-vs-synthesised distinction it needs; the agent package keeps the emit sites and references these exported names. Keep them byte-for-byte in sync with the literals buildSummary (engine/agent/ compaction.go) and CascadeCompactor.summarize (engine/agent/cascade.go) emit.

View Source
const MaxToolResultBytes = 20 << 20 // 20 MiB

MaxToolResultBytes is the cap on the SUM of all block bytes in one tool result, mirroring MaxPromptMediaBytes so a tool result cannot collectively blow memory even when each block is under its per-block cap. It counts inline text + blob bytes (URL-sourced resource links contribute none — the provider fetches those).

View Source
const MaxToolResultTextBytes = 25 << 10 // 25 KiB (25,600 bytes)

MaxToolResultTextBytes caps the byte length of a single TEXT tool-result block. It is the domain-level upper bound on ANY tool's text result — not only the filesystem tools, but MCP results, memory tools, and any consumer-supplied tool — at 25 KiB (25,600 bytes). The adapter-layer output caps (engine/adapter/fstools.MaxOutputBytes and internal/adapter/toolkit.MaxOutputBytes, both 25,000 bytes) are STRICTER and sit under this bound, so a truncated tool result always validates here; the ~600-byte headroom is deliberate, not a drift to reconcile. Defined locally (not imported) because engine/session is the domain leaf: it may not import an adapter (even fstools, which lives in this module), and toolkit lives under the host repo's internal/adapter tree.

Variables

View Source
var (
	// ErrInvalidContent is returned when a Content part violates a structural
	// invariant (no kind, no mime, both/neither of Data/URL, mime/kind mismatch).
	ErrInvalidContent = errors.New("session: invalid media content")
	// ErrInvalidMediaURL is returned by ValidateMediaURL for a URL that is not an
	// absolute https URL to a non-internal host.
	ErrInvalidMediaURL = errors.New("session: invalid media URL")
)

Errors returned by the Content constructors / validators.

View Source
var (
	// ErrIllegalTransition is returned when a method is called in a state that
	// does not permit it.
	ErrIllegalTransition = errors.New("session: illegal state transition")
	// ErrNoPendingAsk is returned by ResumeWith when the session is not awaiting
	// approval.
	ErrNoPendingAsk = errors.New("session: no pending ask to resume")
)

Errors returned by the Session state machine.

View Source
var ErrInvalidSessionMetadata = errors.New("session: invalid kind or relationship")

ErrInvalidSessionMetadata marks an invalid kind/relationship combination.

View Source
var (
	// ErrOwnerAlreadySet is returned by RestoreLabels when the session already
	// carries a DIFFERENT owner. The owner is write-once (ADR 0204 decision 4).
	ErrOwnerAlreadySet = errors.New("session: owner already set")
)

Functions

func ClampTitle

func ClampTitle(s string) string

ClampTitle trims surrounding whitespace and, if the rune count exceeds maxTitleRunes, truncates to maxTitleRunes and appends a single "…" ellipsis. A short or empty input is returned trimmed (empty stays ""). It is the ONE clamp source of truth — SetTitle and the server-layer lazy fallback both call it, so a title is always clamped identically regardless of which path seeded it. The truncation is rune-safe (counts runes, not bytes).

func DebugTargetFingerprint

func DebugTargetFingerprint(s *Session) string

DebugTargetFingerprint returns the immutable identity of a target incarnation.

func IncarnationFingerprint

func IncarnationFingerprint(inc IncarnationID, id SessionID, owner *Principal) string

IncarnationFingerprint returns a domain-separated internal binding for an incarnation in its session-key and owner scope. It contains no timestamp.

func IsGenuineUserPrompt

func IsGenuineUserPrompt(m Message) bool

IsGenuineUserPrompt reports whether m is a GENUINE user instruction — the thing the title fallback and the event-sourced Fold anchor a session label on — as opposed to a harness-authored RoleUser synthesised compaction summary. It is m.Role == RoleUser && !IsSynthesisedSummary(m.Text).

It deliberately does NOT check prompt.IsInjectedTurn0Fragment: the domain leaf cannot import engine/prompt, and as of ADR 0043 the turn-0 fragments are EPHEMERAL (prepended to the request per-run, never persisted into Conversation.Messages), so they do not appear in the persisted history the two read-time consumers (the lazy fallback + Fold) walk. The loop's OWN isGenuineUserTurn (engine/agent/compaction.go) keeps that arm as defense-in-depth for legacy history; this domain predicate is for the persisted read path and is correct without it.

func IsSynthesisedSummary

func IsSynthesisedSummary(text string) bool

IsSynthesisedSummary reports whether a message's text is a harness-synthesised compaction summary (the paths-summary OR the tier-4 LLM summary) rather than a genuine user instruction. The back-snap and the title fallback use it to avoid anchoring on a prior compaction's own output (the re-compaction footgun). It is the domain-leaf export of the predicate that lived unexported in engine/agent (isSynthesisedSummary) — promoted so the server/Fold consumers can reach it.

func NetworkCorrelationDigest

func NetworkCorrelationDigest(kind, value string) (string, bool)

NetworkCorrelationDigest reduces an arbitrary provider correlation value to a fixed, one-way token. Unsupported kinds, empty values, and oversized input are omitted rather than retained.

func ParseArgs

func ParseArgs(call ToolCall, dst any) (msg string, ok bool)

ParseArgs unmarshals a ToolCall's JSON arguments into dst. It is the single canonical implementation of the "decode a tool call's args, fail with a model-facing string" mechanic that the agent loop and the adapter-layer tools both repeat. It lives on the domain ToolCall (which both layers already import) so neither has to depend on the other.

An empty payload (no args) is treated as an empty object: dst is left at its zero value and ok=true, so a tool with all-optional arguments works without the model having to send an explicit "{}". On malformed JSON it returns a model-facing error string (not a Go error) and ok=false; on success it returns "" and true. The returned msg is intended to be fed straight back to the model via NewToolError, so callers may prefix it with the tool name.

func PrincipalScopeHash

func PrincipalScopeHash(p *Principal) [32]byte

PrincipalScopeHash returns the SHA-256 digest of p's (Issuer, Subject) identity pair, joined by a NUL separator. NUL is reserved from both components, making the legacy framing unambiguous for every admissible principal while preserving its byte-for-byte storage contract. The reservation is enforced HERE rather than only at the construction seams (PrincipalFromClaims, WithPrincipal, RestoreLabels), because a Principal built directly from a struct literal — notably internal/adapter/grpcdriver's wire-supplied owner, which ADR-0213/#452 still leaves unverified — reaches this function without passing any of them. An identity whose framing is unsafe returns invalidPrincipalScope, so it cannot alias a valid owner's scope.

The raw hash core has several owner-scope-keying call sites that build on top of it with their own nil-handling, prefix, and truncation conventions (which are load-bearing for their on-disk/wire formats and must NOT be changed here). A nil p hashes the empty string; callers that need a distinct nil representation apply that before calling this.

func ToValidUTF8

func ToValidUTF8(s string) string

ToValidUTF8 returns s with every invalid UTF-8 byte sequence replaced by U+FFFD, so the durable event log, the model view, and the gRPC wire all carry the same text. An already-valid string is returned unchanged — which is what makes the three agree: once repaired here, encoding/json has nothing left to substitute and passes the value through untouched.

It is NOT byte-identical to what encoding/json would have produced from the RAW string: json substitutes one U+FFFD per invalid BYTE, this one per invalid RUN, so "\xe2\x80" becomes two replacement runes there and one here. That only matters for a value repaired on one path and not the other — which is exactly the divergence the single choke point below exists to prevent.

It exists because protobuf string fields REJECT invalid UTF-8 at marshal time (the Converse-stream kill, issue #402): any string that crosses into a proto message must be valid UTF-8 first. This is the single shared repair primitive consumed by BOTH the loop's effective-payload choke point (engine/agent execute, via RepairToolResult) and the protobuf-projection backstop (internal/adapter/server mapper). engine/session is the domain leaf (stdlib-only), so both layers may import it without violating the inward-only dependency rule.

func ToolBlockText

func ToolBlockText(b Content) string

ToolBlockText renders a non-image tool-result block as its model-facing text form. BlockText / BlockStructuredContent carry their text in Text; a resource link renders its URI + name/title; an embedded-resource blob (no Text) renders a pointer (URI + mime) rather than a base64 dump. It is the single shared projection consumed by both the OpenAI and Anthropic adapters so their tool-result rendering cannot drift.

func ValidateJSON

func ValidateJSON(schema, payload json.RawMessage) error

ValidateJSON validates a JSON payload against a DELIBERATE SUBSET of JSON Schema. It is the SINGLE validation choke point for model-authored structured output (the Subagent tool's output_schema): the child calls a synthetic SubmitResult tool whose parameters ARE the schema, and the Subagent tool validates the submitted payload here before accepting it as the delegation's deliverable.

It is a DOMAIN helper (it validates the shape of arbitrary model output, a domain concern) and is DISTINCT from ValidateMediaParts (which validates prompt MEDIA, a different concern with a different shape) — the two never share a code path, so this is not a second media-validation path.

SUPPORTED keywords (the subset):

  • "type": one of object | array | string | number | integer | boolean | null (also accepts a []string union, satisfied if ANY listed type matches).
  • "properties" (object): per-key sub-schemas, validated recursively.
  • "required" (object): listed keys must be present.
  • "items" (array): a single sub-schema applied to every element.
  • "enum": the value must deep-equal one of the listed JSON values.

FAIL-OPEN policy (the scope-creep guard): any keyword NOT in the subset is IGNORED, never an error — an unsupported construct degrades to "any JSON here", it never hard-fails. A schema that is not a JSON object at all (e.g. `true`) also validates everything. This keeps the validator a small, predictable subset and resists the documented scope-creep-into-a-full-validator risk; the cost is that an exotic schema is under-enforced, which is acceptable for a coarse deliverable gate.

It returns a single, model-readable error describing the FIRST mismatch (path + expectation), or nil when the payload satisfies the subset.

func ValidateMediaParts

func ValidateMediaParts(parts []Content) error

ValidateMediaParts enforces the per-prompt media caps (CWE-770) on an already constructed slice of parts: at most MaxPromptMediaParts parts, each inline part at most MaxMediaBytes, and the SUM of inline bytes at most MaxPromptMediaBytes. URL-sourced parts contribute no bytes (the provider fetches those). It is called at the wire→domain choke point after the parts are built via NewContent. A nil/empty slice passes.

func ValidateMediaURL

func ValidateMediaURL(raw string) error

ValidateMediaURL is the SSRF backstop (CWE-918) for a CLIENT-SUPPLIED media URL. Because the URL is handed to a REMOTE vision/audio provider that DEREFERENCES it — possibly a self-hosted/proxying provider behind the provider-agnostic port — an unvalidated URL could reach the cloud metadata endpoint or an internal host. It is STRICTER than mcp.ValidateClientURL: there is NO plaintext-http-to-loopback allowance, because the consumer is a remote service, not a local trusted process.

The contract: the URL must be an absolute "https" URL with a host. A single trailing root dot is normalized away first. A literal IP is rejected unless it is global-unicast (NOT loopback, private/RFC1918, CGNAT 100.64.0.0/10, link-local incl. the 169.254.169.254 metadata IP, unspecified, or multicast). A non-canonical inet_aton-style numeric host ("0x7f.0.0.1", "0177.0.0.1", "127.1", "2130706433") is rejected — a resolver would decode it to a real (often internal) IP that net.ParseIP never canonicalised. A DNS hostname that obviously names an internal target ("localhost", a bare single-label name, or a ".local"/".internal"/".localhost" suffix) is rejected too.

Residual risk (documented honestly): a PUBLIC https URL can still point at any public address, and the provider will fetch it. This validator blocks the internal/metadata SSRF shapes; a self-hosted provider that dereferences media URLs should ALSO apply network egress controls (deny RFC1918/link-local at the provider's own egress) for defense in depth. Inline base64 data is the primary, unaffected path and is preferred when the bytes are available.

func ValidateResolvedIP

func ValidateResolvedIP(ip net.IP) error

ValidateResolvedIP is the dial-layer SSRF backstop (CWE-918) for a fetch the HARNESS itself performs (not a remote provider). ValidateMediaURL screens the hostname string but deliberately does NOT resolve DNS (a TOCTOU resolve-then- fetch would be racy against a remote provider's egress). When the harness itself dials, however, that DNS-rebinding window is live: an attacker-controlled resolver can answer ValidateMediaURL's hostname check with a public IP, then return 169.254.169.254 (or RFC1918) when the dialer connects. A fetch tool that dials directly MUST install a custom DialContext that resolves the hostname and calls this on each resolved IP, rejecting any that is not a routable public address (the same isGlobalUnicast predicate ValidateMediaURL uses for literal IPs). It returns nil for a permitted IP and a non-nil error naming the rejection otherwise. Re-exported as the single dial-layer IP predicate so the fetch path and the URL-string path share ONE screening definition.

func ValidateSessionMetadata

func ValidateSessionMetadata(kind SessionKind, rel SessionRelationship) error

ValidateSessionMetadata validates the closed SessionKind relationship schema.

func ValidateToolPairing

func ValidateToolPairing(msgs []Message) error

ValidateToolPairing reports whether the message history is well-paired for provider replay: every tool-result message (RoleTool) must answer a preceding assistant ToolCall, and every assistant ToolCall must be answered by a following tool-result. It is BIDIRECTIONAL because providers reject BOTH shapes — an orphaned tool result (no matching tool_use/function_call above it) AND a dangling tool call (no result below it) draw an HTTP 400. It is pure (no I/O, no new deps): used by compaction to refuse emitting a history that would brick the session, and by ReplaceHistory as the aggregate-level guard.

An empty or nil slice is trivially valid.

func ValidateToolResultParts

func ValidateToolResultParts(parts []Content) error

ValidateToolResultParts enforces the per-result byte caps (CWE-770) on an already-constructed slice of tool-result blocks: each text block at most MaxToolResultTextBytes, each blob block at most MaxMediaBytes, and the SUM of inline text+blob bytes at most MaxToolResultBytes. URL-sourced resource links contribute no bytes. It is called at the wire→domain choke point after the blocks are built via the constructors. A nil/empty slice passes. It is DISTINCT from ValidateMediaParts (which caps user-message media) — the two paths are read separately by providers and must not be collapsed.

func VerdictString

func VerdictString(v ApprovalVerdict) string

VerdictString maps an ApprovalVerdict to its EvApproval string-passthrough label (VerdictStringDeny / VerdictStringAllowOnce / VerdictStringAllowAlways). It is the SINGLE place the domain enum is projected onto the wire-neutral verdict string, so the EvApproval emit sites cannot drift.

func WithPrincipal

func WithPrincipal(ctx context.Context, p *Principal) context.Context

WithPrincipal returns a context carrying the verified caller p.

A nil p returns ctx UNCHANGED. A principal with unsafe owner-key framing is stored only as an invalid shadow, so it cannot leave an outer caller effective and PrincipalFromContext reports it as absent. Neither case can produce a present-but-empty fabricated principal, and a NUL in either authority-bearing component never reaches legacy owner-key framing. Absent identity is a nil *Principal, never a fabricated one (ADR 0204 decision 2).

The principal is stored as a COPY, so a later mutation through the caller's pointer cannot change what the context reports.

Types

type ApprovalPayload

type ApprovalPayload struct {
	// AskID is the id of the resolved permission ask (the same id carried on the
	// EvPermissionAsk that preceded this verdict and on the wire ResumeApproval).
	AskID string
	// Verdict is the resolution as a STRING passthrough: VerdictStringAllowOnce,
	// VerdictStringAllowAlways, or VerdictStringDeny (the EvNoProgress/StopBudget
	// precedent — no proto enum). It is the human/policy decision, never tool
	// content.
	Verdict string
	// Tool is the NAME of the tool the ask gated (e.g. "Bash"). It is the tool
	// name ALONE — never the call's args.
	Tool string
	// Call is the id of the gated ToolCall. It is an OPAQUE identifier, NOT secret
	// content — it is already implicitly encoded inside AskID (see agent.newAskID) —
	// so surfacing it directly opens no new leak surface. It is the durable,
	// grammar-free correlation handle a 3b permstore-replay consumer uses to find the
	// gated ToolCall in the loaded conversation and re-derive its rule from the real
	// args (which stay in the session history, never on this event).
	Call ToolCallID
	// AllowAlways mirrors (Verdict == VerdictStringAllowAlways): the verdict ASKED
	// the harness to learn a per-session allow rule. It is deliberately NOT named
	// "Learned": Policy.Learn no-ops on an unlearnable call (compound/substituted
	// Bash with no targetable pattern), so an allow-always verdict can set this
	// true even when NO rule was actually recorded. It honestly reflects the
	// VERDICT, not the policy outcome. A 3b permstore-replay consumer filtering on
	// this must re-derive the real rule from the conversation (the metadata-only
	// event never carries enough to reconstruct a pattern), so AllowAlways is a
	// filter hint, never a durable rule record.
	AllowAlways bool
}

ApprovalPayload is the structured detail carried by an EvApproval Event: the resolution of a permission ask. It is the verdict half of the chronological approval record (the EvPermissionAsk it follows is the request half).

NO-LEAK CONTRACT (gauntlet #7): it carries the tool NAME, the verdict string, the askID, the gated tool-call id, and the allow-always flag — and NOTHING ELSE. It NEVER carries the raw tool args (those can quote secrets) nor the deny-reason body (which can quote a sensitive command preview). A consumer that needs to correlate a verdict back to a tool call uses Call (the opaque tool-call id, also implicitly inside AskID) against the conversation history, never an arg payload on this event.

type ApprovalVerdict

type ApprovalVerdict int

ApprovalVerdict is the client's resolution of a permission.ask. It widens the historical allow/deny boolean into three outcomes so a client can ask the harness to LEARN an allow for the matching tool+pattern (allow_always) versus permitting only the current call (allow_once).

VerdictDeny is the ZERO VALUE deliberately: a verdict that is never set, or a resolution path that abandons the ask (ctx cancel, transport error), fails SAFE to deny. A learned allow (VerdictAllowAlways) NEVER overrides a deny and NEVER bypasses plan-mode mutation denial — it only adds a narrow, session-scoped allow rule the Evaluator consults at the lowest precedence.

const (
	// VerdictDeny denies the call. It is the zero value (fail-safe default).
	VerdictDeny ApprovalVerdict = iota
	// VerdictAllowOnce allows the current call only; nothing is learned.
	VerdictAllowOnce
	// VerdictAllowAlways allows the current call AND asks the harness to learn a
	// per-session allow rule for the same tool + exact canonical pattern.
	VerdictAllowAlways
)

type AskOrigin

type AskOrigin int

AskOrigin is the read-time provenance of a PendingAsk, derived from the serialized provenance bools. It is a convenience accessor, NOT a stored field: the two serialized bools (HookOriginated, PlanOriginated) remain the on-disk contract, and Origin is how a reader obtains the single provenance without poking both bools. Hook takes precedence if both were (incorrectly) set; construction is via the loop's ask sites only (a PendingAsk is never built with two origins at once).

const (
	// AskOriginNone is the zero value: the ask came from the permission policy
	// (neither a hook nor a plan gate). It is the common case.
	AskOriginNone AskOrigin = iota
	// AskOriginHook marks an ask refined from a PreToolUse hook BLOCK (ADR 0062).
	AskOriginHook
	// AskOriginPlan marks an ask presented by the plan-approval gate (issue #206).
	AskOriginPlan
)

type Authority

type Authority struct {
	CapabilitySet      governance.CapabilitySet `json:"capability_set"`
	Provenance         string                   `json:"provenance"`
	DefinitionIdentity string                   `json:"definition_identity,omitempty"`
}

Authority is the durable, plain authority payload carried by a bound session. CapabilitySet is the one governance-domain representation; provenance and definition identity are safe labels, not caller claims or runtime handles.

func (Authority) Clone

func (a Authority) Clone() Authority

Clone returns an independent copy of a.

func (Authority) Valid

func (a Authority) Valid() bool

Valid reports whether a has safe provenance labels. CapabilitySet is already typed, so malformed serialized capability data fails during snapshot decoding before this method can bind the aggregate.

type BlockKind

type BlockKind string

BlockKind discriminates a Content block variant. The zero value ("") designates a LEGACY media part on Message.Parts (image/audio via Kind), so the existing ACP / provider-request media path stays byte-identical. Tool-result blocks live on ToolResult.Parts and always set BlockKind.

const (
	// BlockText is a plain-text tool-result block (e.g. an MCP text content).
	BlockText BlockKind = "text"
	// BlockImage reuses the existing media-image fields (Kind=MediaImage,
	// MIMEType, Data/URL) for an image tool-result block.
	BlockImage BlockKind = "image"
	// BlockAudio reuses the existing media-audio fields (Kind=MediaAudio,
	// MIMEType, Data/URL) for an audio tool-result block.
	BlockAudio BlockKind = "audio"
	// BlockResourceLink is a REFERENCE to a resource (URI + metadata); it is
	// NOT fetched here, so its URL is NOT validated by ValidateMediaURL.
	BlockResourceLink BlockKind = "resource_link"
	// BlockEmbeddedResource carries a resource inline as either a text form (Text)
	// or a binary blob (Data), exactly one of which is populated.
	BlockEmbeddedResource BlockKind = "embedded_resource"
	// BlockStructuredContent carries a JSON-stringified structured payload as a
	// text block — the backward-compat mirror of the legacy TextContent path.
	BlockStructuredContent BlockKind = "structured"
)

type CompactionArchivePayload

type CompactionArchivePayload struct {
	// Replaced is the pre-compaction conversation — the exact Messages slice that
	// ReplaceHistory replaced, captured before the mutation. It is the full
	// pre-compaction history (a superset of the dropped span), so the dropped turns
	// are always recoverable from it regardless of how the Compactor split the cut.
	// See the LOG-GROWTH COST note above for why the full slice (not a delta) is kept.
	Replaced []Message
}

CompactionArchivePayload is the structured detail carried by an EvCompactionArchive Event: the pre-compaction conversation that ReplaceHistory replaced. It is the durable, non-destructive archive of the history a compaction would otherwise drop — a later consumer (the Phase 3 reconstruct gate, issue #28 session-scoped detach) replays the EventLog and recovers the pre-compaction turns that the session snapshot no longer holds.

CAPTURE ORDERING (load-bearing): the loop captures the original Messages slice BEFORE ReplaceHistory mutates the conversation, and emits this event only AFTER a SUCCESSFUL ReplaceHistory. Messages are immutable per-element, so holding the slice reference across the replace is safe — Replaced is the genuine pre-compaction history, not the post-compaction tail.

NO-LEAK CONTRACT (gauntlet #7): Replaced is the PARENT'S OWN conversation; no child content ever enters it (only a child's summarised ToolResult does), so archiving it verbatim opens no leak surface. See EvCompactionArchive.

LOG-GROWTH COST (a reasoned decision, not an accident): Replaced is the FULL pre-compaction conversation, NOT just the span the compaction dropped. Across a long session with N compactions this RE-LOGS the retained tail each time, so the event log grows super-linearly in the retained history. We accept that ON PURPOSE: the full slice is robust — it never depends on guessing how the Compactor split the kept tail from the dropped head (the Compactor owns that cut and is a swappable seam), so the archive is correct for ANY Compactor, including a future LLM-backed one whose "drop" is not a clean prefix. The DEFERRED optimization, if log size ever bites, is a delta archive (only the messages absent from the compacted result) computed by diffing pre/post histories in maybeCompact — a strictly additive change to what this field carries, behind the same event. Until a real deployment shows the growth matters, robustness wins over a premature delta.

type Content

type Content struct {
	// BlockKind discriminates the block variant; "" = legacy media part. Set
	// for tool-result blocks (BlockText/BlockImage/BlockAudio/BlockResourceLink/
	// BlockEmbeddedResource/BlockStructuredContent).
	BlockKind BlockKind `json:"block_kind,omitempty"`
	// Kind discriminates the media kind (image / audio) for legacy media parts
	// and the BlockImage/BlockAudio block variants.
	Kind MediaKind
	// MIMEType is the IANA media type of the part (image, audio, resource).
	MIMEType string
	// Data is the inline content bytes; nil when the part is URL-sourced. For
	// BlockEmbeddedResource it is the binary blob form (exactly one of Text/Data
	// populated); the provider summarizes it, this does NOT base64-dump.
	Data []byte
	// URL is the remote reference; "" when the part is inline. For
	// BlockResourceLink it is the resource URI (a reference, NOT fetched here).
	URL string
	// Text carries the text form for BlockText and BlockStructuredContent, and
	// the text form of a BlockEmbeddedResource (exactly one of Text/Data set).
	Text string `json:"Text,omitempty"`
	// Name is the resource name for a BlockResourceLink.
	Name string `json:"Name,omitempty"`
	// Title is the resource title for a BlockResourceLink.
	Title string `json:"Title,omitempty"`
	// Description is the resource description for a BlockResourceLink.
	Description string `json:"Description,omitempty"`
	// Size is the resource byte size for a BlockResourceLink (advisory).
	Size int64 `json:"Size,omitempty"`
	// Audience is the advisory intended-audience list for a resource block.
	//
	// SECURITY: this is UNTRUSTED SERVER SELF-ATTESTATION (CWE-345). An MCP
	// server (or any tool-result producer) asserts who may view a resource; the
	// harness MUST NOT treat this as authoritative. It is carried through for
	// operator/model visibility ONLY — it NEVER suppresses model-visible
	// content and NEVER gates access control. Enforcement lives in the
	// permission layer, not here.
	Audience []string `json:"Audience,omitempty"`
	// Priority is carried through for a resource block; not consumed in v1.
	Priority float64 `json:"Priority,omitempty"`
	// LastModified is carried through for a resource block; not consumed in v1.
	LastModified string `json:"LastModified,omitempty"`
}

Content is an immutable value object. It serves TWO roles, distinguished by BlockKind:

  • LEGACY media part (BlockKind == ""): a non-text part of a USER Message (Message.Parts). The source is EITHER inline bytes (Data, already base64-decoded) OR a remote reference (URL) — never both. MIMEType is the IANA media type (e.g. "image/png", "audio/wav"). Construct via the validating constructors (NewImageContent / NewImageURLContent / NewAudioContent / NewAudioURLContent, or NewContent for the dynamic case) so the exactly-one-of(Data,URL), kind-set, and mime-consistency invariants hold structurally rather than by prose. This path stays MEDIA-ONLY: a tool-result block must never ride on Message.Parts — it lives on ToolResult.Parts (decision #1 / Risk #4 mitigation (a)).

  • TOOL-RESULT block (BlockKind != ""): a typed block variant on a ToolResult.Parts. Construct via NewTextBlock / NewResourceLinkBlock / NewEmbeddedResourceBlock / NewStructuredContentBlock. Image/audio blocks reuse the existing Kind/MIMEType/Data/URL fields (no new fields).

It carries no mutating methods; treat it as immutable. Data []byte is technically mutable; by convention callers MUST NOT mutate Data after construction — the same treatment ToolCall.Args (json.RawMessage) already receives.

func NewAudioContent

func NewAudioContent(mime string, data []byte) (Content, error)

NewAudioContent builds a validated inline-audio part.

func NewAudioURLContent

func NewAudioURLContent(mime, rawURL string) (Content, error)

NewAudioURLContent builds a validated URL-sourced audio part.

func NewContent

func NewContent(kind MediaKind, mime string, data []byte, rawURL string) (Content, error)

NewContent builds and validates a media Content part. EXACTLY ONE of data/url must be set; kind must be MediaImage or MediaAudio; mime must be non-empty and consistent with kind (image/* for MediaImage, audio/* for MediaAudio). A URL source is additionally validated by ValidateMediaURL (absolute https, no internal host) — the SSRF backstop, since a remote provider DEREFERENCES it. It is the blessed wire→domain construction path the mappers (and ACP) use; the raw struct stays usable for tests that intentionally bypass validation.

func NewEmbeddedResourceBlock

func NewEmbeddedResourceBlock(uri, mimeType, text string, blob []byte, audience []string) (Content, error)

NewEmbeddedResourceBlock builds an embedded-resource block carrying a resource inline. Exactly one of text (text form) or blob (binary form) must be populated; the blob path does NOT base64-dump — the provider summarizes it. uri/mimeType identify the resource; audience is untrusted self-attestation.

func NewImageContent

func NewImageContent(mime string, data []byte) (Content, error)

NewImageContent builds a validated inline-image part.

func NewImageURLContent

func NewImageURLContent(mime, rawURL string) (Content, error)

NewImageURLContent builds a validated URL-sourced image part.

func NewResourceLinkBlock

func NewResourceLinkBlock(uri, name, title, description, mimeType string, size int64, audience []string) Content

NewResourceLinkBlock builds a resource-link block: a REFERENCE to a resource (uri + metadata). It is NOT fetched here, so uri is NOT validated by ValidateMediaURL — the producer is trusted to hand a dereferenceable URI and any fetch is the provider's responsibility. mimeType/size are advisory metadata. audience is untrusted self-attestation (see the Audience field doc).

func NewStructuredContentBlock

func NewStructuredContentBlock(structuredJSON string) Content

NewStructuredContentBlock carries a JSON-stringified structured payload as a text block — the backward-compat mirror of the legacy TextContent path. The caller is responsible for JSON-stringifying structuredJSON; this does not re-validate it.

func NewTextBlock

func NewTextBlock(text string) Content

NewTextBlock builds a plain-text tool-result block.

type Conversation

type Conversation struct {
	// Messages is the ordered history sent to the model.
	Messages []Message
}

Conversation is the model-visible message history of a session. It is an entity owned by the Session aggregate; mutate it only through Session methods.

func (*Conversation) Append

func (c *Conversation) Append(m Message)

Append adds a message to the conversation history.

func (*Conversation) Len

func (c *Conversation) Len() int

Len reports the number of messages in the conversation.

type Counters

type Counters struct {
	// Turns is the number of model calls begun.
	Turns int
	// ToolCalls is the total number of tool invocations recorded.
	ToolCalls int
	// ConsecutiveFailures is the current run of back-to-back tool failures; it
	// resets to zero on any successful tool result.
	ConsecutiveFailures int
}

Counters track running totals used to evaluate stop conditions.

type EnvironmentKind

type EnvironmentKind string

EnvironmentKind names the backend family an EnvironmentRef was minted against. It is an open STRING label carried verbatim; the session package owns the well-known constants for the in-tree backends (below) but does not constrain the set — a future remote transport or out-of-tree backend adds its own label without widening this package. The empty string is the zero value and is treated as "unspecified".

const (
	// EnvKindLocal is a real-OS workspace backed by osfs + a local command
	// runner (the main session, a worktree or force-copy fork child).
	EnvKindLocal EnvironmentKind = "local"
	// EnvKindMem is an in-memory workspace (memfs) with no command runner.
	EnvKindMem EnvironmentKind = "mem"
	// EnvKindNoFS is the file-less profile (engine/adapter/nofs).
	EnvKindNoFS EnvironmentKind = "nofs"
)

Well-known EnvironmentKind labels. These are the labels the in-tree adapters mint; composition is the sole constructor site, so a future remote transport adds its own label without widening this package. The set is open: the session package defines the in-tree constants but does not enumerate every possible label.

type EnvironmentRef

type EnvironmentRef struct {
	// Kind names the backend family (local / mem / nofs / …).
	Kind EnvironmentKind
	// ID is the opaque backend identity the adapter that minted the
	// Environment owns. It is never parsed by the session package.
	ID string
	// Revision pins the exact provider inventory generation.
	Revision string
}

EnvironmentRef is the cycle-safe identity value an Environment carries. It is the complete durable placement identity: all three fields are required and compared exactly during reattachment. The session package stores but never interprets the values.

func (EnvironmentRef) Valid added in v0.13.0

func (r EnvironmentRef) Valid() bool

Valid reports whether every component required for exact reattachment is present. The session domain deliberately does not interpret any component.

type Event

type Event struct {
	// Type is the event kind.
	Type EventType
	// Seq is a monotonically increasing sequence number within a run.
	Seq int64
	// Turn is the turn index this event belongs to.
	Turn int
	// Text carries streamed or final text where applicable.
	Text string
	// ToolCall is set on EvToolCall.
	ToolCall *ToolCall
	// ToolResult is set on EvToolResult.
	ToolResult *ToolResult
	// Ask is set on EvPermissionAsk.
	Ask *PendingAsk
	// ModelRetry is set on EvModelRetry and carries the prior failed terminal's
	// typed facts for durable reconstruction without parsing Text.
	ModelRetry *ModelRetryPayload
	// RequestManifest is set on EvRequestManifest. It is log-only structural
	// evidence about the final request and contains no model-visible bodies.
	RequestManifest *RequestManifestPayload
	// NetworkAttempt is set on EvNetworkAttempt. It is log-only sanitized
	// transport/provider evidence emitted by the loop from the resilience observer.
	NetworkAttempt *NetworkAttemptPayload
	// Result is set on EvResult.
	Result *ResultPayload
	// TurnEnd is set on EvTurnEnd (this turn's usage + elapsed time).
	TurnEnd *TurnEndPayload
	// Hook is set on EvHook: the structured phase/tool/decision so clients render
	// hook notices distinctly (and colour blocked ones) rather than parsing Text.
	Hook *HookPayload
	// Approval is set on EvApproval: the resolved verdict (tool name + verdict
	// string + askID + learned flag). It carries NO raw args and NO deny-reason
	// body (gauntlet #7); see ApprovalPayload.
	Approval *ApprovalPayload
	// CompactionArchive is set on EvCompactionArchive: the pre-compaction
	// conversation that ReplaceHistory replaced (the durable non-destructive
	// archive). It is the parent's own history, so it opens no leak surface; see
	// CompactionArchivePayload.
	CompactionArchive *CompactionArchivePayload
	// UserPrompt is set on EvUserPrompt: the user-role message just recorded (Text +
	// Parts). It is the durable record of what the user asked (and the harness's own
	// synthetic continuations), consumed by the EventLog and reconstructed by an
	// event-sourced fold; it is log-only (skipped on the live client wire). See
	// UserPromptPayload.
	UserPrompt *UserPromptPayload
	// Usage is set on usage-bearing events. On EvResult it is the cumulative run
	// total; turn.end carries its per-turn usage in TurnEnd, NOT here.
	Usage *Usage
	// Subagent is set on the three subagent.* events: the REDACTED observability
	// projection of a Subagent child run (metadata only, never child content).
	Subagent *SubagentPayload
	// Team is set on the team.* events (start / member / tasks / end): the BOUNDED
	// observability projection of an in-process team run (fuller-but-bounded; member
	// content is capped and permission.ask is dropped, and never enters the parent
	// conversation).
	Team *TeamPayload
	// Parallel is set on the parallel.* events (start / branch / end): the REDACTED,
	// metadata-only observability projection of a Parallel fork-join run (group-level
	// join/winner facts + per-branch metadata + fork paths, never branch content).
	Parallel *ParallelPayload
	// RunID is the opaque, host-minted identity of the run that emitted this event
	// (ADR 0249). It is STAMPED BY THE LOOP, at Run.emit/emitOrAbort, beside the
	// existing Seq stamp — every event a run emits carries it, so no relay,
	// transport, or persistence path can omit it.
	//
	// It is NOT derive-at-append like Actor, and the difference is deliberate.
	// Actor can be stamped at the server's appendEvent chokepoint because it is
	// log-only; RunID must ALSO reach the client wire (an SDK resolves a started
	// run on the first run-ID-bearing event, and a watch filters envelopes by
	// run), and the wire path never passes through appendEvent. Stamping it "at
	// the relay" would mean stamping it at roughly nine sites by hand.
	//
	// Seq is monotonic WITHIN a run and restarts every run, so it cannot
	// distinguish two runs of one session; RunID is what makes an event
	// attributable to a specific run across restarts and across processes.
	//
	// EMPTY IS MEANINGFUL, not a bug: it means "session-scoped, not run-scoped".
	// The schedule.* lifecycle events are emitted from composition outside any
	// loop and legitimately have no run. The set of event types permitted to carry
	// an empty RunID is CLOSED and enforced by a test, so a new run-less emitter is
	// a visible decision rather than a silent gap. An ergonomic attachment filters
	// to one run and therefore never contains run-less events; a session activity
	// stream includes them, which is why the two are separate operations.
	//
	// A host that supplies no RunID emits events with an empty one, byte-identical
	// to the behaviour before ADR 0249. Like Actor, it is not a reconstruction
	// input: eventsource.Fold ignores it.
	RunID string
	// Actor is the verified caller who ACTED — who drove the request this event
	// belongs to (ADR 0204 decision 5). It is LOG-ONLY and DERIVE-AT-APPEND: every
	// emit site — the loop included — leaves it nil (the loop is storage-agnostic
	// and knows nothing about principals), and the server relay's single appendEvent
	// chokepoint stamps it from the CONTEXT PRINCIPAL just before the durable
	// Append. It never reaches the client wire (toProto has no field for it) and it
	// is not a reconstruction input: eventsource.Fold ignores it, so a folded
	// session keeps the owner its caller restored from the snapshot.
	//
	// Actor is NOT the session's Owner, and the two routinely differ: this phase
	// ships no authorization, so any authenticated caller may act on any session.
	// The owner answers "whose is this?" and remains the identity of record; the
	// actor answers "who did this?". Deriving it from the owner would stamp the
	// owner onto every event of a run somebody else drove — repudiation in both
	// directions, worst on EvApproval, where the record IS a human granting a tool
	// permission. The denormalization is deliberate: an event read in isolation
	// names its actor. A request with no verified caller records a nil Actor;
	// absence is never fabricated.
	Actor *Principal
	// Schedule is set on the schedule.* events (fired / skipped / failed): the
	// scheduler lifecycle projection emitted from composition (the scheduler), NOT
	// the loop. Client-visible (unlike the log-only EvApproval /
	// EvCompactionArchive / EvUserPrompt). See SchedulePayload.
	Schedule *SchedulePayload
	// Steer is set on EvSteer: the committed operator steer the turn-boundary
	// drain just recorded (the authoritative echo). Client-visible. See
	// SteerPayload.
	Steer *SteerPayload
}

Event is the domain-owned, provider-neutral unit of the streaming model. The loop runs as a producer writing Events to a channel; server adapters relay them to the gRPC server-stream or HTTP SSE.

type EventType

type EventType string

EventType is the kind of a domain Event. This is the single event taxonomy shared by the agent loop and the API; the API serializes it to proto and it is never a provider-specific type.

const (
	// EvSessionInit is emitted once when a run starts.
	EvSessionInit EventType = "session.init"
	// EvModelRetry is emitted immediately after session.init when a failed-step retry
	// starts. ModelRetry carries authoritative typed reconstruction data; Text is bounded,
	// harness-authored lifecycle guidance and is never recorded in model history.
	EvModelRetry EventType = "model.retry"
	// EvTurnStart is emitted at the beginning of each turn. Its Turn field is the
	// 0-based turn index (turnIdx = Counters.Turns - 1); turn.end mirrors it. The
	// 0-based wire contract is load-bearing — clients that surface a human-facing
	// "turn N" must add 1 themselves; do not shift the wire value.
	EvTurnStart EventType = "turn.start"
	// EvTurnEnd closes a turn's model exchange, carrying the typed TurnEndPayload
	// (this turn's Usage + elapsed model-call time). Emitted once per successful
	// turn, before the assistant message is recorded; not emitted on error/cancel.
	EvTurnEnd EventType = "turn.end"
	// EvMessageDelta carries streamed assistant text.
	EvMessageDelta EventType = "message.delta"
	// EvReasoningDelta carries streamed, human-readable reasoning summary text.
	// It is display-only and distinct from the opaque Message.Reasoning replay
	// item: it is emitted in addition to (never in place of) the reasoning
	// accumulation that is replayed back to the provider as encrypted content.
	EvReasoningDelta EventType = "reasoning.delta"
	// EvToolCall is emitted when a tool is about to run.
	EvToolCall EventType = "tool.call"
	// EvToolResult carries the result of a tool execution.
	EvToolResult EventType = "tool.result"
	// EvToolProgress is transient progress for a long-running tool: it carries a
	// human-readable Text line (e.g. "scanned 128/512 files") emitted at a
	// tool's phase boundaries via the observability seam so a slow call does not
	// look dead. It is ADVISORY — NOT persisted to a SessionStore and NOT recorded
	// to the model's conversation history; clients render it as a transient status
	// line, cleared on the next tool.result (or turn end).
	EvToolProgress EventType = "tool.progress"
	// EvPermissionAsk is emitted when the loop pauses for client approval.
	EvPermissionAsk EventType = "permission.ask"
	// EvPermissionRetract is emitted when a previously surfaced permission.ask is
	// WITHDRAWN by the harness — the owning subagent child was cancelled by the
	// client (Run.CancelChild) while parked on the ask, so there is nothing left to
	// approve. The payload rides the existing Ask field carrying ONLY the AskID
	// (server-authored; no tool/args/reason — there is no spoofing surface). Clients
	// dismiss a pending approval modal iff its AskID matches; an unknown/stale id is
	// ignored (idempotent). It maps to the proto event-type string verbatim (the
	// wire type field is a string passthrough; PermissionAsk's fields are optional,
	// so an ask_id-only payload is wire-legal with no proto change).
	EvPermissionRetract EventType = "permission.retract"
	// EvApproval is emitted when a permission ask is RESOLVED by a verdict — the
	// chronological approval record (which tool, which verdict, when) that pairs
	// with the EvPermissionAsk that preceded it. It is emitted at BOTH verdict
	// sites: the live-loop path (authorize, after the verdict resolves) and the
	// resume-from-awaiting path (resolvePendingCall, at entry). It is NOT relayed
	// to clients in 3a (the durable EventLog at the server relay consumes it, not
	// the Converse/SSE proto wire); it carries the ApprovalPayload. Its wire
	// string ("approval") is a passthrough, like EvNoProgress/StopBudget — no
	// proto enum, no task generate. See ApprovalPayload for the no-leak contract.
	EvApproval EventType = "approval"
	// EvHook is emitted when a hook fires (e.g. PreToolUse blocked).
	EvHook EventType = "hook"
	// EvCompaction is emitted when a compaction boundary is crossed.
	EvCompaction EventType = "compaction"
	// EvCompactionArchive is emitted AFTER a successful compaction (right after the
	// EvCompaction notice) carrying the pre-compaction conversation that
	// ReplaceHistory just replaced — the durable, NON-DESTRUCTIVE archive of the
	// history compaction would otherwise drop forever (the next session snapshot
	// holds only the compacted tail). It pairs with EvCompaction the way EvApproval
	// pairs with EvPermissionAsk: the notice is the live event, this is the durable
	// record. It carries the CompactionArchivePayload; like EvApproval it is
	// consumed by the durable EventLog at the server relay and is NOT relayed to the
	// client wire (no proto enum; the wire `type` is a string passthrough, no task
	// generate). The loop only EMITS it (e.emit) — the relay persists it; the loop
	// never imports port.EventLog.
	//
	// NO-LEAK CONTRACT (gauntlet #7): the archived messages are the PARENT'S OWN
	// conversation history — the exact slice already present in the pre-compaction
	// session snapshot. A child's (Subagent/team/parallel) content NEVER enters the
	// parent conversation (only a child's summarised ToolResult does), so this event
	// can carry no child content and there is no leak surface here, unlike the
	// redacted delegation events.
	EvCompactionArchive EventType = "compaction.archive"
	// EvNoProgress is emitted when a completed turn produced NEITHER a tool call NOR
	// meaningful assistant text (a reasoning-only / empty turn) and the loop is
	// either injecting a bounded continuation nudge or, on the final attempt, giving
	// up. Text carries a short human-readable reason and Turn is the no-progress
	// turn index. It is ADVISORY — NOT recorded to the model's conversation history
	// and NOT a diagnostics line (the event taxonomy owns the no-progress signal,
	// mirroring how cancellation/tool-error are event-covered). Clients render it as
	// a transient status line, like EvCompaction. It maps to the proto event-type
	// string verbatim (no proto enum; the wire type field is a string passthrough).
	EvNoProgress EventType = "no_progress"
	// EvProviderRoute is emitted once per turn when the serving provider reports
	// which DOWNSTREAM inference provider actually routed the request (issue #480).
	// Text carries an opaque human-readable display label (e.g. "Google" or
	// "Amazon Bedrock"), not the lowercase routing slug used in configuration and
	// not a round-trippable identifier; Turn is the turn index. Today only the
	// openrouter registry entry produces it (via the X-OpenRouter-Metadata opt-in);
	// mecatl's "provider" stays the wire adapter — this is the downstream provider
	// OpenRouter selected. It is ADVISORY + metadata-only: NOT recorded to the
	// model's conversation history, NOT a diagnostics line (the event taxonomy owns
	// it, like EvNoProgress), and gauntlet-#7-clean (a bounded routing label is
	// provider metadata, never child content). It is CLIENT-VISIBLE — clients render
	// it as a transient status line and may also retain it for the current turn. It
	// degrades to ABSENT on a cache hit (OpenRouter strips openrouter_metadata from
	// cached responses): the loop emits nothing rather than fabricating a value. It
	// maps to the proto event-type string verbatim (no proto enum; the wire type
	// field is a string passthrough, like EvNoProgress — no task generate).
	EvProviderRoute EventType = "provider.route"
	// EvRecoverNotice is emitted at the run-entry funnel (in the Service layer,
	// NOT the agent loop) when a session that failed on a PERMANENT provider error is
	// recovered for re-entry. Text carries a short human-readable advisory (e.g.
	// "this session's last turn failed on a permanent provider error; retrying replays
	// the same request and will fail again. Start a new session, or change the
	// request."). It is CLIENT-VISIBLE (like EvNoProgress) — rendered as a
	// transient status/warning so the user sees it at run start (before the run's
	// first event overwrites it). It does NOT block the run — Recover stays honest
	// (retry POSSIBLE, not guaranteed). It is emitted ONCE per recovery (the
	// permanence flag is cleared when Recover resets the session to idle, so a
	// subsequent prompt on the same session emits no repeat notice). It maps to the
	// proto event-type string verbatim (no proto enum; the wire type field is a
	// string passthrough, like EvNoProgress).
	EvRecoverNotice EventType = "recover_notice"
	// EvRequestManifest is emitted after the final provider-neutral request has been
	// assembled and compacted, immediately before the provider Stream call. It is a
	// log-only, content-safe structural manifest: bounded counts and closed
	// prompt/tool metadata, with no request bodies.
	EvRequestManifest EventType = "request.manifest"
	// EvNetworkAttempt is a log-only, provider-neutral record of one failed or
	// otherwise interesting LLM transport attempt. The resilience wrapper creates
	// the sanitized payload through its single decision-classification path; the
	// loop emits it and the relay persists it. It never carries raw errors, URLs,
	// headers, request/response bodies, prompts, or credentials.
	EvNetworkAttempt EventType = "network.attempt"
	// EvResult is the terminal event: success / limit / error / cancelled.
	EvResult EventType = "result"
	// EvUserPrompt is emitted when a USER-ROLE message is recorded into the
	// conversation — the genuine client prompt (recordPrompt) AND the harness-authored
	// synthetic continuations the loop records as user messages (the no-progress nudge,
	// the background-pending nudge, the background-completion notice). It carries the
	// UserPromptPayload (the recorded user-message Content — Text + Parts), enough for an
	// event-sourced consumer to reconstruct the user Message faithfully.
	//
	// LOG-ONLY (the EvApproval / EvCompactionArchive precedent): the durable EventLog at
	// the server relay consumes it, and the relay SKIPS it on the live client wire — the
	// driving client already authored/holds the prompt, so re-sending it is redundant.
	// It maps to the proto event-type string verbatim (no proto enum; the wire `type`
	// field is a string passthrough, no task generate). The loop only EMITS it; the relay
	// persists it — the loop never imports port.EventLog.
	//
	// WHY IT EXISTS: without it the durable log could not show WHAT THE USER ASKED (the
	// relay never re-emits the user prompt it received), and an event-sourced fold of the
	// log (engine/adapter/eventsource) could not reconstruct user-role turns — closing
	// that gap is ADR 0038 / the ADR 0027 row-11 follow-up.
	//
	// NO-LEAK CONTRACT (gauntlet #7): a CHILD's user prompt (a Subagent goal, a team
	// member task, a structured-output correction) is recorded into the CHILD session and
	// emitted on the CHILD run's event stream, which is drained INSIDE the delegation tool
	// and never forwarded to the parent run's log (exactly like every other child event).
	// So this event only ever carries the top-level run's own user input — no child
	// content crosses to the parent, the same posture as EvCompactionArchive.
	EvUserPrompt EventType = "user_prompt"

	// EvSubagentStart is emitted when a Subagent tool run begins. It is a
	// REDACTED observability projection of a child loop — never the child's
	// content. It carries only the parent call id, the child session id, and a
	// short goal label so a client can attribute and title the subagent card.
	EvSubagentStart EventType = "subagent.start"
	// EvSubagentTool is emitted each time a Subagent tool's child tool call
	// resolves. It is a REDACTED observability projection: it forwards ONLY the
	// child tool's NAME and error bool plus a running count — never the child's
	// tool args or result content, and never the child's message text. This keeps
	// the context-isolation guarantee (gauntlet #7) intact: nothing the child
	// produces enters the parent's conversation.
	EvSubagentTool EventType = "subagent.tool"
	// EvSubagentEnd is emitted when a Subagent tool run terminates. It is a
	// REDACTED observability projection carrying only aggregate metadata — the
	// child's tool count, token usage, stop reason, and wall-clock duration —
	// never any child content. The child's terminal summary still folds back into
	// the parent conversation exclusively via the Subagent tool's ToolResult.
	EvSubagentEnd EventType = "subagent.end"

	// EvTeamStart is emitted when a Team tool run begins. It is a BOUNDED
	// observability projection of an in-process team — it carries the parent call
	// id, the team id, and the roster the model formed (member names/roles, never
	// member content). See TeamPayload for the redaction contract.
	EvTeamStart EventType = "team.start"
	// EvTeamMember is emitted for each forwarded member-session event during a Team
	// run. Unlike the metadata-only subagent.tool projection, it is deliberately
	// FULLER — a team is meant to be watched — so it carries the member's message
	// text and BOUNDED tool-call/result previews, tagged by member name. It ALWAYS
	// sets Member (the member whose activity it projects) and InnerKind (that
	// member's underlying session event type). It is STILL bounded and redacted:
	// every preview is capped, and a member's permission.ask is DROPPED entirely
	// (never forwarded). See TeamPayload.
	EvTeamMember EventType = "team.member"
	// EvTeamTasks is emitted when the team's SHARED TASK LIST changes during a Team
	// run (and as a terminal snapshot on EvTeamEnd's payload). It is a team-WIDE
	// projection — NOT per-member — so it carries no Member; only TeamPayload.Tasks
	// (the id/state/assignee/deps snapshot in creation order). It is the discriminant
	// the client routes to the ctrl+a agents task sub-view. Snapshots are emitted
	// only on change (de-duped) to bound wire volume.
	EvTeamTasks EventType = "team.tasks"
	// EvTeamFindings is emitted when the team's SHARED FINDINGS LEDGER changes during
	// a Team run (and as a terminal snapshot on EvTeamEnd's payload). Like EvTeamTasks
	// it is a team-WIDE projection — NOT per-member — so it carries no Member; only
	// TeamPayload.Findings (each member-authored finding's recording member + a BOUNDED
	// body preview, in append order). The findings ledger is the PRIMARY channel the
	// lead's synthesis consolidates; surfacing it on the stream lets a watching client
	// see findings accrue. Snapshots are emitted only on change (de-duped) to bound
	// wire volume, exactly like EvTeamTasks.
	EvTeamFindings EventType = "team.findings"
	// EvTeamEnd is emitted when a Team run terminates. It is a BOUNDED projection
	// carrying aggregate metadata — the number of rounds, the stop reason, the team's
	// cumulative usage — plus the terminal Tasks/Findings snapshots and a per-member
	// terminal disposition snapshot (TeamPayload.Dispositions). The disposition carries
	// ONLY closed-enum supervisor verdicts (done/stopped × error/cancelled/budget),
	// NOT member-authored content, so the redaction discipline is untouched. The team's
	// joined summary folds back into the parent conversation exclusively via the Team
	// tool's ToolResult.
	EvTeamEnd EventType = "team.end"

	// EvParallelStart is emitted when a Parallel (fork-join fan-out) tool run begins.
	// It is a REDACTED, RUN-LEVEL projection: it carries the parent call id, the join
	// strategy, and the branch count so a client can frame the group from the first
	// event — never any branch content. See ParallelPayload for the redaction contract.
	EvParallelStart EventType = "parallel.start"
	// EvParallelBranch is emitted for each per-branch lifecycle transition of a Parallel
	// run, discriminated by ParallelPayload.Kind (branch_start / branch_tool / branch_end).
	// Like EvSubagentTool it is METADATA ONLY: a branch_tool carries only the child tool's
	// NAME + error bool + running count (forwarded via the SAME drainChildObserved
	// chokepoint Subagent uses), and a branch_end carries only the branch's stop / usage /
	// duration / failed flag / fork-root path — never branch args, result bodies, or
	// message text. This keeps gauntlet #7 intact.
	EvParallelBranch EventType = "parallel.branch"
	// EvParallelEnd is emitted when a Parallel run terminates. It is a REDACTED, RUN-LEVEL
	// projection carrying the join strategy, the WINNER branch index (-1 for join=all /
	// none-succeeded), the PRESERVED winner fork root, the run-total usage, the branch
	// count, and the run-level stop — never any branch content. The branches' summaries
	// fold back into the parent conversation exclusively via the Parallel tool's
	// ToolResult.
	EvParallelEnd EventType = "parallel.end"

	// EvScheduleFired is emitted when a schedule fires — a Claim→run→RecordFire
	// cycle started for a named schedule. It is emitted from COMPOSITION (the
	// scheduler) at fire time, NOT the agent loop (engine/agent never imports
	// port.ScheduleStore). For v1 it is delivered to the fire session's durable
	// EventLog ONLY (pull-only via GetFire/ListFires); a live broadcast stream is
	// a future phase. It carries the SchedulePayload; kind/stop/err are STRING
	// passthroughs (the EvNoProgress/StopBudget discipline — no proto enum).
	// Maps to the proto event-type string verbatim.
	EvScheduleFired EventType = "schedule.fired"
	// EvScheduleSkipped is emitted when a schedule's fire was SKIPPED — the
	// singleton overlap check found a prior fire still running, or the misfire
	// policy was MisfireSkip for a missed slot. Emitted from composition, not the
	// loop; carries SchedulePayload (kind="skipped"). A skipped fire has no
	// session id, so it is dropped from the durable log (session-keyed) and
	// surfaces only via the operator diagnostic for v1.
	EvScheduleSkipped EventType = "schedule.skipped"
	// EvScheduleFailed is emitted when a schedule's fire FAILED — the fire's run
	// ended with StopError, or the fire could not be claimed/driven at all.
	// Emitted from composition, not the loop; for v1 delivered to the fire
	// session's durable EventLog ONLY (pull-only via GetFire/ListFires); carries
	// SchedulePayload (kind="failed", stop/err populated).
	EvScheduleFailed EventType = "schedule.failed"

	// EvSteer is emitted when the run's steer inbox DRAIN commits a pending
	// operator steer at a turn boundary (steer-while-running, issue #512). It
	// carries the SteerPayload — the COMMITTED text that was just recorded into
	// the conversation as an ordinary user continuation (recordContinuation).
	//
	// It is the AUTHORITATIVE ECHO: the engine is the sole authority on which
	// appended bundle drained (the client cannot observe the exact drain moment
	// across stream latency), so the client renders THIS text rather than
	// guessing which of its staged steers landed. The recorded == streamed ==
	// model-view invariant holds: the echoed text is byte-identical to the user
	// message recorded into history and replayed to the model on the next turn.
	//
	// It is CLIENT-VISIBLE (unlike the log-only EvUserPrompt / EvApproval /
	// EvCompactionArchive): the relay forwards it on the live wire so the client
	// renders the committed steer as it happens. It is NOT recorded to the
	// model's conversation history (the recorded user message is the model-facing
	// half; this event is the client-facing echo of the same fact). It maps to
	// the proto event-type string verbatim (no proto enum; the wire `type` field
	// is a string passthrough, like EvNoProgress — no task generate).
	EvSteer EventType = "steer"
)

type GrantType

type GrantType string

GrantType names how a Principal was authenticated. It is a closed enum of exactly three values (ADR 0204 decision 1); the zero value is deliberately NOT a member, so an unset grant type is never mistaken for a valid one.

const (
	// GrantTypeUser is an interactive end user (an OIDC authorization-code flow).
	GrantTypeUser GrantType = "user"
	// GrantTypeClientCredentials is a machine caller (an OAuth2
	// client-credentials flow), e.g. a schedule firing under its captured owner.
	GrantTypeClientCredentials GrantType = "client_credentials"
	// GrantTypeSystem is an internal harness goroutine (childgc, the memory/dream
	// consolidators, the scheduler tick/fire/delivery/reconcile). It is explicit
	// so an internal caller is never an ABSENT principal.
	GrantTypeSystem GrantType = "system"
)

func GrantTypeFromClaims

func GrantTypeFromClaims(claims map[string]any) GrantType

GrantTypeFromClaims derives the conservative attribution grant from an already-verified token claim set. The first non-empty explicit claim wins (`grant_type` before `gty`): a recognised user or client grant is decisive. An unknown explicit value is not itself classification evidence, so the equal-client-identity fallback may still identify a machine. With no such fallback, unknown and malformed signals default to user. It never returns system.

func (GrantType) Valid

func (g GrantType) Valid() bool

Valid reports whether g is one of the three defined grant types.

type HookDecision

type HookDecision string

HookDecision is the outcome a hook fire produced, so a client can colour and rank a hook notice without parsing its prose. It is provider-neutral and maps 1:1 to a proto enum.

const (
	// HookInfo is a benign, informational hook notice (the default): the hook
	// fired and allowed the action, or reported something non-blocking.
	HookInfo HookDecision = "info"
	// HookBlocked means the hook vetoed the action (a PreToolUse/UserPromptSubmit
	// block, or a fail-safe hook error). These can abort a run and must read as
	// the most severe hook notice.
	HookBlocked HookDecision = "blocked"
	// HookModified means the hook rewrote the action's payload (prompt rewrite,
	// tool-arg or tool-result mutation) without blocking it.
	HookModified HookDecision = "modified"
	// HookAdvisory means the hook flagged the content as a finding but did NOT
	// alter the call/result (advisory-mode guardrail). It is client-visible
	// (rendered as a warning notice on the tool card) and model-invisible: the
	// tool result is byte-unchanged. Distinct from HookInfo (a generic benign
	// notice) and HookBlocked/HookModified (which change the outcome).
	HookAdvisory HookDecision = "advisory"
)

type HookPayload

type HookPayload struct {
	// Phase is the lifecycle point the hook fired at (e.g. "PreToolUse"); empty
	// when not applicable.
	Phase string
	// Tool is the tool the hook relates to for the per-tool phases; empty
	// otherwise.
	Tool string
	// Decision is the outcome (info / blocked / modified).
	Decision HookDecision
	// CallID is the id of the tool call this hook fired against, for the per-tool
	// phases (PreToolUse / PostToolUse); empty for non-tool phases (e.g. Stop,
	// SessionStart) and for tool phases where no call is in scope. It lets a client
	// address the hook notice to the originating tool card (e.g. mark that exact
	// tool_call as failed) instead of falling back to a free-standing note.
	CallID ToolCallID
}

HookPayload is the structured detail carried by an EvHook Event, in addition to the human-readable Event.Text. It lets a client render a hook notice distinctly from a compaction notice — labelling the lifecycle Phase and colouring the Decision (e.g. a blocked hook in an error colour) — instead of string-parsing the free text. All fields are optional; a zero value renders as a generic informational hook.

type IncarnationID

type IncarnationID string

IncarnationID is an opaque identity for one lifetime of a session key. New values contain 128 bits from crypto/rand and encode no metadata.

func LegacyIncarnationID

func LegacyIncarnationID(id SessionID, createdAtUnixNano int64, owner *Principal) IncarnationID

LegacyIncarnationID deterministically identifies a pre-incarnation snapshot. Its reserved prefix cannot collide with a newly minted incarnation. It is honest only at the legacy snapshot boundary; new sessions never use it.

func NewIncarnationID

func NewIncarnationID() IncarnationID

NewIncarnationID mints an opaque session-incarnation identity.

func PersistedIncarnationID

func PersistedIncarnationID(inc IncarnationID, id SessionID, createdAtUnixNano int64, owner *Principal) IncarnationID

PersistedIncarnationID returns inc when valid, or the deterministic legacy identity for a snapshot written before incarnations were persisted.

func (IncarnationID) Valid

func (i IncarnationID) Valid() bool

Valid reports whether i has the closed syntax of a minted or deterministic legacy incarnation. Prefix separation makes legacy values disjoint from all newly minted values.

type Limits

type Limits struct {
	// MaxTurns caps the number of model calls; 0 disables.
	MaxTurns int
	// MaxToolCalls caps the total tool invocations; 0 disables.
	MaxToolCalls int
	// MaxConsecutiveFailures caps back-to-back tool failures; 0 disables.
	MaxConsecutiveFailures int
}

Limits are the configured stop conditions for a session. A zero value in any field disables that particular limit.

func (Limits) WithDefaults

func (l Limits) WithDefaults(d Limits) Limits

WithDefaults returns l with each zero field filled from d. A caller that pins only some caps (say MaxTurns) keeps the rest from d rather than disabling them: a zero field means "unset", not "unlimited", once a default is supplied. An all-zero l yields d unchanged; a fully-set l is returned verbatim. This mirrors the per-field merge the subagent and team layers already use (defLimits, mergeLimits), so a partial Limits behaves the same wherever defaults apply.

type MediaKind

type MediaKind string

MediaKind discriminates a non-text content Part of a user Message.

const (
	// MediaImage is an image part (e.g. image/png, image/jpeg).
	MediaImage MediaKind = "image"
	// MediaAudio is an audio part (e.g. audio/wav, audio/mp3).
	MediaAudio MediaKind = "audio"
)

type Message

type Message struct {
	// Role is the author of this message.
	Role Role
	// Text is the message body (assistant text, user prompt, etc.).
	Text string
	// ToolCalls holds the tool invocations requested by an assistant message.
	ToolCalls []ToolCall
	// ToolResult holds the result carried by a tool-role message; nil otherwise.
	ToolResult *ToolResult
	// Reasoning is the provider's opaque reasoning REPLAY blob (e.g. OpenAI's
	// reasoning-item encrypted_content, or Anthropic's (thinking,signature) pair),
	// replayed back verbatim on subsequent calls and never interpreted or displayed
	// by the harness. The STRUCTURE is provider-neutral (one opaque blob per
	// message); the CONTENTS are provider-private — each adapter packs/unpacks its
	// own wire shape, so the domain value object stays a bare string (do NOT widen
	// it). It is distinct from the human-readable reasoning SUMMARY surfaced via
	// reasoning.delta events for display: that prose is never stored here.
	Reasoning string
	// ProviderPhase is the OpenAI Responses API's opaque phase marker on an
	// assistant message ("commentary" for intermediate output, "final_answer" for
	// the final answer), replayed back verbatim on subsequent calls and never
	// interpreted or displayed by the harness. For store:false manual-replay apps
	// OpenAI requires preserving and resending it, or GPT-5.x models treat preambles
	// as final answers / stop early. The STRUCTURE is provider-neutral (one opaque
	// phase string per message); the CONTENTS are provider-private (the harness
	// never branches on or validates the value — do NOT widen it). Empty string
	// means "no phase". Same discipline as Reasoning. (Named ProviderPhase, not
	// Phase, to disambiguate from the governance/hook-lifecycle Phase concept, which
	// is interpreted — the opposite contract.)
	ProviderPhase string
	// ReasoningItemID is the OpenAI Responses API's opaque reasoning-item id
	// (the "rs_…" id on the item whose encrypted_content rides Message.Reasoning),
	// replayed back verbatim on subsequent stateless (store:false) calls and never
	// interpreted or displayed by the harness. For store:false manual-replay apps
	// the SDK serialises the assistant reasoning item with its id, and a strict
	// gateway rejects an empty-string id ("id":""), so the id must be preserved
	// alongside the blob or the replay 400s. The STRUCTURE is provider-neutral (one
	// opaque id per message); the CONTENTS are provider-private (the harness never
	// branches on or validates the value — do NOT widen it). Empty string means
	// "no id captured" (e.g. Anthropic, or a pre-fix session). Same discipline as
	// Reasoning/ProviderPhase.
	ReasoningItemID string
	// Parts carries non-text media (image/audio) on a USER message; it is nil for
	// assistant/tool/system messages. Text remains the flattened text body
	// (embedded-text resources collapse into it); Parts carries only the binary or
	// URL-referenced media that rides alongside the text. Do not mutate Parts (or a
	// Part's Data) after construction.
	Parts []Content
}

Message is an immutable value object: one entry in the model-visible conversation history. Construct it with one of the constructors below; it carries no mutating methods.

func CloneMessages

func CloneMessages(msgs []Message) []Message

CloneMessages returns a copy of msgs with a FRESH backing array, so the returned slice and the original never alias: an Append to one cannot grow into the other's storage. The COPY IS SHALLOW per element, which is sound because a Message is an IMMUTABLE value object — its slice/pointer fields (ToolCalls, ToolResult, Parts) are never mutated in place after construction (the constructors build them once; the only mutation of a Conversation is Append, which adds whole new Messages, never edits an existing one's fields). A fork child therefore shares the parent's per-message tool-call / part data safely: it only appends new Messages, it never rewrites a copied one. A nil input returns nil.

func ForkSnapshot

func ForkSnapshot(c *Conversation) []Message

ForkSnapshot returns a deep-enough copy (CloneMessages — fresh backing array, immutable-Message elements) of c's history with any TRAILING UNANSWERED tool calls stripped, so the result is always tool-pairing-valid (ValidateToolPairing passes). It is the seam a fork:true Subagent child seeds from: at dispatch time the parent's trailing assistant message carries the Subagent{fork:true} call itself, whose tool result is recorded only AFTER dispatch returns — so a naive copy would end on a dangling tool_use and draw a provider HTTP 400 on the child's first replay. Stripping only the trailing-orphan tail preserves every prior turn (including fully-answered tool pairs); a conversation with no trailing orphan is returned cloned-but-unchanged. A turn-0 (empty, or just-the-fork-call) parent yields an empty snapshot — trivially pairing-valid, and the fork degrades to a fresh-context child, which is benign by design.

A nil receiver returns nil.

func NewAssistantMessage

func NewAssistantMessage(text, reasoning string, calls []ToolCall) Message

NewAssistantMessage constructs an assistant-role message carrying optional text, reasoning, and tool calls.

func NewSystemMessage

func NewSystemMessage(text string) Message

NewSystemMessage constructs a system-role message.

func NewToolMessage

func NewToolMessage(result ToolResult) Message

NewToolMessage constructs a tool-role message carrying a single tool result. Tool-role messages now have Parts parity with user-role messages, but the typed blocks live on ToolResult.Parts (not Message.Parts): Message.Parts stays media-only for user messages (decision #1 / Risk #4 mitigation (a)), and providers read ToolResult.Parts for tool results.

func NewUserMessage

func NewUserMessage(text string) Message

NewUserMessage constructs a user-role message.

func NewUserMessageWithParts

func NewUserMessageWithParts(text string, parts []Content) Message

NewUserMessageWithParts constructs a user-role message carrying flattened text plus non-text media parts. text may be empty when parts carries the content; parts may be nil for a text-only message (equivalent to NewUserMessage).

func StripProviderState

func StripProviderState(messages []Message) []Message

StripProviderState returns a copy of messages with every provider-private replay blob cleared (Message.Reasoning, Message.ReasoningItemID, Message.ProviderPhase, and each ToolCall.ItemID), yielding a provider-neutral history: the roles, text, tool calls (ID/Name/Args), tool results, and media parts are preserved verbatim. Both the OpenAI and Anthropic adapters treat an EMPTY blob as "no blob" and omit it on the wire, so a stripped history replays safely to ANY provider — the new request's thinking/reasoning config is derived from the NEW model, not the stripped history. Used for CROSS-provider model-switch carryover, where a verbatim replay would hand one provider's encrypted blob to another (HTTP 400). Same-provider carryover does NOT strip (blobs replay, cache stays warm).

The input slice is not mutated; a fresh backing array is returned (each Message is a value, but a Message with tool calls shares the ToolCalls backing array with the input unless copied — copy it before clearing ItemID so the caller's messages are never mutated). A nil or empty input is returned as-is.

type ModelRetryPayload

type ModelRetryPayload struct {
	Disposition RetryDisposition
	Progress    StreamProgress
}

ModelRetryPayload is the structured durable marker that a failed-step retry run has started. Disposition and Progress are copied from the consumed failed terminal so event-sourced reconstruction never parses advisory Text.

type NetworkAttemptPayload

type NetworkAttemptPayload struct {
	SessionID         SessionID `json:"session_id"`
	RunSerial         int64     `json:"run_serial"`
	Turn              int       `json:"turn"`
	Attempt           int       `json:"attempt"`
	MaxAttempts       int       `json:"max_attempts"`
	ElapsedMs         int64     `json:"elapsed_ms"`
	RetryDisposition  string    `json:"retry_disposition"`
	StreamProgress    string    `json:"stream_progress"`
	Decision          string    `json:"decision"`
	SuppressionReason string    `json:"suppression_reason,omitempty"`
	BackoffMs         int64     `json:"backoff_ms"`
	FailureClass      string    `json:"failure_class"`
	HTTPStatus        int       `json:"http_status,omitempty"`
	InBandStatus      int       `json:"in_band_status,omitempty"`
	CorrelationKind   string    `json:"correlation_kind,omitempty"`
	CorrelationDigest string    `json:"correlation_digest,omitempty"`
}

NetworkAttemptPayload is bounded, sanitized evidence about one failed or interesting provider attempt. Durations are whole milliseconds; zero means unavailable/not applicable. Classification values are closed vocabularies produced by the shared resilience decision path. CorrelationDigest is a domain-separated SHA-256 digest, never a raw provider correlation value. No field may contain raw errors or arbitrary transport data.

func CanonicalNetworkAttempt

func CanonicalNetworkAttempt(in NetworkAttemptPayload, id SessionID, runSerial int64, turn int) (NetworkAttemptPayload, bool)

CanonicalNetworkAttempt validates the complete producer-controlled payload and binds its correlation to trusted loop-owned run identity. Invalid input is rejected as a whole and must not be emitted or persisted.

type ParallelEventKind

type ParallelEventKind string

ParallelEventKind discriminates which lifecycle transition an EvParallelBranch event projects (mirroring the way the client switches on a kind, like client.SubagentKind). The run-level EvParallelStart / EvParallelEnd events carry their own implicit kind via their event type; ParallelEventKind is set only on EvParallelBranch.

const (
	// ParallelBranchStart marks a branch beginning its child run. Sets BranchIndex,
	// BranchLabel, Goal.
	ParallelBranchStart ParallelEventKind = "branch_start"
	// ParallelBranchTool marks a branch's child tool call resolving. Sets BranchIndex,
	// ToolName, IsError, ToolCount — metadata only (the drainChildObserved projection).
	ParallelBranchTool ParallelEventKind = "branch_tool"
	// ParallelBranchEnd marks a branch's child run terminating. Sets BranchIndex,
	// ToolCount, Stop, Usage, DurationMs, Failed, Workspace.
	ParallelBranchEnd ParallelEventKind = "branch_end"
)

type ParallelPayload

type ParallelPayload struct {
	// ParentCallID is the parent's Parallel tool-call id; it is the GROUP key (one
	// Parallel call = one group) and attributes every parallel.* event to the
	// originating Parallel card. Set on all kinds.
	ParentCallID string
	// Kind discriminates the per-branch lifecycle transition (branch_start /
	// branch_tool / branch_end). Set on EvParallelBranch only; empty on the run-level
	// start/end events.
	Kind ParallelEventKind

	// Join is the normalized join strategy ("all" / "first" / "judge"). Set on
	// EvParallelStart and EvParallelEnd (run-level).
	Join string
	// BranchCount is the number of branches in the run. Set on EvParallelStart and
	// EvParallelEnd (run-level).
	BranchCount int

	// BranchIndex is the 0-based branch index — the stable per-branch group key within
	// a Parallel call. Set on every EvParallelBranch kind. (The index is the row key;
	// ChildID below is the ADDRESSING handle.)
	BranchIndex int
	// ChildID is the branch's child SESSION id ("parallel-<callID>-<i>") — the uniform
	// per-child cancel/inspect handle (the same single-handle convention as
	// SubagentPayload.ChildID / the Team member session id), surfaced so a client can
	// address a branch (CancelChild) WITHOUT deriving the id grammar. Set on the
	// branch_start and branch_end kinds. It is an id, never branch content.
	ChildID string
	// ChildIncarnation is internal durable correlation metadata, never projected.
	ChildIncarnation IncarnationID
	// BranchLabel is the humanized 1-based branch label ("branch-1" …). Set on the
	// branch_start kind.
	BranchLabel string
	// Goal is a short, plain-text label for the branch's task (a truncation of the
	// model-authored branch prompt — the parent's own instruction, NOT branch content).
	// Set on the branch_start kind. Clamped identically to SubagentPayload.Goal.
	Goal string
	// RoutedCategory / RoutedModel are the OPT-IN semantic model router's classification
	// for this branch (ADR 0031 / ADR 0034): the chosen CATEGORY label and the concrete
	// MODEL id the branch was minted on. Set on the branch_start kind ONLY when the router
	// was wired AND classified this branch (both empty otherwise — no router, a fail-soft
	// miss that inherited the default branch model, or a branch cancelled before it started).
	// Like SubagentPayload.RoutedCategory/RoutedModel they are BARE METADATA — a category
	// label and a model id, never the branch prompt or the classifier's reasoning — so they
	// are gauntlet-#7 safe (no branch content, no model-influenced free text crosses). They
	// ride the proto/client wire end-to-end (parallel.branch_start: Parallel.routed_category
	// = field 19 / routed_model = field 20), surfaced via the server mapper — see ADR 0034.
	RoutedCategory string
	RoutedModel    string
	// RoutingReason names WHY the router did NOT classify this branch (branch_start kind
	// only): EMPTY on a routed hit (RoutedCategory/RoutedModel set), otherwise one of the
	// RoutingReason* gate constants (router-disabled, or aborted for a branch cancelled
	// before it started) or a static miss code from the classifier (the RouterMiss* values)
	// or composition. Open callback detail is reduced before emission. It is BARE METADATA
	// — a bounded harness/composition reason code, never the branch prompt or classifier
	// reasoning — so it is gauntlet-#7 safe (no branch content crosses). Clamped at the
	// emit site.
	RoutingReason string
	// Model is the concrete MODEL id the branch ACTUALLY ran on (branch_start kind only),
	// set unconditionally — inherited default branch model or the opt-in router —
	// independent of whether the router fired. It is BARE METADATA — a model id, never
	// branch content — so it is gauntlet-#7 safe (no branch content crosses). When the
	// router classified this branch, Model == RoutedModel. It rides the proto/client wire
	// end-to-end (parallel.branch_start: Parallel.model = field 21), surfaced via the
	// server mapper — see ADR 0035.
	Model string

	// ToolName is the name of a branch's child tool that just ran. Set on the
	// branch_tool kind only. It is the tool NAME alone — never branch args/result.
	ToolName string
	// IsError reports whether that branch tool call failed. Set on branch_tool only.
	IsError bool
	// ToolCount is the running (branch_tool) or final (branch_end) number of a branch's
	// child tool calls observed.
	ToolCount int
	// Text is a BOUNDED preview of the branch's message/result text — control-byte
	// scrubbed and rune-capped by clampPreview in engine/agent, never the raw,
	// unbounded body. Set on the branch_tool kind for the message.delta / result
	// inner kinds when a preview is available.
	Text string
	// Detail is a BOUNDED preview of a branch tool call's args (tool.call) or a
	// tool result's body (tool.result) — control-byte scrubbed and rune-capped by
	// clampPreview in engine/agent, never the raw, unbounded args/result body. Set
	// on the branch_tool kind for the tool.call / tool.result inner kinds when a
	// preview is available.
	Detail string
	// InnerKind discriminates which inner branch event kind the preview came from
	// (message.delta / tool.call / tool.result / result). Set on the branch_tool
	// kind alongside Text / Detail. A branch's permission.ask is never projected.
	InnerKind EventType

	// Failed reports whether the branch's child run failed (StopError / cancelled /
	// fork failure). Set on the branch_end kind.
	Failed bool

	// Stop is the branch's terminal stop reason (branch_end) or the run-level stop
	// (EvParallelEnd; the winner's stop for join=first/judge, zero/omitted for join=all).
	Stop StopReason
	// Usage is the branch's cumulative usage (branch_end) or the run TOTAL (EvParallelEnd,
	// summed across branches).
	Usage Usage
	// DurationMs is the branch's wall-clock duration in milliseconds. Set on branch_end.
	DurationMs int64

	// Winner is the branch index of the selected winner on EvParallelEnd — a real
	// BranchIndex for join=first/judge, or -1 for join=all and none-succeeded. Set on
	// EvParallelEnd only.
	Winner int
}

ParallelPayload is the REDACTED observability projection carried by the parallel.* events (EvParallelStart / EvParallelBranch / EvParallelEnd).

REDACTION CONTRACT — bounded previews (ADR 0079, superseding the former metadata-only contract): on branch tool events it deliberately forwards BOUNDED previews of the branch's content — Text is a bounded, clamped preview of the branch's message text, Detail is a bounded, clamped preview of a branch tool call's args or a tool result's body. Every such preview is CAPPED — a control-byte scrub plus a rune cap applied by clampPreview in engine/agent — so an unbounded args/result/message body can never be copied verbatim, and a branch's permission.ask is DROPPED entirely: it is NEVER forwarded, so a pending-ask reason (which can quote secrets or sensitive args) never reaches the stream. The only payload forwards only bounded previews and non-sensitive lifecycle metadata. The forwarding is CLIENT-ONLY: nothing here ever enters the parent Session's Conversation (gauntlet #7 unchanged).

Unlike the FLAT SubagentPayload, a Parallel run is a GROUP: N branches of ONE call (keyed by ParentCallID) sharing a join strategy and a single winner (join=first/judge). The per-branch events carry metadata keyed by BranchIndex.

Which fields are set depends on the event kind:

  • EvParallelStart: ParentCallID, Join, BranchCount.
  • EvParallelBranch (Kind=branch_start): ParentCallID, Kind, BranchIndex, ChildID, BranchLabel, Goal, [RoutedCategory, RoutedModel, RoutingReason], Model.
  • EvParallelBranch (Kind=branch_tool): ParentCallID, Kind, BranchIndex, ToolName, IsError, ToolCount, and — when a preview is available — Text / Detail / InnerKind.
  • EvParallelBranch (Kind=branch_end): ParentCallID, Kind, BranchIndex, ChildID, ToolCount, Stop, Usage, DurationMs, Failed.
  • EvParallelEnd: ParentCallID, Join, BranchCount, Winner, Usage (run total), Stop.

type PendingAsk

type PendingAsk struct {
	// AskID correlates the ask with the client's resolution.
	AskID string
	// Tool is the name of the tool awaiting approval.
	Tool string
	// Args is the proposed tool-call argument payload.
	Args json.RawMessage
	// Reason explains why approval is required.
	Reason string
	// Call is the id of the gated ToolCall this ask pauses on. It is the
	// REQUEST-half twin of ApprovalPayload.Call (the verdict half): an OPAQUE
	// identifier, NOT secret content — it is already implicitly encoded inside
	// AskID (see agent.newAskID) — so surfacing it directly opens no new leak
	// surface. It is durable, grammar-free correlation data: a host that pauses
	// on a PendingAsk reads Call instead of parsing the AskID grammar. Unlike the
	// run-scoped ConfiguredAsk/FlooredConfiguredAllow below, it round-trips in the
	// snapshot (it is correlation data, not run-scoped policy state).
	Call ToolCallID `json:"call,omitempty"`
	// ConfiguredAsk carries governance.PermissionDecision.ConfiguredAsk onto the
	// pending ask: the Ask came from a deliberately-configured rule (above the
	// built-in floor). An approval layer keys "never auto-approve a configured
	// Ask" on it. It is purely run-scoped state — never serialized to a snapshot
	// (an old snapshot deserializing false is harmless: an ask is never resumed
	// from one). Mutually exclusive with FlooredConfiguredAllow.
	ConfiguredAsk bool
	// FlooredConfiguredAllow carries
	// governance.PermissionDecision.FlooredConfiguredAllow onto the pending ask:
	// the Ask exists only because of the substitution floor, and the command is
	// one the configured policy already allows with provably read-only
	// substitution contents — so an approval layer may relax the floor without
	// surfacing it. Same run-scoped, never-serialized posture as ConfiguredAsk.
	// Mutually exclusive with it (a third RUN-SCOPED ask-provenance signal would
	// warrant collapsing these two into a single enum on both this value object and
	// the governance decision; HookOriginated below is NOT that third signal — it is
	// a different, SERIALIZED axis, see its note).
	FlooredConfiguredAllow bool
	// HookOriginated marks an ask that arose from a PreToolUse hook BLOCK refined
	// into an approval (governance.HookOutcome.AskApproval; ADR 0062), NOT from the
	// permission policy. It is SERIALIZED (json:"hook_originated,omitempty") and is
	// DELIBERATELY a bool, NOT folded into an AskOrigin enum with
	// ConfiguredAsk/FlooredConfiguredAllow: those two are RUN-SCOPED policy hints that
	// are never serialized (an old snapshot deserializing false is harmless because a
	// policy ask is never resumed from one), whereas HookOriginated is CROSS-PROCESS
	// LOAD-BEARING — the awaiting-resume path (Engine.ResumeApproval →
	// resolvePendingCall) runs in a FRESH process and keys the skip-preHook branch on
	// it (an Allow must EXECUTE the tool WITHOUT re-running the PreToolUse hook, which
	// would re-block / re-ask). Collapsing a serialized correctness marker into an
	// enum with two ephemeral hints would conflate two different lifetimes and
	// serialization contracts — so it stays its own bool. Its plan-approval sibling
	// PlanOriginated (issue #206) is the SECOND serialized provenance bit: the two
	// bools are now the serialized contract, and the read-time Origin() accessor
	// derives the single provenance from them.
	HookOriginated bool `json:"hook_originated,omitempty"`
	// PlanOriginated marks an ask that arose from a plan-approval gate (the
	// operator was asked to approve a presented plan; issue #206), NOT from the
	// permission policy or a hook. It is SERIALIZED (json:"plan_originated,omitempty")
	// and CROSS-PROCESS LOAD-BEARING — the awaiting-resume path
	// (Engine.ResumeApproval → resolvePendingCall) runs in a FRESH process and keys
	// the plan-flip branch on it (an Allow flips the session out of plan mode and
	// drives the turn through the completed path with StopPlanApproved; the resumed
	// call is NOT re-presented). It is deliberately a bool sibling of HookOriginated
	// rather than folded into a single enum with ConfiguredAsk/FlooredConfiguredAllow:
	// those two are RUN-SCOPED policy hints that are never serialized, whereas
	// PlanOriginated (like HookOriginated) is a serialized correctness marker that
	// must survive a process restart. The read-time provenance is surfaced via Origin.
	PlanOriginated bool `json:"plan_originated,omitempty"`
}

PendingAsk describes a permission prompt the loop is blocked on while in StateAwaiting. It is surfaced to the client via a permission.ask Event and resolved by ResumeWith.

func (PendingAsk) Origin

func (p PendingAsk) Origin() AskOrigin

Origin returns the provenance of the ask. Hook takes precedence over Plan when both bools are set (a construction invariant violation — only one site ever sets a given ask — but the tie-break is deterministic for the reader).

type PermissionMode

type PermissionMode string

PermissionMode is the session-wide permission posture, which drives plan-mode tool filtering and acceptEdits behaviour.

const (
	// ModeDefault is the standard deny→ask→allow posture.
	ModeDefault PermissionMode = "default"
	// ModePlan enforces a read-only toolset (plan mode).
	ModePlan PermissionMode = "plan"
	// ModeAccept auto-accepts edits (acceptEdits).
	ModeAccept PermissionMode = "acceptEdits"
)

type PlacementMetadata added in v0.13.0

type PlacementMetadata struct {
	Kind     string `json:"kind,omitempty"`
	Label    string `json:"label,omitempty"`
	Branch   string `json:"branch,omitempty"`
	Revision string `json:"revision,omitempty"`
}

PlacementMetadata is bounded display-only information persisted with a session. It is never accepted as placement authority and contains no exact environment identity or physical locator.

type Principal

type Principal struct {
	// Issuer is the IdP that minted the token (the canonical `iss` claim).
	Issuer string
	// Subject is the caller id within that issuer (the `sub` claim).
	Subject string
	// GrantType is how the caller authenticated.
	GrantType GrantType
	// Name is an optional human-readable display label. It is never part of
	// identity — display only.
	Name string
}

Principal is the verified caller a session or schedule is attributed to (ADR 0204 decision 1). Identity is the (Issuer, Subject) PAIR, never Subject alone — two IdPs or realms collide on `sub`.

It is a pure value object: comparable, stdlib-only, and deliberately narrow. It carries NO scopes, NO authority, NO credentials, and NO claims map — those belong to the enforcement tracks, not to attribution. It is modeled on ToolHive's PrincipalInfo; ToolHive is not imported.

Absent identity is a nil *Principal, NEVER a fabricated anonymous one (the ToolHive anonymous-middleware anti-pattern ADR 0204 rejects).

func PrincipalFromClaims

func PrincipalFromClaims(claims map[string]any) *Principal

PrincipalFromClaims projects an already-verified token claim set into the narrow caller identity used by the engine. It does not verify claims or credentials; callers must do that before calling it. Missing, empty, non-string, or NUL-bearing iss/sub claims yield nil. Claim strings are otherwise preserved byte-exactly.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) *Principal

PrincipalFromContext returns the verified caller carried by ctx, or nil when there is none. Callers MUST handle nil as "no verified identity" — this function never fabricates an anonymous principal.

The returned principal is a COPY: a reader that mutates it cannot change what the context reports for every later reader (which would corrupt ownership and audit attribution downstream). This is the read half of WithPrincipal's copy-on-store promise.

func (*Principal) Clone

func (p *Principal) Clone() *Principal

Clone returns a copy of p, or nil when p is nil. It is the ONE place the "copy a *Principal across a boundary, nil stays nil" rule lives — every site that hands a principal out of, or into, a structure it does not own routes through it instead of hand-rolling the nil check and the deref.

Principal is all-strings today, so a shallow copy IS a deep copy. The method exists so that the day it gains a slice or map field, every site stays correct together rather than silently becoming an aliasing bug.

func (*Principal) IdentityWellFramed

func (p *Principal) IdentityWellFramed() bool

IdentityWellFramed reports whether p's authority-bearing components are free of the reserved owner-key separator. It is the EXPORTED form of the single delimiter-safety rule, so a consumer outside this package — notably the request edge's re-validation of an already-verified principal — enforces the same rule the owner-key derivations depend on instead of hand-rolling it.

A nil p is not well framed: absent identity is a nil *Principal, and callers that admit ownerless operation test for nil themselves rather than routing it through here.

func (*Principal) SameIdentity

func (p *Principal) SameIdentity(other *Principal) bool

SameIdentity reports whether p and other carry the same verified owner identity. Ownership is the exact (Issuer, Subject) pair: metadata such as GrantType and Name is deliberately excluded, and issuer spelling is not normalized. An absent principal never identifies an owner.

type RequestManifestPayload

type RequestManifestPayload struct {
	Provider        string                   `json:"provider,omitempty"`
	Model           string                   `json:"model,omitempty"`
	ReasoningEffort string                   `json:"reasoning_effort,omitempty"`
	ContextWindow   int                      `json:"context_window,omitempty"`
	ToolNames       []string                 `json:"tool_names"`
	ToolDecisions   []RequestToolDecision    `json:"tool_decisions"`
	MessageCount    int                      `json:"message_count"`
	MessageBytes    int                      `json:"message_bytes"`
	Prompt          []RequestPromptComponent `json:"prompt"`
}

RequestManifestPayload is a content-free description of the exact neutral request handed to the provider. It retains only closed provenance/decision tokens, identifiers, and byte/count metadata; it deliberately carries no content digest that could become an offline oracle.

type RequestPromptComponent

type RequestPromptComponent struct {
	Kind       string `json:"kind"`
	Provenance string `json:"provenance"`
	Bytes      int    `json:"bytes"`
}

RequestPromptComponent identifies one prompt component without retaining it. Kind and Provenance are closed tokens; Bytes is the encoded component size.

type RequestToolDecision

type RequestToolDecision struct {
	Name     string `json:"name"`
	Source   string `json:"source"`
	Decision string `json:"decision"`
}

RequestToolDecision records a decision the final request assembly actually observed. Name is a model-visible tool identifier; Source and Decision are closed tokens.

type ResultPayload

type ResultPayload struct {
	// Stop is the reason the run ended.
	Stop StopReason
	// Text is the final assistant text, if any.
	Text string
	// Usage is the cumulative token accounting for the run.
	Usage Usage
	// Error carries the failure detail when Stop is StopError (empty otherwise).
	// It surfaces the error the loop would otherwise drop so callers (the demo,
	// API clients) can see why a run failed instead of an opaque "error".
	Error string
	// Permanent is the compatibility projection of Disposition==Permanent.
	Permanent bool
	// Disposition classifies whether replaying the failed model request is safe.
	Disposition RetryDisposition
	// Progress records how far the terminal model stream advanced semantically.
	Progress StreamProgress
}

ResultPayload is the terminal payload carried by an EvResult Event.

type RetryDisposition

type RetryDisposition uint8

RetryDisposition is the provider-neutral causal classification of a model stream failure. Its zero value is conservative: unknown is not safe to replay and is not presented as permanent.

const (
	RetryDispositionUnknown RetryDisposition = iota
	RetryDispositionRetryable
	RetryDispositionPermanent
)

RetryDispositionUnknown is conservative; Retryable and Permanent are explicit.

func (RetryDisposition) Valid

func (d RetryDisposition) Valid() bool

Valid reports whether d belongs to the closed retry vocabulary. Unknown is a valid conservative zero value for backward compatibility.

type Role

type Role string

Role identifies the author of a Message in the conversation.

const (
	// RoleSystem is the system prompt author.
	RoleSystem Role = "system"
	// RoleUser is the human/client author.
	RoleUser Role = "user"
	// RoleAssistant is the model author.
	RoleAssistant Role = "assistant"
	// RoleTool is a tool-result author.
	RoleTool Role = "tool"
)

type SchedulePayload

type SchedulePayload struct {
	// ScheduleName is the schedule that fired / was skipped / failed (the
	// ScheduleSpec.Name foreign key).
	ScheduleName string
	// FireID is the per-fire session id (the same id ScheduleFire.ID and
	// ScheduleFire.SessionID carry — the fire's id IS its session id on the wire,
	// the FireNowResponse.fire_id == session_id contract).
	FireID string
	// SessionID is the session the fire ran as. It equals FireID for a fired
	// fire; it is empty for a skipped fire (no session was created).
	SessionID SessionID
	// Kind is the event kind: "fired" / "skipped" / "failed". String passthrough.
	Kind string
	// Stop is the terminal stop reason of the fire's run (a StopReason). Empty
	// for a skipped fire (no run happened) and for a fired fire that has not yet
	// completed.
	Stop StopReason
	// Err is the error string if the fire's run failed (Kind="failed"), empty
	// otherwise. It is a flat string (no structured error crosses) so a consumer
	// can render it without importing the run's error types.
	Err string
}

SchedulePayload is the structured detail carried by the schedule.* events (EvScheduleFired / EvScheduleSkipped / EvScheduleFailed). It mirrors the schedule.proto SchedulePayload one-for-one. It is emitted from COMPOSITION (the scheduler) at fire time, NOT the agent loop (engine/agent never imports port.ScheduleStore — the tick loop, cron parsing, misfire policy, and leader-lease acquisition all live in composition). For v1 it is delivered to the fire session's durable EventLog ONLY (pull-only via GetFire/ListFires); a live broadcast stream is a future phase. Skipped fires (no session id) are dropped from the durable log (session-keyed) and surface only via the operator diagnostic.

STRING-PASSTHROUGH DISCIPLINE: Kind / Stop / Err are plain strings (the EvNoProgress / StopBudget precedent — no proto enum, no closed set a later value would silently mis-classify). Kind is "fired" / "skipped" / "failed"; Stop is a session.StopReason; Err is a flat error string.

type Session

type Session struct {
	// ID identifies this session.
	ID SessionID
	// State is the current lifecycle state.
	State State
	// Mode is the permission posture.
	Mode PermissionMode
	// Conversation is the model-visible history.
	Conversation *Conversation
	// Limits are the configured stop conditions.
	Limits Limits
	// Counters are the running totals for stop-condition evaluation.
	Counters Counters
	// Usage is the CUMULATIVE token accounting for the logical run. It is the value
	// the MaxRunTokens budget brake (StopBudget) is evaluated against, so it is
	// persisted into the snapshot and the brake reads it DIRECTLY (the loop keeps a
	// separate zero-based per-run delta for the EvResult figure; there is no seed) —
	// the budget therefore survives reopen/restart instead of re-granting a full
	// fresh allowance every time the session continues. Mutate it through RecordUsage
	// (accumulate) or ResetUsage (the explicit fresh-allowance reset). CRITICAL:
	// unlike Counters, it is NOT cleared by resetToIdle (see the comment there).
	Usage Usage
	// EnvironmentRef is the sole durable identity of the execution environment.
	// It is minted by the placement provider and must be valid before persistence
	// or execution. Resolution to live capabilities belongs to composition.
	EnvironmentRef EnvironmentRef
	// Placement is safe display-only metadata minted by the placement provider.
	// It is persisted for public inventory projection but never used to bind or
	// reattach an environment.
	Placement PlacementMetadata
	// Profile is an opaque tool-surface profile label (e.g. "" for the default
	// filesystem profile, "no-fs" for the no-filesystem one). The aggregate STORES
	// it but never interprets it: the meaning lives entirely in composition, like
	// ProviderID and ModelID. It is a write-once creation label set by the
	// composition root after New (no mutator); persisting it lets a restarted
	// process rebuild the same tool surface. The exact EnvironmentRef remains the
	// sole placement identity.
	Profile string
	// ProviderID and ModelID are the opaque neutral provider+model selector pair
	// this session was bound to. The aggregate STORES them but never interprets
	// them — the ProviderSelector type and all resolution stay in composition; only
	// these two opaque strings cross into the domain. Persisting them lets a
	// restarted process re-derive the same per-session engine via the factory
	// instead of falling to the default-provider floor. They are write-once
	// creation labels set by composition after New (no mutator). The empty pair
	// means "server default".
	ProviderID string
	ModelID    string
	// ReasoningEffort is the opaque neutral reasoning-effort token (ADR 0055) this
	// session was bound to ("" = unset, the provider default). The aggregate STORES
	// it but never interprets it — the neutral vocabulary, normalisation, per-provider
	// clamp, and adapter re-mint all live in composition; only this opaque string
	// crosses into the domain (the same inert-label posture as ProviderID/ModelID).
	// Persisting it lets a restarted process re-mint the SAME per-session engine via
	// the factory instead of falling to the operator default. Write-once creation
	// label set by the composition root after New (no mutator).
	ReasoningEffort string
	// DebugMCPServers and DebugMCPTools are inert, durable labels for a debug
	// session's explicitly selected server-global MCP mount. DebugMCPTools is the
	// exact creation-time tool-name ceiling; rehydration requires exact equality
	// with the selected servers' current direct tool set.
	DebugMCPServers []string
	DebugMCPTools   []string
	// DebugTargetFingerprint binds a debug session to the target incarnation that
	// was authorized at creation. It is internal evidence only and is never
	// projected to clients or the model.
	DebugTargetFingerprint string
	// Title is a human-readable session label seeded ONCE from the first genuine
	// user prompt (via SetTitle, called from the loop's recordPrompt), clamped to
	// maxTitleRunes (120) runes. Subsequent prompts do NOT overwrite it (set-once).
	// It is persisted in the snapshot (an inert stored label, like Profile), and
	// read-time consumers (the lazy ListSessions/GetSession fallback, the
	// event-sourced Fold) use session.IsGenuineUserPrompt to derive it when empty.
	// It is "" for a session with no genuine prompt yet (lazy display-time
	// fallback applies). The aggregate never interprets it.
	Title string
	// TitleProvenance records whether Title came from the first genuine prompt or
	// an explicit operator rename. The zero value means legacy/unknown.
	TitleProvenance TitleProvenance
	// Owner is the verified caller this session is attributed to, or nil when the
	// session is ownerless (a pre-ship snapshot, or a deployment with no identity
	// verifier wired). It is a WRITE-ONCE label stamped through RestoreLabels —
	// never a public setter, and never fabricated when identity is absent
	// (ADR 0204 decision 4). The aggregate STORES it and never interprets it: no
	// enforcement, no filtering, display + audit only.
	Owner *Principal
	// Authority is the derived authority payload stamped through BindAuthority
	// before the first runnable state. The private marker distinguishes a bound
	// empty capability set from a genuinely pre-feature legacy session.
	Authority Authority
	// Kind classifies the trusted producer and continuation posture. New creates
	// main sessions; delegated/scheduled producers use the validated constructors.
	Kind SessionKind
	// Relationship carries kind-specific durable lineage. It is empty for main
	// and legacy unknown sessions.
	Relationship SessionRelationship
	// CreatedAt is the creation timestamp.
	CreatedAt time.Time
	// contains filtered or unexported fields
}

Session is the aggregate root of the Agent Session context. All mutation of the conversation, counters, and lifecycle flows through its intention-revealing methods so the state machine and stop conditions always hold. Outside code holds a SessionID and reaches inner entities only through these methods, never through public setters.

func New

func New(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time) *Session

New constructs an idle Session with an empty conversation and an exact durable environment identity. Callers must provide a valid provider-minted reference; persistence and run entry enforce the same invariant.

func NewDebug

func NewDebug(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time, target SessionID, targetIncarnation IncarnationID) (*Session, error)

NewDebug constructs a validated diagnostic session bound to target. It starts with an empty conversation; target history is never copied into it.

func NewParallelBranch

func NewParallelBranch(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time, parent SessionID, parentIncarnation IncarnationID, call ToolCallID, branchIndex int) (*Session, error)

NewParallelBranch constructs a validated Parallel branch session.

func NewScheduled

func NewScheduled(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time, scheduleName string, origin SessionID, originIncarnation IncarnationID) (*Session, error)

NewScheduled constructs a validated scheduler-fire session.

func NewSubagent

func NewSubagent(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time, parent SessionID, parentIncarnation IncarnationID, call ToolCallID) (*Session, error)

NewSubagent constructs a validated Subagent child session.

func NewTeamMember

func NewTeamMember(id SessionID, mode PermissionMode, ref EnvironmentRef, limits Limits, createdAt time.Time, teamID, member string, parent SessionID, parentIncarnation IncarnationID) (*Session, error)

NewTeamMember constructs a validated team-member session. parent is optional for a directly-driven team and is present for a tool-driven team.

func (*Session) Abandon

func (s *Session) Abandon() error

Abandon returns a session STUCK in StateRunning to StateIdle after repairing the abandoned turn's history — the case where the process that was driving the run exited (crashed, was killed, lost its host) without ever reaching a terminal state, so the last persisted snapshot reads "running" forever (issue #475). Legal ONLY from StateRunning: a session actually paused on an ask belongs to Awaiting's own resume path, not this seam (abandoning it would discard a still-resolvable PendingAsk via resetToIdle), and every other state is already idle or has its own recovery seam.

A turn abandoned mid-dispatch may leave the trailing assistant message with tool calls that never received a result; closeOutInterruptedTurn appends a synthetic error result per orphan (with the abandonment-accurate abandonCloseOutMessage — never the cancellation or failure wording, neither of which was observed here) so the replayed history stays provider-valid (no dangling tool_use / function_call) before the next prompt.

It is the sibling of Interrupt (cancelled→idle) and Recover (failed→idle): same reset (resetToIdle), separate method because its precondition and message differ. Callers are expected to have already established that no process is actually still driving the session (e.g. an age-horizon-gated staleness sweep) — Abandon itself performs no liveness check.

func (*Session) BeginRun

func (s *Session) BeginRun(runID string)

BeginRun stamps the opaque, host-minted identity of the run this session is about to drive (ADR 0249).

It is an UNGUARDED setter by design. Every other run-scoped mutator on this aggregate guards on State because it changes lifecycle meaning; this one changes only a label, and the run-entry seams that call it legitimately do so from several states (idle after a Reopen/Recover/Interrupt, or awaiting on the cross-process resume path). Guarding it would force each seam to re-derive a state check it has already done, for no invariant.

An EMPTY id is accepted and clears the stamp: a host that mints no run id (an in-memory embedder, a test) is byte-identical to the behaviour before ADR 0249.

func (*Session) BeginTurn

func (s *Session) BeginTurn() error

BeginTurn transitions the session into StateRunning at the start of a model call. It is legal from StateIdle (first turn) or StateRunning (a follow-up model call within the same active run). It increments the turn counter. If a turn limit is already reached it returns ErrIllegalTransition is NOT used; callers should consult StopReason before beginning a turn.

func (*Session) BindAuthority

func (s *Session) BindAuthority(authority Authority) error

BindAuthority attaches a derived authority payload before the session becomes runnable. It is write-once and copies its capability set so callers cannot mutate it.

func (*Session) BoundAuthority

func (s *Session) BoundAuthority() (Authority, bool)

BoundAuthority returns the copied durable authority payload and whether this session was explicitly bound. An absent payload is a documented pre-feature legacy session, never an empty bound set.

func (*Session) Cancel

func (s *Session) Cancel() error

Cancel transitions the session to StateCancelled. It is legal from any non-terminal state.

func (*Session) Complete

func (s *Session) Complete() error

Complete marks a successful terminal end of the run. It is legal from any non-terminal state and records StopEndTurn unless a stop reason is already set.

func (*Session) Fail

func (s *Session) Fail() error

Fail transitions the session to StateFailed with StopError. It is legal from any non-terminal state.

A failed session recovers through Recover (failed→idle, history-repaired — issue #51), so a transient provider failure no longer bricks the session permanently. Historical note: the trigger that originally bricked sessions here was compaction emitting unpaired history (an orphaned tool result → provider HTTP 400 → Fail), which the compactors independently prevent by snapping the kept-tail boundary past leading tool results and self-validating via ValidateToolPairing.

func (*Session) FailedStepRetryPending

func (s *Session) FailedStepRetryPending() (RetryDisposition, StreamProgress, bool)

FailedStepRetryPending reports the durable retry intent and its original failure facts. The marker remains set while the retry model step is running.

func (*Session) FailureMetadata

func (s *Session) FailureMetadata() (RetryDisposition, StreamProgress)

FailureMetadata returns typed terminal facts only while the session is failed.

func (*Session) FailurePermanence

func (s *Session) FailurePermanence() bool

FailurePermanence reports whether the failure that landed this session in StateFailed was marked as permanent. It returns false for any state other than StateFailed.

func (*Session) Incarnation

func (s *Session) Incarnation() IncarnationID

Incarnation returns this aggregate's immutable incarnation identity.

func (*Session) Interrupt

func (s *Session) Interrupt() error

Interrupt recovers a CANCELLED session to StateIdle after repairing the interrupted turn's history. Legal ONLY from StateCancelled. Mirrors Reopen's reset (clears stop + pending, resets Counters) but is a SEPARATE method because its precondition and history-repair invariant differ.

A turn cancelled mid-dispatch may leave the trailing assistant message with tool calls that never received a result; closeOutInterruptedTurn appends a synthetic error result per orphan so the replayed history stays provider-valid (no dangling tool_use / function_call) before the next prompt. Reopen stays completed-only; a FAILED session recovers through Recover.

func (*Session) LastError

func (s *Session) LastError() string

LastError reports the terminal failure cause a StateFailed session was stamped with via RecordLastError. It is an unguarded read (returns "" for any state); callers that need the state guard should check State == StateFailed first. The cause is already normalised (one line, rune-clamped) at stamp time.

func (*Session) LimitTripped

func (s *Session) LimitTripped() (StopReason, bool)

LimitTripped reports the stop reason implied by the configured Limits and current Counters, and true when a limit is reached. It is a pure predicate that ignores any recorded terminal reason; it never mutates state.

func (*Session) PauseForApproval

func (s *Session) PauseForApproval(ask PendingAsk) error

PauseForApproval suspends a running turn on a permission ask, transitioning to StateAwaiting. It is legal only while running.

func (*Session) PendingAsk

func (s *Session) PendingAsk() (PendingAsk, bool)

PendingAsk returns the ask the session is blocked on and true when in StateAwaiting; otherwise it returns the zero value and false.

func (*Session) PrepareFailedStepRetry

func (s *Session) PrepareFailedStepRetry() error

PrepareFailedStepRetry consumes an eligible failed attempt into a durable, prompt-free retry intent. It repairs any interrupted tool-call tail and resets per-run counters while preserving the failed attempt's typed retry facts. Calling it again on an already-prepared idle session is idempotent.

func (*Session) RecordAssistant

func (s *Session) RecordAssistant(m Message) error

RecordAssistant appends the assistant message produced by a model call to the conversation. It is legal only while running.

func (*Session) RecordFailureMetadata

func (s *Session) RecordFailureMetadata(disposition RetryDisposition, progress StreamProgress) error

RecordFailureMetadata stamps typed provider retry facts on a failed session.

func (*Session) RecordFailurePermanence

func (s *Session) RecordFailurePermanence(permanent bool) error

RecordFailurePermanence stamps whether the failure that landed this session in StateFailed is permanent (unrecoverable, e.g. a fatal configuration error) versus transient (retryable, e.g. a provider 5xx). It is legal ONLY when State==StateFailed (mirroring the guard style of Fail/Recover: an idle session or a non-failed terminal returns ErrIllegalTransition). The flag is cleared on any transition out of StateFailed (resetToIdle via Recover/Interrupt/Reopen), so a healed session never keeps a stale permanence marker.

func (*Session) RecordLastError

func (s *Session) RecordLastError(cause string) error

RecordLastError stamps the terminal failure CAUSE (the loop's session.ResultPayload.Error) onto a StateFailed session so it persists on the snapshot independent of the parent's subagent.end emit (issue #332). It is the Permanent-analog for the failure detail itself, mirroring the guard style of RecordFailurePermanence: legal ONLY when State==StateFailed (an idle or non-failed terminal returns ErrIllegalTransition). The cause is normalised to ONE line and clamped to maxSnapshotErrorRunes (mirroring the event-side subagentCausePayload normaliser so the snapshot and event fields agree). The field is cleared on any transition out of StateFailed (resetToIdle via Recover/Reopen/Interrupt), so a healed session never keeps a stale cause.

func (*Session) RecordToolResults

func (s *Session) RecordToolResults(results []ToolResult) error

RecordToolResults appends tool-result messages to the conversation and updates the tool-call and consecutive-failure counters. It is legal only while running. Any successful result resets the consecutive-failure run; each error result extends it.

func (*Session) RecordUsage

func (s *Session) RecordUsage(u Usage) error

RecordUsage accumulates the token usage of a model call onto the aggregate's cumulative Usage. It is the intention-revealing seam the loop uses instead of poking the public Usage field, mirroring RecordAssistant/RecordToolResults: it is legal ONLY while running (a usage record belongs to an in-flight turn). The loop calls it each turn (alongside its own zero-based per-run delta); the budget brake reads this cumulative value, so the next Save persists the accumulated spend. Unlike the Counters, Usage is deliberately NOT reset by resetToIdle so the MaxRunTokens budget survives reopen/restart (see resetToIdle).

func (*Session) RecordUserPrompt

func (s *Session) RecordUserPrompt(text string, instructions []Message) error

RecordUserPrompt appends a user prompt to the conversation through the aggregate root, optionally preceded by discovered project-instruction messages (AGENTS.md / CLAUDE.md). It is the intention-revealing seam the loop uses instead of poking the Conversation directly, so all history mutation flows through the root. It is legal from any non-terminal state (a prompt may be the first message while idle, or a follow-up while running).

func (*Session) RecordUserPromptWithParts

func (s *Session) RecordUserPromptWithParts(text string, parts []Content, instructions []Message) error

RecordUserPromptWithParts is the multimodal sibling of RecordUserPrompt: it records a user prompt carrying flattened text PLUS non-text media parts (image/audio), optionally preceded by discovered project-instruction messages. text may be empty when parts carries the content. It shares RecordUserPrompt's state guard and instruction-prepend behaviour; the only difference is the recorded user message carries Parts. It is legal from any non-terminal state.

func (*Session) RecordedStopReason

func (s *Session) RecordedStopReason() (StopReason, bool)

RecordedStopReason returns the terminal stop reason explicitly recorded on the session (via Stop/Cancel/Fail/Complete) and true when one is set. It performs NO limit derivation, so it is a faithful witness of the recorded terminal state — use it for persistence and round-tripping rather than StopReason.

func (*Session) Recover

func (s *Session) Recover() error

Recover returns a FAILED session to StateIdle after repairing the failed turn's history, so a transient provider failure (an upstream 5xx that exhausted the resilience layer's retries) degrades to "retryable" instead of permanently bricking the session (issue #51). Legal ONLY from StateFailed.

A turn that failed mid-stream / mid-dispatch may leave the trailing assistant message with tool calls that never received a result; closeOutInterruptedTurn appends a synthetic error result per orphan (with the failure-accurate recoverCloseOutMessage — never the cancellation wording, which would falsely attribute a user action) so the replayed history stays provider-valid (no dangling tool_use / function_call). Recovery makes RETRY POSSIBLE, not guaranteed — if the underlying cause persists (a permanent auth/config failure, or a history poisoned in a way the pairing repair cannot fix), the retried run fails again, which is acceptable: the user keeps the conversation context and can retry or clear.

It is the sibling of Interrupt (cancelled→idle) and Reopen (completed→idle): same reset (resetToIdle), separate method because its precondition differs. Reopen stays completed-only; Interrupt stays cancelled-only.

func (*Session) Rehome

func (s *Session) Rehome(ref EnvironmentRef) error

Rehome replaces the exact environment identity of an idle delegated session after a fresh child environment has been minted. The live environment used by the caller must carry the same ref.

func (*Session) RenameTitle

func (s *Session) RenameTitle(text string) error

RenameTitle replaces the title at an operator's explicit request. Unlike SetTitle it is intentionally not set-once. Blank titles are rejected and the shared title helper applies the same whitespace and length rules as prompt titles.

func (*Session) Reopen

func (s *Session) Reopen() error

Reopen returns a successfully-completed session to StateIdle so it can accept a new user prompt and run another turn-loop, preserving the conversation history. It is the multi-turn / long-lived-teammate continuation seam: the agent loop always drives a session to a terminal state within a single Run, so a session that must receive another prompt later — a team teammate awaiting a message, an interactive multi-turn chat — needs an explicit, guarded re-open rather than a fresh session that would lose its history.

It is legal ONLY from StateCompleted (a clean end-of-run). A FAILED run recovers through Recover and a CANCELLED run through Interrupt (both also repair the interrupted turn's history), NOT here; and a non-terminal session is already runnable — so every other state returns ErrIllegalTransition. Reopen clears the recorded stop reason and any pending ask, and RESETS the per-run Counters to zero so the configured Limits bound EACH prompt's work, matching their single-run meaning rather than silently becoming a session-lifetime cap. A caller that wants a lifetime budget (e.g. a team supervisor bounding total turns across a teammate's life) must enforce it separately. Conversation, Mode, Limits, EnvironmentRef, and Placement are preserved.

func (*Session) ReplaceHistory

func (s *Session) ReplaceHistory(messages []Message) error

ReplaceHistory atomically replaces the conversation history with messages. It is the compaction seam: the loop hands it the compacted message slice so the replacement flows through the root rather than mutating Conversation.Messages directly. It is legal only while running, when compaction occurs.

As the aggregate-level guard it REJECTS a slice that is not tool-pairing-valid (an orphaned tool result or a dangling tool call) via ValidateToolPairing: such a history draws a provider HTTP 400 on the next replay and would drive the run to StateFailed. Refusing it here keeps the invariant that the conversation is always provider-replayable, independent of which compactor produced the slice.

func (*Session) ReplaceHistoryAtBoundary

func (s *Session) ReplaceHistoryAtBoundary(messages []Message) error

ReplaceHistoryAtBoundary atomically replaces conversation history while no turn is in flight. It is the manual-compaction seam: legal from idle and all terminal states, and rejected from running or awaiting so an out-of-band rewrite cannot race a model turn or invalidate a pending approval. It changes only Conversation.Messages; lifecycle state and all other aggregate metadata are preserved.

The replacement must satisfy ValidateToolPairing so the resulting history is provider-replayable in both directions.

func (*Session) ResetUsage

func (s *Session) ResetUsage() error

ResetUsage zeroes the aggregate's cumulative Usage, granting a fresh MaxRunTokens allowance for the next run. It is the EXPLICIT counterpart to the deliberate non-reset in resetToIdle: because Usage survives Reopen/Interrupt/ Recover (so the budget brake bounds the whole logical run across restart), a caller that genuinely wants a fresh budget for a NEW phase of work must say so through this intention-revealing seam rather than poking the public Usage field (the aggregate-mutation discipline RecordUsage established).

It is legal from any NON-running state (idle, completed, or the other terminals) — NOT while running, where it would discard an in-flight turn's spend mid-budget and race the loop's own RecordUsage. The SOLE caller today is the team supervisor's synthesise step (engine/agent/teamsupervisor.go): a lead whose working run was stopped by its MaxRunTokens must still produce the team's synthesis deliverable, so the supervisor resets the lead's accumulator between the working drive and the synthesis drive (the synthesis spend is then folded into the team outcome separately). Returns ErrIllegalTransition from running.

func (*Session) RestoreFailedStepRetryPending

func (s *Session) RestoreFailedStepRetryPending(disposition RetryDisposition, progress StreamProgress) error

RestoreFailedStepRetryPending restores additive snapshot retry intent without widening sessnap.RestoreState. It is legal only on an idle or running aggregate.

func (*Session) RestoreIncarnation

func (s *Session) RestoreIncarnation(inc IncarnationID) error

RestoreIncarnation replaces New's provisional random incarnation while an aggregate is still idle. Empty persisted values become a deterministic, prefix-disjoint legacy identity.

func (*Session) RestoreLabels

func (s *Session) RestoreLabels(owner *Principal, authority Authority) error

RestoreLabels stamps the write-once owner label and, when present, restores a bound authority payload before the first runnable state.

func (*Session) RestoreSessionMetadata

func (s *Session) RestoreSessionMetadata(kind SessionKind, rel SessionRelationship) error

RestoreSessionMetadata validates and restores persisted creation metadata. An empty kind is legacy data and is restored as SessionKindUnknown.

func (*Session) ResumeWith

func (s *Session) ResumeWith() (PendingAsk, error)

ResumeWith clears the pending ask and returns the session to StateRunning so the loop can continue. It is legal only while awaiting; it returns ErrNoPendingAsk otherwise. The resolved ask is returned for reference. The caller (the loop) owns the permission decision and acts on it (allow → execute, deny → feed the reason back to the model); the aggregate only reconciles its own lifecycle, so it deliberately takes no governance type and keeps session a clean domain leaf.

func (*Session) RunID

func (s *Session) RunID() string

RunID reports the run identity stamped by BeginRun, or "" if none was.

The awaiting-resume path reads it to CONTINUE a run rather than start a new one; that reuse is the whole reason the value is persisted.

func (*Session) SeedHistory

func (s *Session) SeedHistory(messages []Message) error

SeedHistory atomically seeds a FRESH (idle) session's conversation history with messages. It is the IDLE-state sibling of ReplaceHistory (which is running-only, the compaction seam): SeedHistory exists for the fork:true Subagent child, whose session is freshly constructed and must be primed with a deep copy of the parent conversation BEFORE the first turn begins. It is the aggregate-mutation seam the agent package uses instead of poking Conversation.Messages directly, so the history-mutation discipline (and the pairing guard) holds.

It is legal ONLY from StateIdle — a fresh, not-yet-run child. Seeding a session that has already begun a turn (or a terminal one) is a programming error and returns ErrIllegalTransition. As the aggregate-level guard it REJECTS a slice that is not tool-pairing-valid via ValidateToolPairing (an orphaned tool result or a dangling tool call would draw a provider HTTP 400 on the first replay), so the conversation is always provider-replayable regardless of what the caller supplies.

func (*Session) SetMode

func (s *Session) SetMode(mode PermissionMode) error

SetMode changes the session's permission posture. It is the intention-revealing seam an out-of-band control surface (e.g. ACP session/set_mode) uses to switch between default/plan/acceptEdits, so the change flows through the aggregate rather than poking the public Mode field.

It is legal ONLY while the session is NOT actively progressing a turn — i.e. from StateIdle or any terminal state, but NOT from StateRunning or StateAwaiting. Changing the permission posture mid-turn would race the loop's own permission evaluation (plan mode hard-denies mutations; acceptEdits auto-allows them) against tool dispatch already in flight, so a mid-run switch is rejected with ErrIllegalTransition. A control surface that receives a set_mode while running must defer it (apply on the next prompt). Setting the mode it already has is a no-op success.

func (*Session) SetTitle

func (s *Session) SetTitle(text string)

SetTitle seeds Session.Title from a user prompt, ONCE: a non-empty text on a session whose Title is still "" sets it (clamped via ClampTitle); any later call is a no-op (subsequent prompts do NOT overwrite the first). The caller — the loop's recordPrompt (the GENUINE prompt site) — MUST ensure it passes only a genuine user prompt: the aggregate enforces set-once, the caller enforces genuineness. An empty/whitespace-only text leaves Title=="" (a multimodal-only prompt with no text, or an empty prompt, does not seed). It is NOT a state transition (legal from any state) — Title is an inert stored label.

func (*Session) Stop

func (s *Session) Stop(reason StopReason) error

Stop marks a successful terminal end carrying an explicit stop reason (e.g. a limit was reached). It is legal from any non-terminal state.

func (*Session) StopReason

func (s *Session) StopReason() (StopReason, bool)

StopReason reports why the run should stop. It is a DERIVED predicate: it returns the recorded terminal reason if one is set, otherwise it computes a limit-tripped reason from the configured Limits and current Counters. It does not mutate state. The loop consults it as a pre-turn guard.

Because it conflates the recorded reason with a limit derivation, it is NOT a faithful witness of terminal state. Persistence and any caller that needs the exact reason explicitly recorded via Stop/Cancel/Fail/Complete must use RecordedStopReason instead.

type SessionID

type SessionID string

SessionID uniquely identifies a session. Outside code holds a SessionID and reaches inner entities only through the Session aggregate root.

type SessionKind

type SessionKind string

SessionKind classifies the trusted producer and continuation posture of a durable session. The vocabulary is closed; unrecognised values are invalid.

const (
	// SessionKindUnknown is the fail-closed classification for legacy data whose
	// producer metadata was not persisted.
	SessionKindUnknown SessionKind = "unknown"
	// SessionKindMain is a public interactive chat, peer fork, or carryover.
	SessionKindMain SessionKind = "main"
	// SessionKindScheduled is an autonomous scheduler fire.
	SessionKindScheduled SessionKind = "scheduled"
	// SessionKindSubagent is a child created by the Subagent tool.
	SessionKindSubagent SessionKind = "subagent"
	// SessionKindParallelBranch is one child branch of the Parallel tool.
	SessionKindParallelBranch SessionKind = "parallel_branch"
	// SessionKindTeamMember is a member driven by a team Supervisor.
	SessionKindTeamMember SessionKind = "team_member"
	// SessionKindDebug is a separate diagnostic session bound to one target session.
	SessionKindDebug SessionKind = "debug"
)

type SessionRelationship

type SessionRelationship struct {
	ScheduleName           string        `json:"schedule_name,omitempty"`
	OriginSessionID        SessionID     `json:"origin_session_id,omitempty"`
	OriginIncarnation      IncarnationID `json:"origin_incarnation,omitempty"`
	ParentSessionID        SessionID     `json:"parent_session_id,omitempty"`
	ParentIncarnation      IncarnationID `json:"parent_incarnation,omitempty"`
	CallID                 ToolCallID    `json:"call_id,omitempty"`
	BranchIndex            *int          `json:"branch_index,omitempty"`
	TeamID                 string        `json:"team_id,omitempty"`
	MemberName             string        `json:"member_name,omitempty"`
	DebugTargetID          SessionID     `json:"debug_target_id,omitempty"`
	DebugTargetIncarnation IncarnationID `json:"debug_target_incarnation,omitempty"`
}

SessionRelationship carries the kind-specific relationship metadata of a durable session. ValidateSessionMetadata defines which fields are required and forbidden for each SessionKind.

type State

type State string

State is the session lifecycle state. The state machine is:

idle → running → awaiting → running → completed

with Cancel permitted from any non-terminal state and Fail from any non-terminal state. completed, failed, and cancelled are terminal. Each terminal state has its own recovery seam back to idle: Reopen (from completed), Interrupt (from cancelled, also repairing the interrupted turn's history), Recover (from failed, same history repair — issue #51), and Abandon (from running, same history repair, for a session left running by a process that exited mid-turn — issue #475).

const (
	// StateIdle is the initial state: created, no turn running.
	StateIdle State = "idle"
	// StateRunning means a turn is in flight.
	StateRunning State = "running"
	// StateAwaiting means the loop is paused on a permission "ask".
	StateAwaiting State = "awaiting"
	// StateCompleted is a terminal success state.
	StateCompleted State = "completed"
	// StateFailed is a terminal failure state.
	StateFailed State = "failed"
	// StateCancelled is a terminal cancellation state.
	StateCancelled State = "cancelled"
)

func (State) IsTerminal

func (s State) IsTerminal() bool

IsTerminal reports whether no further transitions are possible from s.

type SteerPayload

type SteerPayload struct {
	// Text is the committed steer text, byte-identical to the user message
	// recorded into history and replayed to the model (the recorded == streamed
	// == model-view invariant).
	Text string
	// Parts carries the committed non-text media in fragment order.
	Parts []Content
}

SteerPayload is the structured detail carried by an EvSteer Event: the committed operator steer the turn-boundary drain just recorded into the conversation. It is the client-facing ECHO of the drain — the engine is authoritative on which appended bundle drained, so Text is the version ACTUALLY drained (never a stale draft the client may still be holding).

The text is the run's OWN operator input (the top-level run's steer), never child content — the same posture as UserPromptPayload (gauntlet #7): a child run's steer, if any, rides the child stream, never the parent's.

type StopReason

type StopReason string

StopReason explains why a run stopped. It is carried on terminal events and in the LLM provider's terminal chunk.

const (
	// StopNone is the zero value: the run has not stopped.
	StopNone StopReason = ""
	// StopEndTurn means the model finished without requesting more tools.
	StopEndTurn StopReason = "end_turn"
	// StopMaxTurns means the MaxTurns limit was reached.
	StopMaxTurns StopReason = "max_turns"
	// StopMaxToolCalls means the MaxToolCalls limit was reached.
	StopMaxToolCalls StopReason = "max_tool_calls"
	// StopMaxConsecutiveFailures means too many tool calls failed in a row.
	StopMaxConsecutiveFailures StopReason = "max_consecutive_failures"
	// StopCancelled means the run was cancelled by the client or ctx.
	StopCancelled StopReason = "cancelled"
	// StopError means the run failed with an unrecoverable error.
	StopError StopReason = "error"
	// StopNoProgress means the model produced NEITHER tool calls NOR meaningful text
	// across the bounded continuation-nudge budget; the run ended without a
	// deliverable. It is a CLEAN terminal (not StopError — nothing failed), routed
	// through the same completed path as StopEndTurn, so the session ends COMPLETED
	// and stays Reopen-recoverable. It is distinguishable from StopEndTurn so a
	// client/team can tell "the model went silent" from "the model finished". It maps
	// to the proto stop string verbatim (no proto enum; the wire stop field is a
	// string passthrough).
	StopNoProgress StopReason = "no_progress"
	// StopBudget means the run's cumulative token usage crossed the configured
	// loop-level ceiling (Deps.MaxRunTokens), checked at a turn boundary. It is the
	// shared runaway brake serving every delegation path (main + Subagent + Team + Fork):
	// each engine inherits the ceiling and a per-call override may TIGHTEN it. Like
	// StopNoProgress it is a CLEAN terminal (not StopError — nothing failed), routed
	// through the same completed path as StopEndTurn, so the session ends COMPLETED and
	// stays Reopen-recoverable. The check is at the turn boundary (never mid-stream), so
	// the in-flight turn always completes and the no-replay-after-first-chunk invariant
	// holds. It maps to the proto stop string verbatim (no proto enum; the wire stop
	// field is a string passthrough, exactly like StopNoProgress).
	StopBudget StopReason = "budget"
	// StopTimeout means a per-fire wall-clock deadline expired (issue #386, the
	// in-flight scheduled-fire state). Like StopBudget it is a CLEAN terminal
	// (not StopError — nothing failed, the fire ran out of its allotted time), a
	// budget exhaustion rather than a fault or cancel, routed through the same
	// completed path as StopEndTurn, so the session ends COMPLETED and stays
	// Reopen-recoverable. It is distinct from a per-call Subagent time-budget
	// timeout (which lands StopCancelled on the child and renders as a tool
	// error); StopTimeout is the scheduled-fire wall-clock deadline, checked at a
	// turn boundary exactly like StopBudget. It maps to the proto stop string
	// verbatim (no proto enum; the wire stop field is a string passthrough, exactly
	// like StopNoProgress / StopBudget).
	StopTimeout StopReason = "timeout"
	// StopStructuredOutput means a structured-output (output_schema) child run
	// exhausted its bounded SubmitResult validation-retry budget without ever
	// producing a schema-valid payload. Like StopNoProgress / StopBudget it is a
	// CLEAN terminal (not StopError — the run did not crash, it just failed to
	// satisfy the requested schema), routed through the completed path so the session
	// ends COMPLETED and stays Reopen-recoverable. The Subagent tool renders it as a
	// model-visible tool error carrying the last validation failure, so the failure
	// reaches the model (never only a log line). It maps to the proto stop string
	// verbatim (no proto enum; the wire stop field is a string passthrough, exactly
	// like StopNoProgress / StopBudget).
	StopStructuredOutput StopReason = "structured_output"
	// StopPlanApproved means the operator approved a presented plan (issue #206:
	// the plan-approval gate). Like StopNoProgress / StopBudget / StopStructuredOutput
	// it is a CLEAN terminal (not StopError — nothing failed; the approval is a
	// success outcome), routed through the completed path so the session ends COMPLETED
	// and stays Reopen-recoverable. It is emitted when an operator approves a plan the
	// model presented via the PresentPlan tool; the session is typically flipped out of
	// plan mode and the next run continues the now-approved work. It maps to the proto
	// stop string verbatim (no proto enum; the wire stop field is a string passthrough,
	// exactly like StopNoProgress / StopBudget / StopStructuredOutput).
	StopPlanApproved StopReason = "plan_approved"
	// StopPlanIterate means the operator chose to iterate on a presented plan
	// (issue #206: the plan-approval gate). It is the iterate/deny sibling of
	// StopPlanApproved: a deny of a plan ask TERMINATES the plan run CLEANLY instead
	// of continuing in-turn, so the operator's NEXT typed prompt drives the
	// revision (the old behaviour kept the model working with no operator input).
	// Like StopPlanApproved it is a CLEAN non-error terminal (not StopError — the
	// operator asked for edits, nothing failed), routed through the completed path
	// so the session ends COMPLETED and stays Reopen-recoverable. It maps to the
	// proto stop string verbatim (no proto enum; the wire stop field is a string
	// passthrough, exactly like StopPlanApproved).
	StopPlanIterate StopReason = "plan_iterate"
)

type StreamProgress

type StreamProgress uint8

StreamProgress describes how far a streamed model attempt advanced semantically before its terminal outcome.

const (
	StreamProgressUnknown StreamProgress = iota
	StreamProgressPrecommit
	StreamProgressVisible
	StreamProgressComplete
)

StreamProgressUnknown is conservative; later values mark semantic boundaries.

func (StreamProgress) Valid

func (p StreamProgress) Valid() bool

Valid reports whether p belongs to the closed progress vocabulary. Unknown is a valid conservative zero value for backward compatibility.

type SubagentPayload

type SubagentPayload struct {
	// ParentCallID is the parent's Subagent tool-call id, used by clients to attribute
	// this event to the originating Subagent card. Set on all three kinds.
	ParentCallID string
	// ChildID is the child session id, distinguishing concurrent subagents. Set on
	// all three kinds.
	ChildID string
	// ChildIncarnation is internal durable correlation metadata. It is persisted in
	// the event log but deliberately omitted from client/model projections.
	ChildIncarnation IncarnationID
	// Goal is a short, plain-text label for the delegated task (the Subagent call's
	// description, or a truncation of its prompt). Set on EvSubagentStart only.
	Goal string
	// Background marks a detached-delivery child (`background: true` on the Subagent
	// call): the tool call returned an immediate started-result and the child keeps
	// working while the parent continues; its result is collected via
	// SubagentStatus. Set on EvSubagentStart only. NOT a fourth delegation family —
	// one boolean on subagent.* (the ChildActivity trip-wire stands).
	Background bool
	// RoutedCategory / RoutedModel are the OPT-IN semantic model router's classification
	// for this child (ADR 0031): the chosen CATEGORY label and the concrete MODEL id the
	// child was minted on. Set on EvSubagentStart ONLY when the router was wired AND
	// classified this delegation (both empty otherwise — no router, or a fail-soft miss
	// that inherited the default model). They are BARE METADATA — a category label and a
	// model id, never the task prompt or the classifier's reasoning — so they are
	// gauntlet-#7 safe (no child content, no model-influenced free text crosses). They
	// surface end-to-end: the session struct + a per-classification INFO (dispatch-path
	// `routeTask` closure) + a Build-once "router ACTIVE" fact + the proto/client wire
	// (`routed_category`/`routed_model` on the `Subagent` event payload, relayed through
	// the gRPC + HTTP relays and the mecatui client).
	RoutedCategory string
	RoutedModel    string
	// RoutingReason names WHY the router did NOT classify this delegation (EvSubagentStart
	// only): EMPTY on a routed hit (RoutedCategory/RoutedModel set), otherwise one of the
	// RoutingReason* gate constants (pinned-model / agent-def-pinned-model / resume / fork /
	// router-disabled / breaker-open / aborted) or a static miss code from the classifier
	// (the RouterMiss* values) or composition (e.g. category-selector-empty). Open callback
	// detail is reduced to a static/generic code before emission. It is BARE METADATA — a
	// bounded harness/composition reason code, never the task prompt
	// or the classifier's reasoning — so it is gauntlet-#7 safe (no child content, no
	// model-influenced free text crosses). Clamped at the emit site.
	RoutingReason string
	// Model is the concrete MODEL id the child ACTUALLY ran on (EvSubagentStart only),
	// set unconditionally — inherited default, agent-def pin, per-call `model` override,
	// or the opt-in router — independent of whether the router fired. It is BARE METADATA
	// — a model id, never child content — so it is gauntlet-#7 safe (no child content
	// crosses). When the router classified this delegation, Model == RoutedModel. It rides
	// the proto/client wire end-to-end (subagent.start: Subagent.model = field 13),
	// surfaced via the server mapper — see ADR 0035.
	Model string
	// ToolName is the name of a child tool that just ran. Set on EvSubagentTool
	// only. It is the tool NAME alone — never the child's tool args or result.
	ToolName string
	// IsError reports whether the child tool call failed. Set on EvSubagentTool
	// only.
	IsError bool
	// ToolCount is the running (EvSubagentTool) or final (EvSubagentEnd) number of
	// child tool calls STARTED. It is CUMULATIVE and stamped on EVERY projection, so
	// a client assigns it (never sums) with no InnerKind guard — it is always current,
	// never 0-after-positive. (Counted at the call, not the result, because a result
	// may never arrive on a cancel.)
	ToolCount int
	// Text is a BOUNDED preview of the child's message/result text — control-byte
	// scrubbed and rune-capped by clampPreview in engine/agent, never the raw,
	// unbounded body. Set on EvSubagentTool for the message.delta / result inner
	// kinds when a preview is available.
	Text string
	// Detail is a BOUNDED preview of a child tool call's args (tool.call) or a
	// tool result's body (tool.result) — control-byte scrubbed and rune-capped by
	// clampPreview in engine/agent, never the raw, unbounded args/result body. Set
	// on EvSubagentTool for the tool.call / tool.result inner kinds when a preview
	// is available.
	Detail string
	// InnerKind discriminates which inner child event kind the projection came from
	// (message.delta / tool.call / tool.result / result / turn.end) and which preview
	// fields it populates (Text/Detail). A turn.end projection carries NO Text/Detail;
	// it only advances Usage. A child's permission.ask is never projected.
	InnerKind EventType
	// Usage is the child run's cumulative provider-reported token accounting. Like
	// ToolCount it is CUMULATIVE and stamped on EVERY projection (zero until the
	// first turn.end), so a client assigns it with no InnerKind guard and a dropped
	// frame cannot drift the figure. It is provider truth only — the issue-#82
	// display-only estimate is never folded in.
	Usage Usage
	// Stop is the child run's terminal stop reason. Set on EvSubagentEnd only.
	Stop StopReason
	// Cause carries the child run's failure detail when Stop is StopError (empty
	// otherwise) — the mirror of ResultPayload.Error for the delegation projection.
	// Set on EvSubagentEnd ONLY.
	//
	// It is HARNESS/PROVIDER metadata — a transport or loop error string, or (for a
	// failure BEFORE the child run started, e.g. workspace isolation) the harness's own
	// error text — NOT child-authored model output, so it is gauntlet-#7 safe on the
	// same footing as Stop/Usage.
	//
	// It is LINE-ORIENTED by contract: every emit site normalises it through one helper
	// (whitespace collapsed to single spaces, then rune-clamped), because its consumers
	// are single-line surfaces — a fleet-roster row, an ACP status line, a log line — and
	// a provider error body routinely carries real newlines. A consumer renders it as-is
	// rather than re-deriving the collapse. (The MODEL-facing failure body keeps its
	// newlines; that is prose in a conversation, not a row.)
	Cause string
	// DurationMs is the child run's wall-clock duration in milliseconds
	// (best-effort). Set on EvSubagentEnd only.
	DurationMs int64
}

SubagentPayload is the REDACTED observability projection carried by the three subagent.* events (EvSubagentStart / EvSubagentTool / EvSubagentEnd). It is the ONLY information about a Subagent tool's child run that surfaces to clients.

REDACTION CONTRACT — bounded previews (ADR 0079, superseding the former metadata-only contract): on tool events it deliberately forwards BOUNDED previews of the child's content — Text is a bounded, clamped preview of the child's message text, Detail is a bounded, clamped preview of a child tool call's args or a tool result's body. Every such preview is CAPPED — a control-byte scrub plus a rune cap applied by clampPreview in engine/agent — so an unbounded args/result/message body can never be copied verbatim, and a child's permission.ask is DROPPED entirely: it is NEVER forwarded, so a pending-ask reason (which can quote secrets or sensitive args) never reaches the stream. The forwarding is CLIENT-ONLY: nothing here ever enters the parent Session's Conversation (gauntlet #7 unchanged) — only the Subagent tool's own ToolResult text does, so the LLM's context is untouched.

Which fields are set depends on the event kind:

  • EvSubagentStart: ParentCallID, ChildID, Goal, [RoutedCategory, RoutedModel, RoutingReason], Model.
  • EvSubagentTool: ParentCallID, ChildID, ToolName, IsError, ToolCount, and — when a preview is available — Text / Detail / InnerKind (which inner event kind the preview came from: message.delta / tool.call / tool.result / result).
  • EvSubagentEnd: ParentCallID, ChildID, ToolCount, Usage, Stop, [Cause], DurationMs.

type TeamFindingSnapshot

type TeamFindingSnapshot struct {
	// Member is the name of the member that recorded the finding.
	Member string
	// Body is a BOUNDED preview of the finding text (capped like every other
	// member-derived preview).
	Body string
}

TeamFindingSnapshot is one entry of the team findings ledger, projected onto the event stream so a watching client (the ctrl+a agents overlay) can see findings accrue. It is a plain value type carrying only the recording member's name and a BOUNDED body preview (clampPreview), never the raw finding. Like TeamTaskSnapshot it lives in session (session never imports team); the team.Finding → snapshot bridge lives in engine/agent.

type TeamMemberDisposition

type TeamMemberDisposition struct {
	// Name is the member name (matches a roster entry by name).
	Name string
	// Disposition is "done" / "stopped".
	Disposition string
	// Reason is "error" / "cancelled" / "budget"; empty when done.
	Reason string
	// ErrorRounds is how many of this member's rounds ended in a run-level error. It is
	// a plain COUNT of supervisor verdicts (no member content, no cap needed), and it is
	// what keeps the disposition HONEST now that a member's errored round is bounded-
	// retried rather than always terminal (issue #318): such a member finishes
	// Disposition "done" with no Reason, so the count is the only signal a client has
	// that the run was not clean.
	//
	// It is INDEPENDENT of Disposition/Reason: it counts errored rounds over the member's
	// whole LIFETIME, so it is 0 exactly when the member never had an errored round —
	// NOT when it ended cleanly. A member that failed a round, was recovered and retried,
	// and was then cancelled reports Reason "cancelled" with ErrorRounds 1. Render the
	// two together; do not derive either from the other.
	ErrorRounds int
}

TeamMemberDisposition is one member's TERMINAL disposition, projected onto the team.end snapshot so a watching client can render a stopped member distinctly from a clean one (instead of recomputing "done" and contradicting the supervisor). It carries ONLY closed-enum supervisor verdicts — never member-authored content — so it needs no preview cap and opens no redaction surface (Name is already forwarded verbatim on the team.start roster). Disposition is "done"/"stopped"; Reason is "error"/"cancelled"/"budget" (empty for a done member). Like TeamTaskSnapshot it lives in session (session never imports team); the MemberOutcome → snapshot bridge lives in engine/agent. Disposition/Reason are plain strings here (the domain stays free of the engine/agent enum types, mirroring TeamTaskSnapshot.State); the closed-enum guarantee is enforced at the bridge.

type TeamMemberSpec

type TeamMemberSpec struct {
	// Name is the member's unique handle.
	Name string
	// Role is the member's short role label (the model-supplied role string).
	Role string
	// Mutating reports whether the member runs in an isolated fork with
	// workspace-mutating tools (true) or shares the base read-only (false).
	Mutating bool
	// Lead marks the coordinating member.
	Lead bool
	// RoutedCategory / RoutedModel are the OPT-IN semantic model router's classification
	// for this member (ADR 0031 / ADR 0034): the chosen CATEGORY label and the concrete
	// MODEL id the member's engine was minted on. Set on the EvTeamStart roster entry ONLY
	// when the router was wired AND classified this member (both empty otherwise — no
	// router, a fail-soft miss that inherited the default member model, or a DEFINED member
	// whose agent def pinned its own model so the router never fired). Like the Subagent and
	// Parallel routed fields they are BARE METADATA — a category label and a model id, never
	// the member's role/prompt or the classifier's reasoning — so they are gauntlet-#7 safe
	// (no member content crosses). They ride the proto/client wire end-to-end (team.start
	// roster: TeamMemberSpec.routed_category = field 5 / routed_model = field 6), surfaced
	// via the server mapper — see ADR 0034.
	RoutedCategory string
	RoutedModel    string
	// RoutingReason names WHY the router did NOT classify this member (EvTeamStart roster
	// entry only): EMPTY on a routed hit (RoutedCategory/RoutedModel set), otherwise one of
	// the RoutingReason* gate constants (agent-def-pinned-model for a DEFINED member,
	// router-disabled when no router is wired) or a static miss code from the classifier
	// (the RouterMiss* values) or composition. Open callback detail is reduced before
	// emission. It is BARE METADATA — a bounded harness/composition reason code, never the
	// member's role/prompt or classifier reasoning — so it is gauntlet-#7 safe (no member
	// content crosses). Clamped at the emit site.
	RoutingReason string
	// Model is the concrete MODEL id the member's engine ACTUALLY runs on (EvTeamStart
	// roster entry only), set unconditionally — inherited default member model, agent-def
	// pin, or the opt-in router — independent of whether the router fired. It is BARE
	// METADATA — a model id, never member content — so it is gauntlet-#7 safe (no member
	// content crosses). When the router classified this member, Model == RoutedModel. It
	// rides the proto/client wire end-to-end (team.start roster: TeamMemberSpec.model =
	// field 7), surfaced via the server mapper — see ADR 0035.
	Model string
}

TeamMemberSpec is one roster entry forwarded on EvTeamStart: the member name, its role label, and the read-only/mutating and lead flags, plus the OPT-IN model router's bare-metadata routing projection ([RoutedCategory, RoutedModel, RoutingReason], Model). It is a small value type carrying ONLY model-supplied metadata about the team's shape — never any member content (no prompt body, no transcript). It mirrors the proto TeamMemberSpec.

type TeamPayload

type TeamPayload struct {
	// ParentCallID is the parent's Team tool-call id, attributing every team.*
	// event to the originating Team card. Set on all three kinds.
	ParentCallID string
	// TeamID is the team id, distinguishing concurrent teams. Set on all kinds.
	TeamID string
	// Roster is the team's membership as the model formed it. Set on EvTeamStart
	// only. It carries only member metadata, never member content.
	Roster []TeamMemberSpec
	// Member is the name of the member whose activity this event projects. Set on
	// EvTeamMember only.
	Member string
	// MemberSessionID is the producing member's child SESSION id (MemberSessionID:
	// "team-<teamID>-<member>") — the uniform per-child cancel/inspect handle (the
	// same single-handle convention as SubagentPayload.ChildID), surfaced so a client
	// can address a member (CancelChild) WITHOUT deriving the id grammar. Set on
	// EvTeamMember only. It is an id, never member content.
	MemberSessionID string
	// MemberIncarnation is internal durable correlation metadata, never projected.
	MemberIncarnation IncarnationID
	// InnerKind is the member's underlying session event kind being projected
	// (e.g. "message.delta", "tool.call", "tool.result", "turn.end", "result").
	// Set on EvTeamMember only. permission.ask is never projected.
	InnerKind EventType
	// Text is the member's message/result text or a BOUNDED preview of it. Set on
	// EvTeamMember for message.delta / result inner kinds.
	Text string
	// ToolName is the name of a member tool that was called. Set on EvTeamMember
	// for tool.call / tool.result inner kinds.
	ToolName string
	// Detail is a BOUNDED preview of a member tool call's args (tool.call) or
	// result body (tool.result) — capped at maxTeamPreview runes. It is never the
	// raw, unbounded args/result body. Set on EvTeamMember for tool.* inner kinds.
	Detail string
	// IsError reports whether a member tool.result failed. Set on EvTeamMember for
	// the tool.result inner kind.
	IsError bool
	// Rounds is the number of scheduling rounds that ran work. Set on EvTeamEnd
	// only.
	Rounds int
	// Stop is the team run's terminal stop reason. Set on EvTeamEnd only.
	Stop StopReason
	// Usage is the member's per-event usage (EvTeamMember turn.end/result) or, on
	// EvTeamEnd, the TEAM TOTAL — the sum of every member's per-turn usage.
	Usage Usage
	// ContextUsed is the member's CURRENT context occupancy — the most recent
	// turn's input-token count (Usage.InputTokens of the turn just ended), i.e.
	// what the next turn would carry into the model, not a cumulative sum. Set on
	// EvTeamMember turn.end; 0 when unknown. It feeds the per-member context meter
	// in the ctrl+a agents overlay (the team analogue of the main context meter).
	ContextUsed int64
	// ContextWindow is the producing member engine's context window in tokens (the
	// meter's denominator). Set on EvTeamMember turn.end; 0 when unknown (no meter
	// is drawn in that case).
	ContextWindow int64
	// Tasks is a snapshot of the team's SHARED TASK LIST in creation order. It is
	// set on an EvTeamTasks event (emitted on change, de-duped, from the Team tool's
	// member-event sink) and on EvTeamEnd (the terminal snapshot, so the final task
	// state always lands). It feeds the ctrl+a agents task sub-view; it carries only
	// task metadata, never member content.
	Tasks []TeamTaskSnapshot
	// Findings is a snapshot of the team's SHARED FINDINGS LEDGER in append order. It
	// is set on an EvTeamFindings event (emitted on change, de-duped) and on EvTeamEnd
	// (the terminal snapshot). It feeds the ctrl+a agents findings view; each entry
	// carries the recording member's name and a BOUNDED body preview, never the raw
	// finding.
	Findings []TeamFindingSnapshot
	// Dispositions is a per-member TERMINAL disposition snapshot, set ONLY on EvTeamEnd
	// (parallel to the terminal Tasks/Findings snapshots). It lets a client render a
	// stopped member distinctly from a clean one without recomputing "done". Each entry
	// carries closed-enum supervisor verdicts only, never member content. Empty on
	// every other kind.
	Dispositions []TeamMemberDisposition
	// Cause carries the member run's per-round FAILURE DETAIL when that round's
	// EvResult.Stop is StopError (empty otherwise) — the mirror of
	// ResultPayload.Error for the team-member projection. Set on EvTeamMember with
	// InnerKind=EvResult ONLY, and ONLY when the round failed; empty on every other
	// inner kind and on EvTeamEnd's disposition snapshot.
	//
	// It is HARNESS/PROVIDER metadata — a transport or loop error string, or (for a
	// failure BEFORE the member run started) the harness's own error text — NOT
	// member-authored model output, so it is gauntlet-#7 safe on the same footing as
	// Stop/Usage.
	//
	// It is LINE-ORIENTED by contract: the ONE emit site (projectTeamEvent) normalises
	// it through subagentCausePayload (whitespace collapsed to single spaces, then
	// rune-clamped to maxSubagentCausePreview), because its consumers are single-line
	// surfaces — a mecatui roster/focus row, an ACP status line, a log line — and a
	// provider error body routinely carries real newlines. A consumer renders it
	// as-is rather than re-deriving the collapse. The terminal EvTeamEnd disposition
	// stays the closed-enum reason; Cause is per-round, so a retried member's failed
	// rounds each surface their own cause. Mirrors SubagentPayload.Cause.
	Cause string
}

TeamPayload is the BOUNDED observability projection carried by the team.* events (EvTeamStart / EvTeamMember / EvTeamTasks / EvTeamFindings / EvTeamEnd). It is the ONLY information about an in-process team's run that surfaces to clients on the event stream.

REDACTION CONTRACT — fuller-but-bounded. Like SubagentPayload / ParallelPayload (bounded previews per ADR 0079) but fuller, since a team is meant to be WATCHED: this payload deliberately forwards member CONTENT on team.member events: the member's streamed/terminal message text and BOUNDED previews of its tool calls (name + capped arg preview) and tool results (error bool + capped body preview). Every such preview is CAPPED (see maxTeamPreview) so an unbounded args/result body can never be copied verbatim, and a member's permission.ask is DROPPED entirely — it is NEVER forwarded, so a pending-ask reason (which can quote secrets or sensitive args) never reaches the stream. This forwarding is orthogonal to the parent conversation: the team's per-member transcripts NEVER enter the parent Session's Conversation; only the Team tool's joined-summary ToolResult does. So the LLM's context still sees only the summary, exactly like Subagent/Fork.

Which fields are set depends on the event kind:

  • EvTeamStart: ParentCallID, TeamID, Roster.
  • EvTeamMember: ParentCallID, TeamID, Member, MemberSessionID, InnerKind, and the subset of {Text, ToolName, Detail, IsError, Usage, ContextUsed, ContextWindow} relevant to InnerKind, and, when the round's result was StopError, Cause.
  • EvTeamTasks: ParentCallID, TeamID, Tasks (the team-wide task snapshot; no Member).
  • EvTeamFindings: ParentCallID, TeamID, Findings (the team-wide findings ledger snapshot; no Member).
  • EvTeamEnd: ParentCallID, TeamID, Rounds, Stop, Usage (cumulative), Tasks (the terminal task snapshot), Findings (the terminal findings snapshot), Dispositions (the per-member terminal disposition snapshot).

type TeamTaskSnapshot

type TeamTaskSnapshot struct {
	// ID is the stable task identifier.
	ID string
	// Description is a BOUNDED preview of the work to do (capped like every other
	// member-derived preview).
	Description string
	// State mirrors team.TaskState: "pending" / "in_progress" / "completed".
	State string
	// Assignee is the member name that claimed the task, or empty if unclaimed.
	Assignee string
	// Deps lists the task ids that must complete before this task is claimable.
	Deps []string
}

TeamTaskSnapshot is one entry in the team's shared task list, projected onto the event stream so the ctrl+a agents task sub-view can render the team's task state (id · state · assignee · deps) without an out-of-band ListTeam RPC — the team is a Team-tool-local object the TUI cannot address. It is a plain value type mirroring the proto TeamTask; it carries only task metadata (no member content). Deps are the task ids this task depends on (it is blocked until they complete).

type TitleProvenance

type TitleProvenance string

TitleProvenance records who last authored a session title. The zero value is legacy/unknown so snapshots written before provenance was introduced fail closed.

const (
	// TitleProvenanceUnknown means the title's author is unavailable.
	TitleProvenanceUnknown TitleProvenance = ""
	// TitleProvenanceFirstPrompt means the first genuine prompt supplied the title.
	TitleProvenanceFirstPrompt TitleProvenance = "first-prompt"
	// TitleProvenanceOperator means an operator explicitly renamed the session.
	TitleProvenanceOperator TitleProvenance = "operator"
)

type ToolCall

type ToolCall struct {
	// ID pairs this call with its ToolResult.
	ID ToolCallID `json:"ID"`
	// Name is the tool name as registered in the catalog.
	Name string `json:"Name"`
	// Args is the raw, tool-specific argument payload, validated against the
	// tool's JSON schema by the Tool itself.
	Args json.RawMessage `json:"Args"`
	// ItemID is the provider-assigned item-level unique identifier for this
	// function_call output item (e.g. the "id" field in the OpenAI Responses
	// API, distinct from ID which carries "call_id"). Carried opaquely — same
	// discipline as Message.Reasoning and Message.ProviderPhase — so the adapter
	// can round-trip it for store:false stateless multi-turn replay. Empty string
	// means "no item id" and the field is wire-omitted on replay. The STRUCTURE
	// is provider-neutral; the CONTENTS are provider-private — do NOT interpret
	// or validate this value in domain code.
	ItemID string `json:"item_id,omitempty"`
}

ToolCall is an immutable value object: a request from the model to invoke a named tool with tool-specific arguments. It is produced by the LLM provider and consumed by both the Tooling and Governance contexts. Construct it with NewToolCall; it carries no mutating methods.

JSON tags use capitalized keys (json:"ID", json:"Name", json:"Args") to preserve the wire format that predates JSON tagging — old snapshots serialized these fields as "ID", "Name", "Args" by Go's default reflection rule, so the tags below preserve exact backward compatibility. ItemID uses a lowercase tag following the ProviderPhase precedent (additive, omitempty).

func NewToolCall

func NewToolCall(id ToolCallID, name string, args json.RawMessage) ToolCall

NewToolCall constructs a ToolCall value object.

type ToolCallID

type ToolCallID string

ToolCallID uniquely identifies a tool invocation within a session. It is produced by the LLM and used to pair a ToolCall with its ToolResult.

type ToolResult

type ToolResult struct {
	// CallID is the ID of the ToolCall this result answers.
	CallID ToolCallID
	// Content is the result body, already token-shaped/truncated by the tool.
	Content string
	// IsError reports whether the tool failed; an error result is still fed
	// back to the model so it can recover.
	IsError bool
	// Parts carries typed tool-result blocks (text/image/audio/resource). Empty
	// for the legacy string-only path; when non-empty, consumers prefer Parts
	// over Content. Distinct from Message.Parts (which is media-only for user
	// messages) — providers read ToolResult.Parts, not Message.Parts, for tool
	// results.
	Parts []Content `json:"Parts,omitempty"`
}

ToolResult is an immutable value object: the outcome of executing a ToolCall, paired to it by CallID. Construct it with NewToolResult or NewToolError; it carries no mutating methods.

Content-vs-Parts precedence: both Content and Parts may be present. Content is the default model-facing string (always set by the legacy constructors); Parts carries typed tool-result blocks (text/image/audio/resource). Consumers prefer Parts when non-empty, falling back to Content. A legacy/empty-Parts result is byte-identical to the pre-Parts shape.

func NewToolError

func NewToolError(callID ToolCallID, content string) ToolResult

NewToolError constructs an error ToolResult for the given call.

func NewToolResult

func NewToolResult(callID ToolCallID, content string) ToolResult

NewToolResult constructs a successful ToolResult for the given call.

func NewToolResultWithParts

func NewToolResultWithParts(callID ToolCallID, content string, parts []Content) ToolResult

NewToolResultWithParts constructs a successful ToolResult carrying typed blocks alongside the default model-facing Content string. content is the plain model-facing summary; parts are the typed tool-result blocks. Consumers prefer Parts when non-empty.

func RepairToolResult

func RepairToolResult(r ToolResult) ToolResult

RepairToolResult returns r with every TEXTUAL string field normalized to valid UTF-8: Content, and per Parts block the Text/Name/Title/Description/ URL/LastModified fields plus the Audience list. r is an immutable value object, so the repair is a copy, never a mutation.

Byte-exact fields are deliberately untouched:

  • Data []byte — binary payloads ride proto bytes fields, which carry no UTF-8 rule; they must stay byte-identical.
  • MIMEType — an IANA machine token, not prose; exactness is the contract, so it is never rewritten. What keeps that safe is the TRANSPORT, not a validator: validateMIME runs only on the inbound media path via NewContent, and the MCP block constructors (NewResourceLinkBlock / NewEmbeddedResourceBlock) do not check it — MCP metadata is simply JSON- decoded before it arrives, which already coerces invalid bytes. A future non-JSON producer of a block MIME type (a sniffed local file, an HTTP Content-Type header) reopens issue #402 on this field.

The repair is applied at the loop's effective-payload choke point (after PostToolUse, before the recorder/event/record) so the model view, the audit log, the durable log, and the client stream all carry the SAME repaired text — see engine/agent execute. It is NOT applied in the NewToolResult* constructors: those are also called by replay/test paths with already-persisted data, and constructor-side repair would smear the single-choke-point invariant across every caller.

type Turn

type Turn struct {
	// Index is the zero-based position of this turn in the session.
	Index int
	// Assistant is the assistant message produced by the model call.
	Assistant Message
	// Results holds the results of the tools the assistant requested.
	Results []ToolResult
	// Usage is the token accounting for this turn's model call.
	Usage Usage
}

Turn records one model call together with the tools it triggered. It is a value object summarizing a single iteration of the agent loop.

type TurnEndPayload

type TurnEndPayload struct {
	// Usage is THIS turn's model-call usage (not the cumulative run total).
	Usage Usage
	// Estimated is true when Usage.InputTokens is a conversation-size ESTIMATE
	// rather than a provider-reported figure — the issue-#82 zero-usage fallback
	// fired because the turn produced no usage frame (a stalled/usage-less turn).
	// It is DISPLAY-ONLY: the estimate never feeds the cumulative run total or a
	// token budget (those stay on provider truth), only the context meter, which a
	// client may flag with a "~" hint. False for an ordinary provider-reported turn.
	Estimated bool
	// DurationMs is the elapsed milliseconds for the turn's model call; 0 when no
	// Clock is injected.
	DurationMs int64
	// TTFTMs is the time-to-first-token: the elapsed milliseconds from the start
	// of the model stream to the FIRST observable output (text, reasoning, a
	// reasoning replay item, or a tool call) of the turn. It is 0 when no Clock is
	// injected OR when the turn produced no observable output at all (a genuinely
	// empty turn) — a 0 here is "not measured", never a real zero, so telemetry
	// must guard against recording bogus zeros.
	TTFTMs int64
	// InterTokenMeanMs is the per-turn MEAN gap, in milliseconds, between
	// consecutive streaming content deltas (text or reasoning) within the turn —
	// the typical streaming smoothness. It feeds the mecatl.inter_token histogram.
	// It is 0 when fewer than two streaming content deltas were observed (no gap
	// exists) or no Clock is injected. A tool call or reasoning replay blob is NOT a
	// streaming delta and never contributes a gap.
	InterTokenMeanMs int64
	// InterTokenMaxMs is the per-turn WORST (largest) single gap, in milliseconds,
	// between consecutive streaming content deltas within the turn — the jitter
	// spike users feel. It feeds the mecatl.inter_token.max histogram. It is 0 under
	// the same <2-delta / no-Clock conditions as InterTokenMeanMs.
	InterTokenMaxMs int64
}

TurnEndPayload is the payload carried by an EvTurnEnd Event. It is a typed envelope (mirroring ResultPayload) so turn.end owns its own usage semantics and has room to grow (finish reason, model id, retries) without overloading the shared Event fields. This keeps Event.Usage with a single meaning — the cumulative run total on EvResult — rather than two semantics on one field.

type Usage

type Usage struct {
	// InputTokens is the number of prompt tokens billed for this call,
	// including any tokens served from cache.
	InputTokens int
	// OutputTokens is the number of completion tokens generated.
	OutputTokens int
	// CacheReadTokens is the number of input tokens served from the prompt
	// cache (a subset of InputTokens).
	CacheReadTokens int
	// CacheWriteTokens is the number of input tokens written into the prompt
	// cache on this call.
	CacheWriteTokens int
	// ReasoningTokens is the number of output tokens spent on internal
	// reasoning (a subset of OutputTokens — providers bill reasoning as part of
	// the inclusive output total; this is the breakdown, not an addend).
	ReasoningTokens int
}

Usage is an immutable value object accounting for the token cost of a single model call (or an aggregate thereof). Construct it as a literal; it carries no mutating methods.

func (Usage) Add

func (u Usage) Add(other Usage) Usage

Add returns a new Usage that is the element-wise sum of u and other. Usage is immutable, so accumulation is expressed by replacement, not mutation.

func (Usage) CacheHitRate

func (u Usage) CacheHitRate() float64

CacheHitRate returns the fraction of input tokens that were served from the prompt cache: CacheReadTokens / InputTokens. It returns 0 when InputTokens is zero (guarding against division by zero).

func (Usage) TotalTokens

func (u Usage) TotalTokens() int

TotalTokens returns the spend proxy used by the loop-level token budget (Deps.MaxRunTokens / StopBudget): InputTokens + OutputTokens. Cache tokens are DELIBERATELY excluded — CacheReadTokens is a subset of InputTokens (double counting it would inflate the total) and CacheWriteTokens is a write-through side cost, not the model-call spend the budget bounds. ReasoningTokens is likewise DELIBERATELY excluded — it is a subset of OutputTokens (providers bill reasoning as part of the inclusive output total; adding it here would double-count). The budget is a coarse runaway brake, so input+output is the right, simple proxy.

type UserPromptPayload

type UserPromptPayload struct {
	// Text is the flattened user-message text (the prompt body, or a harness-authored
	// continuation/notice).
	Text string
	// Parts carries any non-text media (image/audio) that rode alongside the text on
	// the user message; nil for a text-only prompt. It mirrors Message.Parts so the
	// reconstructed user Message is faithful.
	Parts []Content
}

UserPromptPayload is the structured detail carried by an EvUserPrompt Event: the user-role message that was just recorded into the conversation. It carries the flattened Text plus any non-text media Parts, mirroring the user Message a reconstruction must rebuild (session.NewUserMessageWithParts). It is the durable record of WHAT THE USER ASKED — both a genuine client prompt and the harness's own synthetic continuation messages (nudges/notices), so an event-sourced fold of the log reconstructs a COMPLETE conversation, not just genuine prompts.

NO-LEAK CONTRACT (gauntlet #7): it carries only the TOP-LEVEL run's own user input. A child run's prompt is emitted on the child stream (drained inside the delegation tool), never on the parent's, so no child content crosses here — the same posture as CompactionArchivePayload (the parent's own history).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL