domain

package
v1.7.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package domain is the ubiquitous language (CONTEXT.md) rendered as Go: every type, interface, enum, sentinel error, and hook working-value in Apogee's public surface, plus the pure logic intrinsic to those types (the Mechanism registry's ordering-cycle detection, ConfinementCaps.AutoEligible, the Session envelope and its versioning).

It is the foundational layer of the package layout decided in ADR 0010: the engine (internal/agent), the provider (internal/provider), and the platform backends (internal/platform) all import domain for these types and never import the root apogee package; the root facade re-exports the public ones as aliases. Domain depends only on the standard library, so the language has a dependency-free home and the invariant "internal/* never imports root" holds at the bottom of the graph.

Naming: "domain", not "core" — the retired "Apogee Core" library term (CONTEXT.md "Retired terms") is unrelated to this internal package.

Index

Constants

View Source
const SessionVersion = 1

SessionVersion is the schema version Snapshot stamps and Resume/DecodeSession accept. v1 is finalised by P1.6; a snapshot whose Version exceeds this is from a newer build and is rejected with ErrSessionVersion (ADR 0001 — no silent forward migration).

Domain owns the Session *envelope* and its versioning; the engine (internal/agent) owns the opaque State payload and its schema. In v1 that payload is the loop's full quiescent-boundary state: the conversation (messages with tool-call/result pairing and per-message Extra wire fields, plus the deferred-action queue) and the loop counters (turnIndex, inExchange, pending input) — see internal/agent/state.go.

Variables

View Source
var (
	// ErrAutoUnavailable is returned by New when Mode==Auto and NO Confiner was injected
	// at all (cfg.Confiner == nil) — Auto has no facility to enforce its subprocess surface
	// with, so it is refused. A PRESENT-but-incapable backend (denyConfiner, or landlock on
	// a kernel <5.13) does NOT refuse Auto: under ADR 0012 the gate is FSWrite-only and the
	// unfenceable subprocess surface falls back to Approval — "confine if you can, gate if
	// you can't" (confinement-execution-contract §4–5). The CLI always injects a backend,
	// so this error is a library-embedder contract, not a CLI-user-facing one.
	ErrAutoUnavailable = errors.New("apogee: auto mode requires filesystem-write confinement, unavailable on this host")

	// ErrConfinementUnavailable is the runtime "confine if you can, gate if you can't"
	// safety net (ADR 0012; confinement-execution-contract §2.2): a Confiner backend
	// that finds it cannot establish the requested box for a subprocess returns it, and
	// the dispatch disposition falls back to Approval rather than running the call
	// unconfined. Distinct from ErrAutoUnavailable, which gates Auto at construction.
	ErrConfinementUnavailable = errors.New("apogee: confinement unavailable on this host")

	// ErrOrderingCycle is returned by New / registry Add when Mechanism ordering
	// constraints form a cycle — it must fail loudly at startup (ADR 0003).
	ErrOrderingCycle = errors.New("apogee: mechanism ordering constraints contain a cycle")

	// ErrIncompatibleMechanisms is returned by New when two registered Mechanisms declare
	// each other incompatible (MechanismDescriptor.IncompatibleWith) — they must never
	// co-fire, so registering both is a configuration error that fails loudly at startup
	// (ADR 0003), the same posture as ErrOrderingCycle.
	ErrIncompatibleMechanisms = errors.New("apogee: incompatible mechanisms registered together")

	// ErrMissingRequirement is returned by New when a registered Mechanism declares a required
	// peer (MechanismDescriptor.Requires) that is not itself registered — the two are benched as
	// a stack, so enabling one without the other is a configuration error that fails loudly at
	// startup (ADR 0003 posture, ADR 0014 §4), the dual of ErrIncompatibleMechanisms.
	ErrMissingRequirement = errors.New("apogee: a required mechanism is not registered")

	// ErrUnknownMechanism is wrapped by mechanisms.Build (and, through it, agent construction from
	// Config.EnableMechanisms) when a named Mechanism ID is not in the catalogue — a typo'd or
	// deferred ID fails loudly rather than silently disabling a Mechanism (ADR 0015 §4). The
	// wrapping error still names the known IDs; this sentinel makes the condition matchable with
	// errors.Is (locked decision 5).
	ErrUnknownMechanism = errors.New("apogee: unknown mechanism")

	// ErrSessionVersion is returned by Resume / DecodeSession for a snapshot whose
	// schema version this build does not understand.
	ErrSessionVersion = errors.New("apogee: unsupported session schema version")

	// ErrInputPending is returned by Submit when an Exchange is already in progress.
	ErrInputPending = errors.New("apogee: cannot submit input mid-exchange")

	// ErrDuplicateTool is returned by ToolRegistry.Register when a tool with the same
	// Name is already registered — the name is the model's stable handle, so a
	// collision is a configuration error, not a silent overwrite.
	ErrDuplicateTool = errors.New("apogee: a tool with this name is already registered")

	// ErrInvalidTool is returned by ToolRegistry.Register for a tool that cannot be
	// addressed — currently an empty Name.
	ErrInvalidTool = errors.New("apogee: invalid tool")
)

Functions

func IsReadOnly

func IsReadOnly(t Tool) bool

IsReadOnly reports whether t has declared itself read-only via ReadOnlyTool. A tool that makes no such declaration is treated as write-capable.

func IsSubprocessTool

func IsSubprocessTool(t Tool) bool

IsSubprocessTool reports whether t has declared itself a subprocess tool via SubprocessTool — the signal the disposition confines it (Auto) rather than gating it. A tool that makes no such declaration is not a subprocess tool.

func PromptChars

func PromptChars(msgs []Message, tools []ToolDef) int

PromptChars is a stable character measure of a request's prompt — the message contents and tool-call arguments plus the tool menu's names, descriptions, and schemas — used both as the estimator's calibration sample (internal/context.TokenEstimator.Calibrate) and as the basis for a token estimate (EstimateTokens). It deliberately omits the chat template's own markup, which the character count cannot see; the same omission on both sides of the chars→token ratio means a systematic offset cancels, so an estimate stays consistent with the calibration that produced the ratio.

func WithConfinement

func WithConfinement(ctx context.Context, conf Confinement) context.Context

WithConfinement returns a context carrying conf, so a subprocess tool's Execute can retrieve the Confiner + box to confine the command it launches. The dispatch disposition calls this only when it has decided to run a subprocess tool confined.

Types

type ApprovalDecision

type ApprovalDecision string

ApprovalDecision is the Approver's verdict.

const (
	ApprovalAllow           ApprovalDecision = "allow"
	ApprovalDeny            ApprovalDecision = "deny"
	ApprovalAllowForSession ApprovalDecision = "allow-for-session"
)

type ApprovalEvent

type ApprovalEvent struct {
	EventBase
	Request  ApprovalRequest
	Decision ApprovalDecision
}

ApprovalEvent reports that an Approval was requested/decided for a tool call. (The decision is obtained synchronously via the Approver; this event is for observers — TUI display, bench accounting.)

type ApprovalRequest

type ApprovalRequest struct {
	Tool      string
	Arguments json.RawMessage
	Reason    string // why approval is required (e.g. "write", "unconfinable MCP tool")
}

ApprovalRequest describes the pending tool call the human is asked to allow.

type Approver

type Approver interface {
	Approve(ctx context.Context, req ApprovalRequest) (ApprovalDecision, error)
}

Approver is the host-supplied human-in-the-loop gate on a single tool call. In Ask-Before mode it is consulted for every call; in Auto mode it is consulted only for tools that cannot be confined (e.g. MCP — ADR 0004). It is called synchronously inside a Step and may block on the human; cancelling ctx unblocks it.

type AskAnswer

type AskAnswer struct {
	// Text is the human's typed answer.
	Text string
}

AskAnswer is the human's free-text reply. A STRUCT for the same freeze-safety reason (a post-v1 Choice index is an additive field).

type AskRequest

type AskRequest struct {
	// Question is the free-text prompt the human answers.
	Question string
}

AskRequest is the free-text question put to the human. It is a STRUCT (not a bare string) for freeze-safety (D7): a post-v1 multiple-choice field (Choices) is then an additive, non-breaking change to the v1.0.0 surface.

type Asker

type Asker interface {
	Ask(ctx context.Context, req AskRequest) (AskAnswer, error)
}

Asker is the host-supplied delegate the ask_user tool routes a free-text question to mid-task: the model asks the human a clarifying question and waits for a typed answer. It is the public analogue of Approver (a deliberate v1-surface addition, D7) but for free-text Q&A, NOT a safety gate — an Asker decision carries no allow/deny semantics and never bypasses the Approval/disposition machinery. It is consulted synchronously inside a Step (on the worker goroutine) and may block on the human; cancelling ctx unblocks it.

In a headless / non-interactive context the host must supply an Asker that FAILS SAFE (returns promptly with an error or a scripted answer) rather than hanging — and a nil Asker means the ask_user tool is simply not registered (graceful), so the model is never offered a question it cannot have answered.

type AuditEvent

type AuditEvent struct {
	EventBase
	Tool     string
	CallID   string
	Decision string
	Reason   string // the guardrail reason, if any
	IsError  bool   // whether the recorded result was a tool-level error
}

AuditEvent surfaces one append-only audit record — a tool call, the guardrail decision it cleared/was blocked by, and whether its result errored — to the EventSink as it is recorded, so the audit trail is OBSERVABLE (and snapshot- or log-shippable) rather than living only in a volatile in-process ring no observer reads (security-review M1). Because a sub-agent emits through the parent's EventSink at Depth > 0, a delegated call's audit record reaches the same observer at its nesting depth instead of vanishing with the discarded child Agent.

The payload mirrors security.AuditRecord but is expressed in domain-only types: the agent layer (which imports both domain and security) constructs it, so domain keeps its no-upward-dependency property (ADR 0010). Decision is the audit decision as a string (e.g. "allowed", "dangerous-refused", "circuit-tripped").

type Budget

type Budget struct {
	ContextLimit  int     // the model's full context window (n_ctx tokens); 0 when unknown
	Used          int     // tokens the last server usage reported the prompt occupied; 0 until the first UsageEvent
	CharsPerToken float64 // the chars→token ratio, calibrated against reported usage

	// The window allocation (internal/context.Allocate): how many tokens of ContextLimit each
	// part of a request may claim. ResponseReserve is held back for the reply; the rest is split
	// across SystemPrompt, FileContext, and History (they sum to ContextLimit - ResponseReserve).
	// Every field is 0 when the window is unknown. It is ADVISORY: the context reducers
	// (tool-result capping, automatic Compaction) read it; nothing in the request path is
	// reshaped by it here.
	ResponseReserve int
	SystemPrompt    int
	FileContext     int
	History         int
}

Budget is the read-only context-budget view a hook reads to gate token-sensitive behaviour (e.g. Library injection backs off as the window fills, tool-result capping trims a result to its fraction). It is the CONTEXT "Budget": the single authority on how much room each part of a request gets. Its token accounting is calibrated against server-reported usage (internal/context.TokenEstimator), so Used and CharsPerToken are honest measures rather than a fixed guess.

func (Budget) EstimateTokens

func (b Budget) EstimateTokens(chars int) int

EstimateTokens converts a character count to a token estimate through the calibrated chars→token ratio, rounding up so a part is never estimated to fit when it is one token over. A non-positive CharsPerToken — the zero-value Budget of an uncalibrated view — yields 0, so a comparison against any positive threshold stays false: token-gated behaviour is inert until the ratio is known, never fired on an un-measured guess.

func (Budget) HistoryExceedsAllocation

func (b Budget) HistoryExceedsAllocation(msgs []Message) bool

HistoryExceedsAllocation reports whether the estimated token size of msgs (the conversation history the reducers reclaim) has outgrown the Budget's History allocation. It is the single compare behind both the engine's automatic Compaction trigger and any hook reading the Budget, so the two can never disagree. The measure runs the whole conversation through the calibrated ratio (PromptChars omits the tool menu — that is not history) and is deliberately conservative: comparing the whole conversation against the History slice trips slightly before the prompt would overflow. A non-positive History (the window is unknown, so nothing was allocated) never trips — there is no basis to bound.

type Capability

type Capability string

Capability is what a Mechanism does — and what Bypass switches on (ADR 0006: Bypass disables proactive-nudge + response-repair, keeps off-ramp).

const (
	CapOffRamp        Capability = "off-ramp"        // exempt recovery guarantee; survives Bypass
	CapProactiveNudge Capability = "proactive-nudge" // disabled under Bypass
	CapResponseRepair Capability = "response-repair" // disabled under Bypass
)

type Config

type Config struct {
	// Upstream — the local OpenAI-compatible LLM server (CONTEXT: Upstream).
	Endpoint string
	Model    string

	// Autonomy.
	Mode   Mode // Plan / Ask-Before / Allow-Edits / Auto (the privilege ladder)
	Bypass bool // ADR 0006: Mechanisms off, structure on (the hard-constraint floor)

	// ConfineToWorkspace tunes Auto's blast radius (ADR 0012); meaningful only in Auto.
	// true (the default) fences subprocess writes to the workspace under OS confinement
	// (network open, MCP gated); false ("I am the sandbox") runs Auto unconfined, safe
	// only inside a VM. It is loaded from the GLOBAL config only (a project config cannot
	// loosen it — the hostile-repo footgun is closed). The host sets it; the loop reads it
	// in the dispatch disposition.
	ConfineToWorkspace bool

	// ConfineWritablePaths and ConfineNetworkAllow extend the confinement box beyond the
	// workspace root (confinement-execution-contract §7): the toolchain cache/temp dirs a
	// confined `go build`/`pip` needs to write, and the per-project network tightening
	// list. The host probes/configures these and folds them into Config; the loop confines
	// a subprocess to WorkspaceDir ∪ ConfineWritablePaths with ConfineNetworkAllow as the
	// box's NetworkAllow. Empty NetworkAllow leaves the network open (the ADR 0012 default).
	ConfineWritablePaths []string
	ConfineNetworkAllow  []string

	// Host-supplied delegates. The host (TUI / bench / embedder) owns these.
	Approver  Approver  // the human-in-the-loop gate; required unless Mode==Plan
	Asker     Asker     // free-text Q&A delegate for the ask_user tool; nil ⇒ ask_user is not registered (P3.11)
	Presenter Presenter // document-presentation delegate for the present_document tool; nil ⇒ present_document is not registered (ADR 0019)
	Confiner  Confiner  // nil ⇒ no confinement ⇒ Auto is refused (ADR 0004)
	Events    EventSink // where typed Events are pushed; required

	// Extension points. nil ⇒ the built-in defaults.
	Tools *ToolRegistry // open extension point (ADR 0002)

	// Mechanisms is the experimental-hook carrier: the bench registers candidate hooks on it via
	// AddExperimental, and a host may pre-build catalogued Mechanisms into it directly. Catalogued
	// Mechanisms are normally armed by ID through EnableMechanisms (ADR 0015), which builds each
	// named Mechanism and merges it INTO this registry (a fresh one when nil), so the two coexist in
	// one arm. The field keeps its name under v1 semver (no rename); EnableMechanisms is the
	// enable-by-ID surface (ADR 0002/0003, ADR 0015).
	Mechanisms *MechanismRegistry

	// EnableMechanisms names catalogued Mechanisms to arm by ID (ADR 0015 §1). New and Resume build
	// each named Mechanism at construction and merge it INTO Mechanisms (creating a fresh registry
	// when that is nil), so a catalogued Mechanism and a bench experimental hook coexist in one arm.
	// An unknown ID (ErrUnknownMechanism), an ID listed twice or already pre-built into Mechanisms
	// (the registry's already-registered rejection), a hook-less Mechanism, or a half-armed Requires
	// stack fails construction — a typo or a half-built stack never silently disables a Mechanism.
	// Empty/nil arms nothing (the default-off posture). The catalogue's CONTENTS are data, not v1
	// contract — an ID may change in a minor with a CHANGELOG notice; the field and its build
	// semantics are the stable surface (locked decisions 1–2, 6).
	EnableMechanisms []MechanismID

	// Skills resolves the user's attached skill IDs (UserInput.SkillIDs) to their injectable
	// bodies; nil ⇒ no skills are wired and any attached ID is reported and dropped. It is an
	// interface defined here (not the concrete internal/skills catalog) so the loop fulfils the
	// SkillIDs seam without domain importing skills — the dependency flows toward domain (ADR
	// 0010). The host (cmd/apogee) loads the catalog and injects it.
	Skills SkillResolver

	// Injected state roots — no implicit ~/.apogee (ADR 0001). The bench points
	// these at ephemeral dirs so sim runs never touch the production Library.
	LibraryDir  string
	SessionsDir string
	ConfigDir   string

	// WorkspaceDir is the sandbox root the built-in file tools are scoped to when
	// Config.Tools is nil. Empty ⇒ no default tools are wired (the host must inject
	// Config.Tools to give the Agent any tools). The bench points it at an ephemeral
	// workspace so a file-edit task never escapes its sandbox (ADR 0001 isolation).
	WorkspaceDir string

	// ExternalEffects is the single injectable boundary for non-forkable effects
	// (network, MCP). nil ⇒ live. The bench injects a deterministic stub for v1;
	// record/replay slots in behind the same interface later (ADR 0008).
	ExternalEffects ExternalEffects

	// WebSearchEndpoint is the search backend the web_search tool sends a query to
	// (P3.11). DEFAULT-ON: empty ⇒ the tool falls back to its built-in DuckDuckGo
	// provider (no API key needed); the sentinel "off" disables it (a graceful "web
	// search is disabled", never a crash). The host folds a configured endpoint in from
	// config.yaml.
	WebSearchEndpoint string

	// Profile describes how the configured model speaks the wire (CONTEXT: Model profile) —
	// its tool-call format and inline thinking-channel style — so the loop selects the matching
	// tool-call parser and content-stripper at the parse seam. A ZERO Profile == native tool
	// calls with no inline thinking == today's exact behaviour (the byte-identical anchor): a
	// native profile selects no-op parsers, so the content path is unchanged. The host folds a
	// configured profile in from config.yaml; an embedder sets it directly. It is declarative
	// DATA translated to internal/processing's parsers at the boundary (ADR 0010) — not the
	// parsers' own config types, which cannot move up the DAG since processing imports domain.
	Profile ModelProfile

	// Budget / Compaction knobs (context/) are structural and load-bearing — they
	// run even under Bypass. Defaults are sane; overrides are advanced.
	Context ContextConfig
}

Config is the full construction surface. It carries the Upstream target, the autonomy posture, the host-supplied delegates, the extension registries, and the injected state roots. A zero Config is not valid; Endpoint, Model, and Events are the minimum. A struct (not functional options) because every field is a deliberate, reviewable seam and ADR 0001 speaks of state "injected via Config".

type Confinement

type Confinement struct {
	Confiner Confiner
	Box      ConfinementBox
}

Confinement is the handle a subprocess tool uses to confine the *exec.Cmd it builds: the Confiner backend plus the box to confine to. The dispatch disposition installs it into the Execute context (WithConfinement) for a subprocess tool the Auto disposition chose to run confined; the tool retrieves it (ConfinementFromContext) and calls Confine on its cmd before running it. Keeping the handle on the context — rather than in domain.Tool.Execute's signature — preserves the open Tool extension point (ADR 0002) while giving the subprocess tools (P3.8) exactly the contract's tool-builds-and-runs- the-cmd model (confinement-execution-contract §2.2).

func ConfinementFromContext

func ConfinementFromContext(ctx context.Context) (Confinement, bool)

ConfinementFromContext returns the Confinement handle installed by WithConfinement and whether one is present. ok is false when the call is not running under a confinement disposition (every mode other than Auto/confine-on for a subprocess tool), in which case the tool runs its command unconfined — the disposition has already ensured that path is only reached where unconfined execution is the intended outcome.

type ConfinementBox

type ConfinementBox struct {
	WorkspaceRoot string
	WritablePaths []string
	NetworkAllow  []string // per-project tightening; empty = network open (ADR 0012)
}

ConfinementBox is the confinement policy for a run. Default = workspace-write-only + network OPEN + per-project allowlist (ADR 0012). NetworkAllow is a TIGHTENING list: empty leaves the network open; non-empty opts the box into network-deny.

type ConfinementCaps

type ConfinementCaps struct {
	FSWrite       bool
	NetworkEgress bool
}

ConfinementCaps is the capability matrix a Confiner reports (ADR 0012 — extensible beyond these two).

func (ConfinementCaps) AutoEligible

func (c ConfinementCaps) AutoEligible() bool

AutoEligible reports whether these capabilities satisfy the Auto gate. Under ADR 0012 the network is open by default, so Auto requires filesystem-write confinement ONLY (was FSWrite && NetworkEgress under ADR 0004). NetworkEgress is still reported and matters only when a user opts back into network-deny via box.NetworkAllow. A host without fs-confinement is no longer refused Auto — it gates the subprocess surface instead (confinement-execution-contract §5).

type Confiner

type Confiner interface {
	// Capabilities reports what this backend can actually enforce, here and now —
	// probed once at construction, never optimistic (confinement-execution-contract
	// §5). A kernel without landlock, or a macOS without sandbox-exec, reports
	// {false, false}, so the dispatch disposition gates the subprocess surface rather
	// than confining it.
	Capabilities() ConfinementCaps

	// Confine prepares cmd to execute confined to box, then RETURNS — it does not run
	// cmd (confinement-execution-contract §2.2). It rewrites cmd to launch under the
	// host OS confinement facility (macOS: exec under sandbox-exec -p <profile>; Linux:
	// interpose the landlock re-exec wrapper) and sets cmd.SysProcAttr so the caller's
	// process-group kill reaches the wrapped child. The caller has already wired
	// Stdin/Stdout/Stderr/Dir/Env and afterwards invokes cmd.Run()/Output(). The PARENT
	// process is never restricted.
	//
	// Confine is only invoked when Capabilities() reports box is enforceable on this
	// host (the disposition checks caps first, §4). ErrConfinementUnavailable is the
	// runtime safety net: a backend that finds it cannot establish the box returns it,
	// and the caller falls back to Approval ("confine if you can, gate if you can't").
	Confine(ctx context.Context, box ConfinementBox, cmd *exec.Cmd) error
}

Confiner is the OS-level confinement facility for the unbounded subprocess surface (ADR 0012). The interface is PUBLIC because the host injects it via Config; the backends (seatbelt / landlock / AppContainer) live in internal/platform.

Granularity is the single, all-OS subprocess (Linux landlock applied to the child after fork, before execve; macOS sandbox-exec wrapping the child). There is no in-process per-thread confinement — Apogee's own in-process writes are path-safety-bounded instead (ADR 0012's blast-radius split).

type ContextConfig

type ContextConfig struct {
	MaxContextTokens  int // 0 ⇒ window unknown; the CLI discovers it or the context-window key supplies it (Budget/Compaction inactive until known)
	ResponseReserve   int
	CompactionEnabled bool // generative summarisation; default true
}

ContextConfig governs the structural context reducers — Budget and Compaction — which are NOT Mechanisms and stay on under Bypass (CONTEXT: Budget, Compaction).

type Conversation

type Conversation struct {
	// contains filtered or unexported fields
}

Conversation is the serializable conversation state a history-rewrite hook edits. It is a cleanly copyable value with no live handles (ADR 0001) — what lets the bench fork by deep-copying it and the user resume from a snapshot. Summaries are not a separate structure: they are ordinary messages produced by generative Compaction (context/) and written back via Replace. A deferred Response Action (ActionDefer) is held here (Defer / TakeDeferred) so it survives a snapshot/resume boundary.

MarshalJSON / UnmarshalJSON keep the type opaque while persisting it; the v1 wire schema persists the message list (with per-message Extra preservation, P1.6) and the pending deferred corrections. The engine wraps this payload in its session-state envelope (internal/agent/state.go), which adds the loop counters.

func NewConversation

func NewConversation(messages []Message) *Conversation

NewConversation builds a Conversation over a copy of messages (engine seam).

func (*Conversation) Append

func (c *Conversation) Append(m Message)

Append adds m to the end of the history — the engine's per-Turn commit of a user, assistant, or tool-result message, and the natural primitive a history-rewrite hook uses to grow the conversation (a summary, a gap note). It is Insert at Len with a name that reads at the call site.

func (*Conversation) AssistantBoundaries

func (c *Conversation) AssistantBoundaries() []int

AssistantBoundaries are the indices of assistant messages — the only safe cut points, because a tool result must stay adjacent to the assistant call that produced it (strict chat templates).

func (*Conversation) At

func (c *Conversation) At(i int) Message

At returns the message at index i (panics on an out-of-range index, like a slice).

func (*Conversation) ClearDeferred

func (c *Conversation) ClearDeferred()

ClearDeferred discards all pending deferred corrections. A Deferred Response Action is a decision about the NEXT request of the SAME conversation flow, so the loop clears the queue whenever an Exchange ends (completeTurn's Exchange-complete branch, abandonTurn, AbortExchange): a stale fan-out directive must never survive a fault or abort into the next Exchange (F6).

func (*Conversation) Defer

func (c *Conversation) Defer(inject string)

Defer records a deferred correction (the Inject payload of an ActionDefer PostResponseDecision) to be injected, role-safe, into the next request. It is held in conversation state so it survives a snapshot/resume boundary — the streaming feed-forward path (design §4.1).

func (*Conversation) DeferredLen

func (c *Conversation) DeferredLen() int

DeferredLen reports how many deferred corrections are currently queued — the loop reads it after draining a request to capture the floor a cancelled Turn's own deferrals are truncated back to (TruncateDeferred), so a re-attempt restores only the drained injections (F6).

func (*Conversation) DropRange

func (c *Conversation) DropRange(start, end int)

DropRange drops messages in [start, end) — history truncation drops the middle, keeping the prefix and a recent tail. Bounds are clamped; an empty range is a no-op.

func (*Conversation) Insert

func (c *Conversation) Insert(i int, m Message)

Insert places a message at index i — e.g. a static gap note at a truncation cut. i is clamped to [0, Len].

func (*Conversation) Len

func (c *Conversation) Len() int

Len reports the number of messages.

func (*Conversation) MarshalJSON

func (c *Conversation) MarshalJSON() ([]byte, error)

MarshalJSON serializes the Conversation (messages + pending deferred corrections).

func (*Conversation) Messages

func (c *Conversation) Messages() []Message

Messages returns a copy of the message list (engine seam — the loop projects it onto the provider wire shape).

func (*Conversation) PrefixEnd

func (c *Conversation) PrefixEnd() int

PrefixEnd is the index past the leading system messages and the first user message — the protected prefix a truncation must keep.

func (*Conversation) Range

func (c *Conversation) Range(fn func(i int, m Message) bool)

Range iterates messages until fn returns false.

func (*Conversation) Replace

func (c *Conversation) Replace(msgs []Message)

Replace swaps the entire message list — generative Compaction writes its summarised history back through here. The slice is copied.

func (*Conversation) Revision

func (c *Conversation) Revision() int

Revision reports how many mutations have been applied to the Conversation — the loop's acted-fire probe (R4, engine seam): hookrun snapshots it around each catalogued history-rewrite fire and books the fire only when the counter moved. A hook never needs it, and it does not survive a snapshot round-trip.

func (*Conversation) SetMessageContent

func (c *Conversation) SetMessageContent(i int, content string)

SetMessageContent edits one message's content in place by index. An out-of-range index is a no-op.

func (*Conversation) TakeDeferred

func (c *Conversation) TakeDeferred() (injects []string, ok bool)

TakeDeferred removes and returns the pending deferred corrections in FIFO order — the loop drains them when building the next request and InjectContexts each. ok is false when none are pending.

func (*Conversation) TruncateDeferred

func (c *Conversation) TruncateDeferred(n int)

TruncateDeferred drops every deferred correction past the first n, keeping the queue's first n entries. n is clamped to [0, len]. The loop calls it when rolling a cancelled Turn back: the deferrals the cancelled Turn's own post-response hooks queued die with the Turn, so restoreDeferred re-queues the drained injections exactly once rather than atop a contradictory re-derivation (F6).

func (*Conversation) UnmarshalJSON

func (c *Conversation) UnmarshalJSON(data []byte) error

UnmarshalJSON restores a Conversation from its serialized form.

type ConversationView

type ConversationView interface {
	Len() int
	At(i int) Message
	Range(fn func(i int, m Message) bool)
	// LastUser returns the most recent user message and its index.
	LastUser() (msg Message, index int, ok bool)
	// CallByID resolves a tool result to its originating call (for the name/args).
	CallByID(id string) (call ToolCall, index int, ok bool)
	// ResultFor resolves a tool call to its result message.
	ResultFor(callID string) (msg Message, index int, ok bool)
}

ConversationView is read-only history with the tool-call/result pairing helpers every history-inspecting Mechanism needs: the tool name and arguments live only on the originating ToolCall, never on the tool-result message, so resolving a result back to its call is mandatory for error-handling Mechanisms.

type ErrorEvent

type ErrorEvent struct {
	EventBase
	Source string // tool name / mechanism ID / "loop"
	Err    string
}

ErrorEvent reports a localised, recovered fault — a tool or Mechanism panic caught at the extension boundary, or a tool execution error (ADR 0007). It does not imply the loop stopped.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is the sealed sum type of everything the loop reports. It is sealed (an unexported marker) so the variant set stays owned by Apogee and additively versioned; external code switches on the concrete types but cannot add variants.

type EventBase

type EventBase struct {
	Depth int
	Turn  int
}

EventBase is embedded in every Event variant. Depth is the sub-agent nesting level (0 = top-level agent); a sub-agent's events nest into the parent's stream with Depth > 0 (ADR 0005). Turn is the Turn index the event belongs to.

It is exported so the engine and other internal subsystems can construct Event variants (setting Turn/Depth), but it is deliberately NOT re-exported by the root facade: the sealing method eventDepth() stays unexported in this package, so no package outside internal/* can satisfy Event — the variant set remains closed.

type EventSink

type EventSink interface {
	Emit(Event)
}

EventSink receives typed Events as the loop produces them, including *inside* a Step (streaming). The TUI adapts these to Bubble Tea messages; the bench consumes them as Go values. Emit must not block the loop for long — fan out if needed.

type ExchangeView

type ExchangeView struct {
	// contains filtered or unexported fields
}

ExchangeView is the current Exchange derived from a conversation read surface: the opening user message and the messages strictly after it. It is a working value — construct it where needed with CurrentExchange and let it go; it holds the backing reader, so derive it again after a mutation rather than keeping one across edits.

func CurrentExchange

func CurrentExchange(c messageReader) ExchangeView

CurrentExchange derives the current Exchange from c: the opening is the last RoleUser message, the Exchange the messages strictly after it. With no user message present there is no current Exchange (Found reports false).

func (ExchangeView) After

func (e ExchangeView) After() []Message

After returns copies of the messages strictly after the opening user message — the current Exchange's body. It returns nil when no user message exists or nothing follows the opening.

func (ExchangeView) Found

func (e ExchangeView) Found() bool

Found reports whether an opening user message exists — without one there is no current Exchange.

func (ExchangeView) RangeAfter

func (e ExchangeView) RangeAfter(fn func(i int, m Message) bool)

RangeAfter walks the messages strictly after the opening user message without allocating, calling fn with each message's index in the backing view, until fn returns false. It is a no-op when no user message exists.

func (ExchangeView) UserIndex

func (e ExchangeView) UserIndex() int

UserIndex returns the index of the opening user message, or -1 when no user message exists.

type ExternalEffectKind

type ExternalEffectKind string

ExternalEffectKind classifies a non-forkable external effect.

const (
	EffectNetwork ExternalEffectKind = "network"
	EffectMCP     ExternalEffectKind = "mcp"
)

type ExternalEffectTool

type ExternalEffectTool interface {
	Tool
	ExternalEffect() ExternalEffectKind
}

ExternalEffectTool is an optional interface a Tool implements when it reaches state Apogee does not own (network, MCP). The loop routes these through Config.ExternalEffects so the bench can stub them deterministically (ADR 0008), and the Confiner/Approval gate treats them as unconfinable in Auto (ADR 0004).

type ExternalEffects

type ExternalEffects interface {
	Do(ctx context.Context, call ToolCall) (ToolResult, error)
}

ExternalEffects is the single injectable boundary for non-forkable external effects (ADR 0008). Production uses a live implementation; the bench injects a deterministic stub (network-unreachable / empty-MCP) without touching tool code.

type FingerprintConfidence

type FingerprintConfidence int

FingerprintConfidence tags how strongly a ModelFingerprint identifies the model behind the Upstream. The Library keys learned observations on a fingerprint and gates injection on this tier ("prefer not to inject under uncertainty", CONTEXT "Library"): a low-confidence identity is easily aliased — two different builds can advertise the same label — so an observation keyed there is the weakest evidence and an inject Mechanism may decline it.

const (
	// ConfidenceLow is a metadata label: the model id the Upstream advertises. It is the
	// always-available fallback, and the weakest tier because two distinct builds can share
	// one label (CONTEXT: "keyed on the model name" was the predecessor's gap).
	ConfidenceLow FingerprintConfidence = iota

	// ConfidenceMedium is a behavioral-probe identity (`apogee probe`, Phase 5). The slot
	// exists so the store format and the FingerprintResolver seam are forward-compatible;
	// no resolver produces it yet — the probe is explicitly out of scope for Phase 4 (D8).
	ConfidenceMedium

	// ConfidenceHigh is a weights-hash: a digest derived from the reachable model file, so
	// two builds that share a label but differ in weights resolve to distinct fingerprints.
	ConfidenceHigh
)

func (FingerprintConfidence) String

func (c FingerprintConfidence) String() string

String renders the confidence tier for logging and diagnostics.

type FingerprintResolver

type FingerprintResolver interface {
	// Resolve returns the best-available fingerprint for modelID. A resolver that cannot
	// identify the model returns the zero ModelFingerprint rather than an error — an
	// unidentified model simply leaves the Library inert.
	Resolve(modelID string) ModelFingerprint
}

FingerprintResolver resolves the model behind the Upstream to a confidence-tagged ModelFingerprint. It is the seam for the three identity tiers: a production resolver returns the best available (a weights-hash when the model file is reachable, else the metadata label), and the Phase-5 behavioral probe (ConfidenceMedium) slots in behind this same interface without the loop changing (D8). Domain declares the seam; internal/library implements it (ADR 0010 — the dependency points at domain).

type FinishReason

type FinishReason string

FinishReason is the model's stop reason; the set is open (treat unknown values defensively).

const (
	FinishStop      FinishReason = "stop"
	FinishLength    FinishReason = "length"
	FinishToolCalls FinishReason = "tool_calls"
)

type HistoryRewriter

type HistoryRewriter interface {
	RewriteHistory(ctx context.Context, conv *Conversation) error
}

HistoryRewriter edits conversation state — the home of truncate_history. A capability that may attach at more than one point (CONTEXT: Hook point). The Conversation is itself the history, so this hook reads and mutates it directly.

type HookPoint

type HookPoint string

HookPoint is where in the loop a Mechanism fires — the primary classification (CONTEXT: Hook point). The set is fixed by the loop's structure.

const (
	HookPreRequest     HookPoint = "pre-request"      // shape the outgoing request
	HookPostResponse   HookPoint = "post-response"    // inspect response, choose an action
	HookPreToolExec    HookPoint = "pre-tool-exec"    // between decision-to-run and execution
	HookPostToolResult HookPoint = "post-tool-result" // act on a result before the model sees it
	HookHistoryRewrite HookPoint = "history-rewrite"  // edit conversation state (may attach widely)
)

type LoopView

type LoopView interface {
	Conversation() ConversationView
	Tools() []ToolDef
	Budget() Budget
	Turn() int
	// Depth reports the sub-agent nesting level the hook is firing at: 0 for a top-level
	// Agent, parent+1 for a sub-agent (ADR 0013). It is the seam a gate keyed on "only at
	// the top level" needs — guided decomposition steers only the primary call, never a
	// nested delegation it itself set up (ADR 0014 §5). A view built without a depth (a test
	// fake, the degraded no-view Response) reports 0, the top-level default.
	Depth() int
	// Fired reports how many times a Mechanism has ACTED this Session (R4): an
	// invocation is booked only when it mutated its working value or returned a
	// non-zero post-response Action — an inspect-and-do-nothing invocation is not a
	// fire. It is the seam for cross-Mechanism coupling (e.g. decompose muting itself
	// once a read-loop Mechanism has fired) without a shared mutable meta map. An
	// experimental hook's synthetic ID keeps counting every invocation (bench
	// observability).
	Fired(id MechanismID) int
}

LoopView is the read-only window every hook has onto loop state beyond its own mutable value — the conversation so far, the tool menu, the budget, the Turn index, and a self-regulation query. It is the home of all cross-Turn reads: most Mechanisms decide by aggregating across Turns, so the primary mutable value (a *Response, *ToolCall, *ToolResult) is never sufficient alone. Request and Response expose it via their View method; the tool-stage hooks receive it as an argument.

type Mechanism

type Mechanism interface {
	Descriptor() MechanismDescriptor
	Ordering() OrderingConstraints
}

Mechanism is a catalogued unit of gated, self-regulating behaviour (CONTEXT: Mechanism). It supplies a descriptor and ordering constraints, and implements at least one hook interface above; the registry type-asserts which. A hook without a descriptor is an experimental hook (no self-regulation — ADR 0002).

type MechanismDescriptor

type MechanismDescriptor struct {
	ID          MechanismID
	Capability  Capability
	Suppression SuppressionPolicy
	// IncompatibleWith constrains stacking — Mechanisms that must not co-fire.
	IncompatibleWith []MechanismID
	// Requires constrains stacking the other way — Mechanisms that must all be
	// registered for this one to be enabled (the dual of IncompatibleWith). It is the
	// enable-time declaration that this Mechanism is benched as a stack with its named
	// peers: enabling it without them is a startup error (ValidateRequirements, ADR 0014
	// §4). Enable-time only — live suppression of a required peer mid-Session is not
	// re-checked.
	Requires []MechanismID
}

MechanismDescriptor is per-Mechanism metadata orthogonal to its hook point (CONTEXT: Mechanism descriptor). The single source of truth for what Bypass turns off (by Capability) and what may co-fire (IncompatibleWith).

type MechanismFiredEvent

type MechanismFiredEvent struct {
	EventBase
	Mechanism MechanismID
	Hook      HookPoint
	Action    string // e.g. the PostResponseDecision taken, or "suppressed"
}

MechanismFiredEvent reports that a Mechanism (or experimental hook) fired at a hook point — the observability spine for self-regulation and bench attribution.

type MechanismID

type MechanismID string

MechanismID is the canonical, stable identifier of a Mechanism — also the stable tiebreak in the deterministic total order (ADR 0003).

const ExperimentalMechanismID MechanismID = "experimental"

ExperimentalMechanismID is the synthetic MechanismID a descriptor-less experimental hook fires under (ADR 0002 — no descriptor, no self-regulation). It exists so MechanismFiredEvent.Mechanism is never empty for bench attribution, and it is RESERVED: Add refuses a catalogued Mechanism claiming it, so a real Mechanism can never masquerade as the bench's own instrument or inherit its always-booked fire accounting (R5, phase-4-review-fixes item 4).

type MechanismRegistry

type MechanismRegistry struct {
	// contains filtered or unexported fields
}

MechanismRegistry is the injectable catalogue plus the bench's experimental-hook slots (ADR 0002/0003). The built-in catalogue is curated; Add is how internal Mechanisms join, AddExperimental is how the bench registers a candidate hook.

func NewMechanismRegistry

func NewMechanismRegistry() *MechanismRegistry

NewMechanismRegistry returns a registry seeded with the built-in catalogue. The Phase-0 catalogue is empty — the curated Mechanisms land with the catalogue→hook mapping session (Phase 4); P0.6 needs only the experimental-hook slots.

func (*MechanismRegistry) Add

func (r *MechanismRegistry) Add(m Mechanism) error

Add registers a catalogued Mechanism. It returns an error if the Mechanism claims the reserved experimental sentinel ID, re-uses an already-registered MechanismID (topoSort's byID map would otherwise silently drop one of the two — a loud failure instead, phase-4-review-fixes item 5), or implements no hook interface. (The constraint-cycle check is performed by New over the whole graph — a startup gate, ADR 0003 — so a registry under construction can hold constraints that only close a cycle once every Mechanism is present.)

func (*MechanismRegistry) AddExperimental

func (r *MechanismRegistry) AddExperimental(at HookPoint, hook any) error

AddExperimental registers a bench experimental hook at a hook point — a behaviour that is not (yet) a Mechanism (CONTEXT: Experimental hook). It runs but does not join self-regulation. hook must implement the interface for at.

func (*MechanismRegistry) Experimental

func (r *MechanismRegistry) Experimental(at HookPoint) []any

Experimental returns the experimental hooks registered at hook point at, in registration order. It is the read seam the engine drives the loop through without reaching into the registry's unexported storage (ADR 0010 — internal subsystems see domain through its methods, the same way the public surface does).

func (*MechanismRegistry) Ordered

func (r *MechanismRegistry) Ordered(at HookPoint) []Mechanism

Ordered returns the catalogued Mechanisms that hook at at, in the deterministic total order the loop dispatches them (ADR 0003 / D4): a topological sort of their Before/After constraints with a stable tiebreak by canonical MechanismID, so the order is independent of registration order. Only Mechanisms implementing the interface for at are returned; a constraint naming a Mechanism absent from at is ignored (ordering is relative to the co-located Mechanisms). It is the read seam the engine dispatches catalogued Mechanisms through, the counterpart to Experimental for the descriptor-carrying catalogue.

func (*MechanismRegistry) ValidateIncompatibilities

func (r *MechanismRegistry) ValidateIncompatibilities() error

ValidateIncompatibilities reports ErrIncompatibleMechanisms if two registered Mechanisms declare each other incompatible (MechanismDescriptor.IncompatibleWith). It is the second construction-time gate alongside ValidateOrdering — a loud startup failure (ADR 0003), so a config enabling two mutually-exclusive Mechanisms is refused rather than silently running both. New calls it once the whole graph is present.

func (*MechanismRegistry) ValidateOrdering

func (r *MechanismRegistry) ValidateOrdering() error

ValidateOrdering reports ErrOrderingCycle if the catalogued Mechanisms' Before/After constraints form a cycle (ADR 0003 — a constraint cycle is a startup error). New calls it once the whole graph is present.

func (*MechanismRegistry) ValidateRequirements

func (r *MechanismRegistry) ValidateRequirements() error

ValidateRequirements reports ErrMissingRequirement if any registered Mechanism declares a required peer (MechanismDescriptor.Requires) that is not itself registered. It is the third construction-time gate alongside ValidateOrdering and ValidateIncompatibilities — the dual of the incompatibility check: where incompatibility refuses two Mechanisms that must never co-fire, this refuses a Mechanism enabled without a peer it is benched as a stack with, a loud startup failure (ADR 0003 posture, ADR 0014 §4). Enable-time only: live suppression of a required peer mid-Session is accepted and not re-checked. New calls it once the whole graph is present.

type Message

type Message struct {
	Role       Role
	Content    string
	ToolCalls  []ToolCall // RoleAssistant only
	ToolCallID string     // RoleTool only — links the result to its ToolCall.ID
	// contains filtered or unexported fields
}

Message is a read-only snapshot of one conversation message handed to hooks. A hook reads Messages and mutates by index against the owning container (Request / Conversation); it never holds the loop's backing storage.

func (Message) Extra

func (m Message) Extra(key string) (json.RawMessage, bool)

Extra reports a preserved unknown wire field on the message (reasoning_content, tool_choice, thinking, …). Round-trip preservation of these is load-bearing for snapshot/resume and the bench's fork, so they survive a history rewrite.

func (Message) MarshalJSON

func (m Message) MarshalJSON() ([]byte, error)

MarshalJSON serializes the Message as its known wire fields with any preserved Extra fields flattened alongside them. Known fields win on a key collision, so a stale extra entry can never shadow a real field. A Message with no extras takes the fast path and marshals straight from messageJSON.

The preserved siblings are spliced on in sorted key order rather than via a map marshal, so the wire bytes are deterministic regardless of Go's map iteration order — snapshots containing reasoning_content (or any other Extra) are byte-reproducible, which a later snapshot diff/hash relies on.

func (*Message) UnmarshalJSON

func (m *Message) UnmarshalJSON(data []byte) error

UnmarshalJSON restores a Message, decoding the known fields and collecting any unknown sibling fields into the preserved Extra set so they survive a snapshot round-trip.

func (Message) WithExtra

func (m Message) WithExtra(key string, v json.RawMessage) Message

WithExtra returns a copy of m carrying an additional preserved wire field under key. The engine attaches the model's reasoning channel (reasoning_content) to a committed assistant message this way, so it survives snapshot/resume; an empty key or value is a no-op. It copies the extra set, so a caller already holding the original Message is unaffected.

type MessageEvent

type MessageEvent struct {
	EventBase
	Text string
}

MessageEvent is a completed assistant message (the no-tool turn ends an Exchange).

type Mode

type Mode string

Mode is the autonomy level governing whether tool calls need human approval (CONTEXT: Agent mode). It is orthogonal to Config.Bypass.

const (
	// ModePlan is read-only: no writes, no command execution.
	ModePlan Mode = "plan"
	// ModeAskBefore requires an Approval for every write, command, and external reach
	// (a harmless read runs free).
	ModeAskBefore Mode = "ask-before"
	// ModeAllowEdits auto-approves Apogee's own workspace-scoped writes (path-safety-
	// bounded); shell/exec, network, MCP, third-party in-process tools, and any
	// out-of-workspace write still gate. It needs NO Confinement — path-safety bounds
	// the auto-approved writes and the human backstops the unbounded surface — so it is
	// identical on every OS (ADR 0012).
	ModeAllowEdits Mode = "allow-edits"
	// ModeAuto runs unbounded tool calls without per-call approval, tuned by
	// Config.ConfineToWorkspace (ADR 0012). With confinement on (the default), the
	// subprocess surface runs OS-confined to the workspace; an unfenceable tool (MCP) or
	// an out-of-workspace Apogee write still gates through Approval; if fs-confinement is
	// unavailable, the subprocess surface gates ("confine if you can, gate if you can't").
	ModeAuto Mode = "auto"
)

func NextMode

func NextMode(cur Mode) Mode

NextMode returns the mode one rung up the privilege ladder, wrapping Auto back to Plan. An unknown or empty mode starts the cycle at Plan (the safest rung), so a caller can never get stuck off-ladder.

func TighterMode

func TighterMode(a, b Mode) Mode

TighterMode returns the more restrictive of two autonomy modes — the one lower on the privilege ladder (Plan < Ask-Before < Allow-Edits < Auto). It is the sub-agent tighten-only helper (ADR 0013): a sub-agent's disposition takes the tighter of the parent's LIVE mode and the child's spawn mode, so a parent tightening mid-delegation (Shift+Tab down) reaches the still-running child, while a parent loosening can never loosen it. An off-ladder mode (empty/unknown) ranks with Ask-Before — the same safe default the dispatch disposition applies to an unrecognised mode — so a stray value can neither loosen nor over-tighten the result.

type ModelFingerprint

type ModelFingerprint struct {
	Label      string
	Confidence FingerprintConfidence
}

ModelFingerprint is the confidence-tagged identity the Library keys observations on (CONTEXT "Library"). Label is the resolved identity string — a weights-hash digest, a probe signature, or the bare metadata label — and Confidence records which tier produced it, because injection is gated on confidence. A zero ModelFingerprint (empty Label) means the model could not be identified; the Library treats it as inert (nothing to key on).

func (ModelFingerprint) IsZero

func (f ModelFingerprint) IsZero() bool

IsZero reports whether the fingerprint failed to identify the model (no Label). An inert Library (nothing to observe or inject against) is the zero-fingerprint case.

type ModelProfile

type ModelProfile struct {
	// ToolCallFormat selects how the model emits tool calls. "" and FormatNative both mean the
	// structured out-of-band tool_calls path (nothing to recover from visible content); a text
	// format (FormatMarkdownFenced / FormatCustomRegex) is parsed from the model's visible
	// content at the seam.
	ToolCallFormat ToolCallFormat

	// Pattern is the custom-regex tool-call pattern — mandatory for FormatCustomRegex, ignored
	// for the other formats. Named capture groups name the tool and its arguments; the parser's
	// own group/flag defaults apply at the boundary when its finer knobs are unset.
	Pattern string

	// Thinking selects the model's inline reasoning-channel style. A zero Thinking (ThinkingNone)
	// leaves the Upstream-split reasoning_content path untouched (the default).
	Thinking ThinkingProfile
}

ModelProfile describes how a given small model speaks the wire (CONTEXT: Model profile): two ORTHOGONAL axes — its tool-call format and its inline thinking-channel style (a model can emit native tool calls AND inline thinking; gpt-oss does both). It is declarative domain DATA on Config (host- or embedder-settable) that the loop translates to the internal/processing parsers at the parse seam, not the parsers' own config types — those cannot move up the dependency DAG because internal/processing imports domain (ADR 0010), and profile-as-data snapshots cleanly and seeds the deferred switchable-profile / `apogee probe` work. A ZERO ModelProfile == native tool calls, no inline thinking == today's exact behaviour (the byte-identical anchor).

type OrderingConstraints

type OrderingConstraints struct {
	Before []MechanismID
	After  []MechanismID
}

OrderingConstraints declares a Mechanism's position relative to others at its hook point (ADR 0003 — seeded from apogee-sim's type, now owned here). The loop builds a deterministic total order by topological sort with a stable tiebreak by MechanismID; a constraint cycle is a startup error (ErrOrderingCycle).

type PostResponseAction

type PostResponseAction string

PostResponseAction enumerates the post-response decisions.

const (
	ActionRetry     PostResponseAction = "retry"     // re-call the Upstream now
	ActionIntercept PostResponseAction = "intercept" // alter the response before the loop acts
	ActionDefer     PostResponseAction = "defer"     // schedule a correction into the next request
)

type PostResponseDecision

type PostResponseDecision struct {
	Action PostResponseAction
	// Inject is the correction text — injected into the retried request for
	// ActionRetry, or the next request for ActionDefer (role-safe, like
	// Request.InjectContext). Empty carries no correction (for ActionRetry, a bare
	// re-stream).
	Inject string
}

PostResponseDecision is the action a post-response Mechanism chooses (CONTEXT: Post-response decision). ActionIntercept is expressed by mutating the *Response in place (SetText / SetToolCallArguments) and carries no payload. ActionRetry re-calls the Upstream in the same Turn; a non-empty Inject makes it a correction retry — the loop re-streams the request with the superseded assistant message and the correction appended, request-scoped, never committed to history (R1, amending catalogue C5). ActionDefer carries the correction into the *next* request — the feed-forward path — held in conversation state as a Deferred Response Action so it survives a snapshot boundary.

type PostResponseHook

type PostResponseHook interface {
	PostResponse(ctx context.Context, resp *Response) (PostResponseDecision, error)
}

PostResponseHook inspects the model response (resp.View() for history and the tool menu) and chooses an action — mutating resp in place for ActionIntercept, or returning ActionRetry / ActionDefer.

type PostToolResultHook

type PostToolResultHook interface {
	PostToolResult(ctx context.Context, call ToolCall, result *ToolResult, view LoopView) error
}

PostToolResultHook acts on a tool result before the model next sees it — the home of correct_tool_result, new to the loop (the proxy could not host it). It receives the originating call (the tool name and arguments live there, not on the result) and the loop view (error handling often counts prior failures across Turns).

type PreRequestHook

type PreRequestHook interface {
	PreRequest(ctx context.Context, req *Request) error
}

PreRequestHook shapes the outgoing request before it is sent. It reads the conversation, tool menu, and budget through req.View() and mutates the request in place (the request being built is the conversation as it will be sent).

type PreToolExecHook

type PreToolExecHook interface {
	PreToolExec(ctx context.Context, call *ToolCall, view LoopView) error
}

PreToolExecHook acts between the decision to run a tool and its execution. It receives the loop view because the decision is usually cross-Turn (e.g. short-circuiting a re-read of a file already read earlier needs the read history).

type PresentMethod

type PresentMethod string

PresentMethod names the presentation-ladder rung that carried a document to the user. The tool result echoes it so the model can tell the user the truth ("opened on your machine" vs. "the path is shown in the transcript") rather than claiming an outcome it never saw. The set is open (additively extensible — treat unknown values defensively).

const (
	// PresentOpened: the host opened the document on the user's own machine — the OS opener
	// (rung 1) or the configured present.command (rung 3).
	PresentOpened PresentMethod = "opened"
	// PresentServed: the document is registered with the doc server and its URL joined the
	// transcript entry (rung 2), for the user's terminal to linkify into the host's browser.
	PresentServed PresentMethod = "served"
	// PresentShown: the baseline alone (rung 0) — the workspace-relative path stands in the
	// transcript for the user to open. It is equally the outcome of a degraded higher rung,
	// which is why it is a normal result and not an error.
	PresentShown PresentMethod = "shown"
)

type PresentOutcome

type PresentOutcome struct {
	// Method is the rung that ran — the highest one that succeeded, degraded to PresentShown
	// when everything above the baseline failed or did not apply.
	Method PresentMethod

	// Location is where the user finds the document: the served URL for PresentServed, the
	// DisplayPath otherwise.
	Location string
}

PresentOutcome reports which rung of the presentation ladder actually carried the document to the user. A STRUCT for the same freeze-safety reason as PresentRequest, and the reason the tool never has to assert a success it cannot observe: it relays this outcome verbatim.

type PresentRequest

type PresentRequest struct {
	// Path is the ABSOLUTE path of the document to present. The tool has already resolved it
	// inside the workspace root and confirmed it is an existing regular file, so a Presenter
	// receives a path it may hand straight to a mechanism.
	Path string

	// DisplayPath is Path in its workspace-relative form — the text the transcript carries as
	// plain text on its own line, for the terminal (Zed / VS Code / iTerm2 / WezTerm / kitty)
	// to linkify. It is display-only: mechanisms use Path.
	DisplayPath string

	// Title is an optional human label for the document; it MAY be empty. The host renders it
	// above the path when set.
	Title string
}

PresentRequest is the document put in front of the user. It is a STRUCT (not a bare path) for freeze-safety (D7), the same reason AskRequest is one: a post-v1 field (a content-type hint, a "reveal in folder" flag) is then an additive, non-breaking change to the v1.0.0 surface.

type Presenter

type Presenter interface {
	Present(ctx context.Context, req PresentRequest) (PresentOutcome, error)
}

Presenter is the host-supplied delegate the present_document tool routes a finished deliverable to: the model names a document it has just written and the HOST decides how the user sees it (the presentation ladder — the transcript baseline always, the OS opener when the session is local and a desktop exists, a doc-server URL when it is remote, a user-configured command when one is set). The model supplies a path, never a mechanism.

It is the sibling of Asker (P3.11): the same host-decides delegate shape, for showing a document rather than asking a question. Like Asker it is NOT a safety gate — a Presenter carries no allow/deny semantics and never bypasses the Approval/disposition machinery — and it is MODE-INDEPENDENT: presenting writes nothing, so the tool is ReadOnly and runs in every mode, Plan included.

It is consulted synchronously inside a Step (on the worker goroutine) but, unlike Asker, it awaits no human rendezvous and must never block on the user. It must FAIL SAFE under cancellation: when ctx is cancelled it returns promptly rather than finishing a mechanism, so a cancelled Turn is never held open by a presentation.

A nil Presenter means the present_document tool is simply NOT REGISTERED (graceful), so a headless / non-interactive host never offers the model an affordance nobody can honour.

Fail visible, degrade to the baseline (ADR 0019): a mechanism that fails — no opener on this box, a doc server that cannot bind — is not an error, because the baseline rung has already put the path in front of the user. An implementation reports the rung it actually reached (PresentShown) instead; an error is reserved for a presentation that reached the user in no form at all.

type ReadOnlyTool

type ReadOnlyTool interface {
	Tool
	ReadOnly() bool
}

ReadOnlyTool is an optional interface a Tool implements to declare that it performs no writes. It is the signal Plan mode filters on (only read-only tools run) and that Ask-Before uses to skip Approval for a harmless read. A Tool that does not implement it — or implements it returning false — is treated as write-capable, the safe default that gates. IsReadOnly is the helper the loop should call rather than the type assertion directly.

type ReasoningEvent

type ReasoningEvent struct {
	EventBase
	Text string
}

ReasoningEvent is one newly-revealed chunk of the model's reasoning channel — the observability seam for "the model is thinking", which the visible TokenEvent stream by design never shows. It is emitted for BOTH reasoning paths: the provider's native channel (reasoning_content) and an inline <think>/harmony span held off the visible stream. Chunks arrive in order and concatenate to the reasoning the Turn's assistant message preserves; a Turn that reasons without emitting visible text produces ReasoningEvents and no TokenEvents. The concatenation is a liveness view, not a byte-exact copy: on the inline path a channel token split across deltas is revealed as span text while it accumulates, so the chunks can carry a partial closer (e.g. "secret</thi") that the completed token later removes from the preserved reasoning.

It is OBSERVATION ONLY: it never changes history or what the model receives. The reasoning channel is already preserved on the committed assistant message (reasoning_content), so an observer that ignores this event loses nothing but liveness. Arrival alone is a usable signal — a UI may render "thinking" from the event and never read Text at all.

Text is untrusted model output. Any consumer that DISPLAYS it must escape-strip it exactly as the TUI's token path (transcript.appendToken) does before it reaches a terminal; the raw chunk may carry ESC bytes.

type Request

type Request struct {
	// contains filtered or unexported fields
}

Request is the outgoing Upstream request a pre-request hook may shape. Reads go through View; mutations are the characterised operation set from apogee-sim's pre-request Mechanisms. The loop builds one with NewRequest, hands it to every pre-request hook (their mutations compose), then drains it with State to project onto the provider wire shape.

func NewRequest

func NewRequest(model string, messages []Message, tools []ToolDef, budget Budget, turn int, fired map[MechanismID]int) *Request

NewRequest builds the pre-request working value from loop state (engine seam). The messages and tools slices are copied, so a hook mutating the Request never reaches back into the loop's conversation storage. fired, in contrast, is shared BY REFERENCE: it is the loop's live per-Session fire ledger LoopView.Fired reads, so a Mechanism can see a peer's fire from earlier in the same hook pass (the decompose↔read_loop coupling seam). It is only ever read through the view — no view operation mutates it — so the shared reference is safe. nil is fine (Fired then reports 0 for every Mechanism).

func (*Request) AppendSupersededAssistant

func (r *Request) AppendSupersededAssistant(text string, calls []ToolCall)

AppendSupersededAssistant appends a superseded assistant message (text + tool calls) to the end of the request — the loop's retry-exchange seam (engine seam, R1), NOT a hook-mutation primitive: on an ActionRetry correction the loop appends the response it is retrying, then the correction via InjectContext, so the re-streamed request carries the exchange the sim's retry builders carried. The append is request-scoped — it is never committed to history. A wholly empty superseded response (empty text, no calls) appends nothing. calls is copied, so the caller's slice stays independent.

The FIRST call freezes committedLen at the current length (item 10, sim parity): this superseded attempt, its correction, and every later accumulated retry are request-scoped and stay out of the post-response scanners' View(). It is frozen once — not advanced per retry — because the sim's detectors ran against the ORIGINAL committed request on every retry iteration, so the scanner view stays pinned to the pre-retry length throughout. The freeze precedes the empty-response short-circuit, so an empty superseded + a correction is bounded too.

func (*Request) AppendToSystem

func (r *Request) AppendToSystem(marker, text string) (injected bool)

AppendToSystem appends text to the first system message (creating one if absent), but is a no-op if marker already occurs there — the idempotent inject the nudge Mechanisms (library, cot, decompose) share. Reports whether it injected. The caller embeds marker within text so a second call with the same marker is a no-op.

func (*Request) Extra

func (r *Request) Extra(key string) (json.RawMessage, bool)

Extra reports a preserved unknown request field (e.g. a grammar Mechanism checks for an existing response_format before setting one).

func (*Request) InjectContext

func (r *Request) InjectContext(text string)

InjectContext inserts a user message at the role-safe position: appended to the system prompt if the conversation ends in a tool result (a user message after a tool result breaks strict chat templates); appended at the end if it ends in an assistant message (the retry-exchange shape — the correction answers the superseded assistant message it follows, R1); otherwise inserted before the last user message. With no user message present it appends at the end.

func (*Request) Model

func (r *Request) Model() string

Model is the target model id (the Library keys its lookup on this).

func (*Request) Revision

func (r *Request) Revision() int

Revision reports how many mutations have been applied to the Request — the loop's acted-fire probe (R4, engine seam): hookrun snapshots it around each catalogued fire and books the fire only when the counter moved. A hook never needs it.

func (*Request) SetDepth

func (r *Request) SetDepth(depth int)

SetDepth records the sub-agent nesting level this request runs at (engine seam, ADR 0013/0014): the loop stamps it from Agent.depth so a pre-request hook reading req.View().Depth() can tell a top-level (0) from a nested request. It is loop setup, not a hook mutation — it carries no acted-fire meaning and so does NOT bump the revision. A Request built without it reports Depth 0, the top-level default.

func (*Request) SetExtra

func (r *Request) SetExtra(key string, v json.RawMessage)

SetExtra sets an unknown request field, allocating the carrier if needed (e.g. a grammar constraint sets response_format).

func (*Request) SetMessageContent

func (r *Request) SetMessageContent(index int, content string)

SetMessageContent edits one message's content in place by index — tool-result capping and history-collapse of older messages. An out-of-range index is a no-op.

func (*Request) SetSampling

func (r *Request) SetSampling(p SamplingParams)

SetSampling overrides sampling parameters. Forward-looking — no current Mechanism mutates these; included so the surface need not change to add one.

func (*Request) SetTools

func (r *Request) SetTools(tools []ToolDef)

SetTools replaces and reorders the tool menu (the tool-filter Mechanism). The slice is copied so the caller cannot mutate the menu after the call.

func (*Request) State

func (r *Request) State() RequestState

State returns the Request's current state after any hook mutations (engine seam). The slices and the extras map are copies, so the loop's projection cannot disturb the Request and a later hook (none run after the drain today) would still see a faithful value.

func (*Request) View

func (r *Request) View() LoopView

View exposes the read-only conversation/tools/budget window. The conversation is bounded to committedLen once a retry-in-place has appended a superseded exchange (item 10): the post-response scanners then see only committed history + the response under review, never the request-scoped superseded attempt/correction — matching the sim, whose retry builders ran their detectors against the unmutated request. The tool menu and budget are unbounded.

type RequestState

type RequestState struct {
	Model    string
	Messages []Message
	Tools    []ToolDef
	Sampling SamplingParams
	Extras   map[string]json.RawMessage
}

RequestState is the post-hook state of a Request the loop reads to build the provider request (engine seam). Hooks shape the Request through its mutators and never call State.

type ResolvedSkill

type ResolvedSkill struct {
	ID          string
	DisplayName string
	Body        string
}

ResolvedSkill is one attached skill reduced to the fields the loop injects: the ID and DisplayName label the prepended block, and Body is the skill's instruction text scoped to the turn it was attached to.

type Response

type Response struct {
	// contains filtered or unexported fields
}

Response is the model response a post-response hook inspects and may intercept. The loop builds one with NewResponse from the parsed Upstream reply; reads go through the accessors, and ActionIntercept is expressed by mutating in place.

func NewResponse

func NewResponse(text, thinking string, toolCalls []ToolCall, finish FinishReason, view LoopView) *Response

NewResponse builds the post-response working value from the parsed reply (engine seam). view is the read window onto the conversation+tools+budget the response was produced against; a nil view degrades to an empty one so View never returns nil.

func (*Response) AppendToolCall

func (r *Response) AppendToolCall(call ToolCall)

AppendToolCall appends a synthesized tool call to the response and bumps the revision — the intercept seam a post-response Mechanism uses to add a delegation the model did not itself emit (guided decomposition synthesizing the first sub_agent call from the model's enumeration, ADR 0014 §2). The appended call is indistinguishable from a model-emitted one downstream: the loop reads it back through ToolCalls(), records it on the committed assistant message, and dispatches it through the full per-call Resolution — the ADR 0013 recursion point for a sub_agent call. The caller owns the call's ID (the loop's synthesized-call style) and arguments; combined with a returned ActionDefer the appended call and the deferred correction both take effect (hookrun applies the in-place mutation, then routes the defer).

func (*Response) FinishReason

func (r *Response) FinishReason() FinishReason

FinishReason is the model's stop reason.

func (*Response) Revision

func (r *Response) Revision() int

Revision reports how many mutations have been applied to the Response — the loop's acted-fire probe (R4, engine seam): hookrun snapshots it around each catalogued fire and books the fire only when the counter moved or a non-zero Action was returned. A hook never needs it.

func (*Response) SetText

func (r *Response) SetText(s string)

SetText replaces the assistant text — the intercept path (ActionIntercept).

func (*Response) SetToolCallArguments

func (r *Response) SetToolCallArguments(index int, args json.RawMessage)

SetToolCallArguments rewrites one tool call's arguments in place — the auto-fix Mechanism writing back repaired/formatted content (ActionIntercept). An out-of-range index is a no-op.

func (*Response) Text

func (r *Response) Text() string

Text is the assistant's raw text content.

func (*Response) Thinking

func (r *Response) Thinking() (text string, ok bool)

Thinking is the harmony/thinking channel content when the model and parser expose it (ok == false when there is none).

func (*Response) ToolCalls

func (r *Response) ToolCalls() []ToolCall

ToolCalls are the parsed tool calls the model requested (a copy; mutate via SetToolCallArguments).

func (*Response) View

func (r *Response) View() LoopView

View exposes the read-only conversation/tools/budget window — response-repair Mechanisms validate tool calls against the menu; loop detection reads history.

type Role

type Role string

Role is a conversation message's role.

const (
	RoleSystem    Role = "system"
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

type SamplingParams

type SamplingParams struct {
	Temperature *float64
	MaxTokens   *int
}

SamplingParams are the optional sampling overrides a pre-request hook may set; a nil field leaves the loop's value untouched.

type Session

type Session struct {
	Version int             // schema version; Resume rejects an unknown future version
	State   json.RawMessage // opaque serialized conversation state
}

Session is the serializable, copyable conversation state — no live handles, no process globals (ADR 0001). Deep-copying it yields an independent branch (the bench's fork primitive); Encode/Decode persist it (the user's resume feature).

func DecodeSession

func DecodeSession(data []byte) (Session, error)

DecodeSession deserializes a session, returning ErrSessionVersion if the schema version is newer than this build understands.

func (Session) Encode

func (s Session) Encode() ([]byte, error)

Encode serializes the session for storage.

type SkillResolver

type SkillResolver interface {
	// ResolveSkills returns the resolved skills for ids, in the given order, skipping any
	// unknown ID. The caller compares the result against what it requested to report a miss,
	// so a typo in an attached ID is never silently swallowed.
	ResolveSkills(ids []string) []ResolvedSkill
}

SkillResolver maps attached skill IDs to their injectable form. It is implemented by the skills catalog (internal/skills) and injected via Config.Skills; the interface lives in domain so the loop can fulfil the UserInput.SkillIDs seam without importing the skills package (ADR 0010 — the dependency flows toward domain).

type StepResult

type StepResult struct {
	Status    StepStatus
	TurnIndex int           // 0-based index of the Turn just completed
	Elapsed   time.Duration // wall time for this Turn
}

StepResult reports the outcome of one Step at the quiescent boundary.

type StepStatus

type StepStatus string

StepStatus is the disposition of a completed Step. The set is open (additively extensible — treat unknown values defensively).

const (
	// StatusTurnComplete: the Turn finished and more Turns are pending (the model
	// requested tools; the loop will continue on the next Step).
	StatusTurnComplete StepStatus = "turn-complete"
	// StatusExchangeComplete: the model produced a final no-tool response; the
	// Agent now awaits the next Submit.
	StatusExchangeComplete StepStatus = "exchange-complete"
	// StatusCancelled: ctx was cancelled; state is serializable, resume is valid.
	StatusCancelled StepStatus = "cancelled"
)

type StreamResetEvent

type StreamResetEvent struct {
	EventBase
}

StreamResetEvent signals that the assistant tokens streamed for the current Turn since the last boundary are superseded and must be discarded — the loop is re-streaming the Turn because an ActionRetry post-response decision re-called the Upstream. A streaming observer (the TUI) clears its in-progress token buffer for the Turn on this event; the MessageEvent that ends the Turn carries the final, accepted text.

type SubprocessTool

type SubprocessTool interface {
	Tool
	// Subprocess reports that this tool launches an OS subprocess. It exists so a tool
	// can implement the marker yet still report false (a degraded build), the safe
	// default being treated as a non-subprocess tool.
	Subprocess() bool
}

SubprocessTool is an optional interface a Tool implements to declare that it launches an OS subprocess (a shell, an interpreter, a child program) whose blast radius is the whole filesystem unless OS-confined — the unbounded surface ADR 0012 fences with the Confiner. The dispatch disposition keys on this marker to RUN such a tool inside Confiner.Confine in Auto with confine-to-workspace on (rather than gating it), and to gate it when fs-confinement is unavailable ("confine if you can, gate if you can't"). terminal / python-exec (P3.8) carry it; the in-process write tools do not (they are path-safety-bounded, not OS-confined). IsSubprocessTool is the helper the loop calls.

type SuppressionPolicy

type SuppressionPolicy string

SuppressionPolicy is how a Mechanism participates in self-regulation (CONTEXT: Adaptive Suppression, Off-ramp). Exempt off-ramps still earn their place by their own leave-one-out A/B (ADR 0006 / ADR 0009) — exempt-from-suppression is not exempt-from-validation.

const (
	SuppressStrikesThree SuppressionPolicy = "strikes-3" // suppressed after N non-helpful fires
	SuppressExempt       SuppressionPolicy = "exempt"    // never suppressed (off-ramps)
)

type ThinkingProfile

type ThinkingProfile struct {
	// Style selects the stripping strategy: ThinkingNone (no inline channel, the default),
	// ThinkingDelimited (a literal Start/End token pair), or ThinkingHarmony (gpt-oss channels,
	// which need no tokens).
	Style ThinkingStyle

	// Start and End are the literal delimiter tokens for ThinkingDelimited (e.g. "<think>" /
	// "</think>"); both must be set for stripping to run. They are ignored for the other styles.
	Start string
	End   string
}

ThinkingProfile selects a model's inline thinking-channel style (CONTEXT: Thinking channel): the private reasoning the loop strips from visible content and preserves as reasoning in history. A zero ThinkingProfile (ThinkingNone) means no inline channel — content passes through untouched, the right default when the Upstream already splits reasoning into a separate reasoning_content field.

type ThinkingStyle

type ThinkingStyle string

ThinkingStyle names a model's inline reasoning-channel format. "" is treated as ThinkingNone.

const (
	// ThinkingNone is the default: no inline channel (the model emits none, or the Upstream
	// already split reasoning into reasoning_content). "" is treated the same.
	ThinkingNone ThinkingStyle = "none"
	// ThinkingDelimited is a literal Start/End token pair bracketing reasoning (e.g.
	// <think>…</think>). The exact tokens vary per model and even per build — the live smoke
	// test found gemma-4-e4b-it-qat emits <|channel>thought…<channel|> — so Start/End must be
	// set to what the model actually emits, not assumed from the model family.
	ThinkingDelimited ThinkingStyle = "delimited"
	// ThinkingHarmony is gpt-oss's harmony channel format (<|channel|>analysis<|message|>…).
	ThinkingHarmony ThinkingStyle = "harmony"
)

type TokenEvent

type TokenEvent struct {
	EventBase
	Text string
}

TokenEvent is one streamed chunk of assistant text. The tokens streamed for a Turn may be superseded by a StreamResetEvent (the loop re-streamed the Turn on an ActionRetry): accumulate TokenEvents per Turn and discard the accumulation when a reset arrives.

type Tool

type Tool interface {
	// Name is the stable identifier the model calls and the registry keys on.
	Name() string
	// Description and Schema are presented to the model (the JSON-schema of args).
	Description() string
	Schema() json.RawMessage
	// Execute runs the call. It must honour ctx cancellation (ADR 0007) and the
	// statelessness contract above. A panic here is caught at the loop's extension
	// boundary and surfaced as an ErrorEvent.
	Execute(ctx context.Context, call ToolCall) (ToolResult, error)
}

Tool is the public, open extension point: embedders may register their own.

Contract — stateless across Turns (ADR 0008): a tool's only durable side effect is filesystem writes; nothing live (process, REPL, socket, cursor) survives the quiescent boundary. terminal and python-exec are one-shot (fresh process per call). A tool needing persistence must serialize it into conversation state, not hold it live — this is what makes snapshot/resume and the bench's fork coherent.

type ToolCall

type ToolCall struct {
	ID        string
	Tool      string
	Arguments json.RawMessage
}

ToolCall is a parsed request from the model to run a tool.

type ToolCallEvent

type ToolCallEvent struct {
	EventBase
	Call ToolCall
}

ToolCallEvent reports that the model requested a tool call (post-parse).

type ToolCallFormat

type ToolCallFormat string

ToolCallFormat identifies how a model emits tool calls, so the loop can select the matching parser at the seam. Its values mirror internal/processing's ToolCallFormat so the boundary translation is a straight map. "" is treated as FormatNative.

const (
	// FormatNative is the structured tool_calls path: calls arrive out-of-band and the text
	// parser finds nothing in the visible content ("" is treated the same).
	FormatNative ToolCallFormat = "native"
	// FormatMarkdownFenced is the markdown-fenced code-block tool-call format.
	FormatMarkdownFenced ToolCallFormat = "markdown-fenced"
	// FormatCustomRegex is the user-supplied named-group regex tool-call format (needs Pattern).
	FormatCustomRegex ToolCallFormat = "custom-regex"
)

type ToolDef

type ToolDef struct {
	Name        string
	Description string
	Schema      json.RawMessage // JSON-schema of arguments
}

ToolDef is one entry of the tool menu the model sees.

type ToolRegistry

type ToolRegistry struct {
	// contains filtered or unexported fields
}

ToolRegistry is the injectable set of available tools (ADR 0001 — injectable, no globals). A sub-agent receives a subset of the parent's registry, never a superset (ADR 0005). Registration order is preserved so the tool menu the model sees is deterministic across runs (load-bearing for the bench's reproducibility).

func NewToolRegistry

func NewToolRegistry() *ToolRegistry

NewToolRegistry returns an empty registry.

func (*ToolRegistry) All

func (r *ToolRegistry) All() []Tool

All returns the registered tools in registration order — the read seam the loop builds the model's tool menu from without reaching into unexported storage.

func (*ToolRegistry) Lookup

func (r *ToolRegistry) Lookup(name string) (Tool, bool)

Lookup returns the tool registered under name, and whether it was found — the seam the loop's dispatch resolves a parsed ToolCall against.

func (*ToolRegistry) Register

func (r *ToolRegistry) Register(t Tool) error

Register adds a tool, returning ErrDuplicateTool on a name already present and ErrInvalidTool on an empty name (the model keys calls on the name, so it must be a stable, non-empty identifier).

func (*ToolRegistry) Subset

func (r *ToolRegistry) Subset(names ...string) *ToolRegistry

Subset returns a new registry containing only the named tools, in the order named — the primitive a caller uses to narrow a sub-agent's tools (ADR 0005). Names not present in this registry are skipped, so the result can never be a superset of the parent; a repeated name is registered once.

type ToolResult

type ToolResult struct {
	CallID  string
	Content string
	IsError bool
}

ToolResult is what a tool returns to the loop (pre tool-result-capping).

type ToolResultEvent

type ToolResultEvent struct {
	EventBase
	Result ToolResult
}

ToolResultEvent reports a tool's result after execution (and after any post-tool-result Mechanisms have acted on it).

type UsageEvent

type UsageEvent struct {
	EventBase
	PromptTokens     int
	CompletionTokens int
	TotalTokens      int
}

UsageEvent reports the token accounting an Upstream reply carried — the prompt (context) tokens, the generated completion tokens, and their total — once a Turn's stream reaches its terminal Done. A server that omits usage emits no UsageEvent, so an observer that never sees one simply has no token counts (the zero state). It is the observability spine for the live context-usage gauge and a tokens/sec readout: an observer reads the latest Depth-0 UsageEvent for the current context fill and times the completion against its own clock for throughput. Like every variant it nests by Depth, so a sub-agent's usage reaches the parent's observer at its nesting level.

type UserInput

type UserInput struct {
	Text     string
	FileRefs []string
	SkillIDs []string `json:",omitempty"`
}

UserInput is one user message into an Exchange: free text plus optional file references the loop resolves into context, plus reserved skill references. Stays a value (no live handles) so it snapshots cleanly.

FileRefs (@file tokens parsed from the chat input) are resolved at Step time — the loop reads each within the workspace fence and prepends its content to the user message. SkillIDs are the skills the user attached in chat (the /skill command); the loop resolves each through Config.Skills and prepends its body to the user message for that one turn. The refs round-trip through a snapshot, so a resumed session re-resolves them.

Directories

Path Synopsis
Package domaintest is the hook seam's shared test adapter (the internal/platform/confinetest precedent): conversation fixtures and a settable LoopView fake, so a Mechanism or engine test builds history and loop state through one vocabulary instead of hand-rolled per-file literals.
Package domaintest is the hook seam's shared test adapter (the internal/platform/confinetest precedent): conversation fixtures and a settable LoopView fake, so a Mechanism or engine test builds history and loop state through one vocabulary instead of hand-rolled per-file literals.

Jump to

Keyboard shortcuts

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