harness

package
v0.4.3 Latest Latest
Warning

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

Go to latest
Published: Nov 9, 2026 License: AGPL-3.0, Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package harness implements the agent's core loop as a state machine: stream a completion, recover from max-output-tokens / prompt-too-long, surface text/thinking/tool events, execute tool calls through a streaming executor (FIFO + exclusive scheduling), gate the Stop transition with hooks and a token budget, and repeat. It is exposed as a range-over-func iterator of Events and threads context cancellation throughout.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Query

func Query(ctx context.Context, in QueryInput, deps QueryDeps) iter.Seq2[Event, error]

Query runs the agent loop and returns its event stream. The final event is always KindResult carrying a Terminal (unless the consumer stops early).

Types

type Compactor

type Compactor interface {
	// Pre conditions the history before a model request. lastInputTokens is the
	// total token size the previous model response reported (0 if none yet), used
	// as the initial gate count in preference to local estimation.
	Pre(ctx context.Context, msgs []llm.Message, lastInputTokens int) []llm.Message
	Reactive(ctx context.Context, msgs []llm.Message) ([]llm.Message, bool)
	IsOverflow(err error) bool
}

Compactor is the harness's view of the compaction system (implemented in package compaction). Compaction is non-destructive; the harness sends only llm.MessagesForAPI(messages) to the model.

type ContextView

type ContextView interface {
	View(ctx context.Context, msgs []llm.Message) []llm.Message
}

ContextView is optionally implemented by a Compactor that builds the request message array itself, replacing llm.MessagesForAPI. Hosts with their own context manager (e.g. model-driven compression) implement it to project the history per request without mutating it.

View MUST be pure: the returned slice is used for this request only and is never written back to the loop's history. Implementations that depend on stable message identity (content hashing, per-message refs) rely on this — writing back would change message bytes and invalidate that identity.

type ContinueReason

type ContinueReason string

ContinueReason names why the loop took another turn. These drive control flow; they are recorded on the loop for telemetry.

const (
	ContinueNextTurn                ContinueReason = "next_turn"
	ContinueReactiveCompactRetry    ContinueReason = "reactive_compact_retry"
	ContinueMaxOutputTokensEscalate ContinueReason = "max_output_tokens_escalate"
	ContinueMaxOutputTokensRecovery ContinueReason = "max_output_tokens_recovery"
	ContinueStopHookBlocking        ContinueReason = "stop_hook_blocking"
	ContinueTokenBudget             ContinueReason = "token_budget_continuation"
	ContinueTaskNotification        ContinueReason = "task_notification"
)

type Event

type Event struct {
	Kind       EventKind
	Text       string            // KindText / KindThinking / KindProgress
	ToolUse    *llm.ContentBlock // KindToolUse
	ToolResult *llm.ContentBlock // KindToolResult
	Terminal   *Terminal         // KindResult
	Usage      *llm.Usage        // KindUsage: cumulative usage after the latest model turn
}

Event is one item in the loop's output stream.

type EventKind

type EventKind string

EventKind classifies a loop Event.

const (
	KindText       EventKind = "text"        // assistant text delta
	KindThinking   EventKind = "thinking"    // reasoning delta
	KindToolUse    EventKind = "tool_use"    // a tool is about to run
	KindToolResult EventKind = "tool_result" // a tool finished
	KindProgress   EventKind = "progress"    // mid-tool progress
	KindUsage      EventKind = "usage"       // cumulative token usage so far (per model turn)
	KindResult     EventKind = "result"      // terminal event (loop finished)
)

type HookRunner

type HookRunner interface {
	PreToolUse(ctx context.Context, name string, input []byte) (block bool, message string, updated []byte)
	PostToolUse(ctx context.Context, name string, input, result []byte, isErr bool)
	Stop(ctx context.Context, messages []llm.Message) (prevent bool, blockingErrors []string, message string)
}

HookRunner is the harness's view of the hook system (implemented in package hook). Stop returns two distinct outcomes: preventContinuation (hard terminate) and/or blockingErrors (inject + continue).

type QueryDeps

type QueryDeps struct {
	CallModel func(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error]
	// CallModelSync is the non-streaming counterpart of CallModel, used when
	// QueryInput.NonStreaming is set. It performs a single request and returns the
	// fully assembled assistant message, the stop reason, and cumulative usage.
	CallModelSync func(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error)
	NewID         func() string
}

QueryDeps are injectable side-effect boundaries for testing (FR-01.6).

type QueryInput

type QueryInput struct {
	Provider        llm.Provider
	System          []string
	DynamicBoundary int
	Messages        []llm.Message // conversation including the latest user turn
	// SystemReminder, when non-empty, is prepended as the first message on each
	// model call. It is not stored in conversation history — the host builds it
	// fresh per Prompt call so it never accumulates across turns.
	SystemReminder string
	Tools          *tool.Registry
	// DeferredTools names tools whose schemas are withheld from the model's tool
	// list (they stay in Tools and are invoked via ExecuteExtraTool). Empty = all
	// tool schemas sent as usual.
	DeferredTools []string
	MaxTokens     int
	Temperature   *float64
	// MaxTurns bounds tool-turns (0 = off). MaxDuration bounds wall-clock time,
	// checked at the turn boundary so a turn is never aborted mid-stream — unlike
	// cancelling ctx, which cuts an in-flight stream/tool (0 = off).
	MaxTurns       int
	MaxDuration    time.Duration
	MaxConcurrency int
	// NonStreaming makes each model call a single non-streaming request (via
	// QueryDeps.CallModelSync / Provider.Complete) instead of a token stream. The
	// loop still emits the same KindText/KindThinking/KindToolUse/KindUsage events
	// to the host — they are surfaced once, when the full response arrives, rather
	// than incrementally. Zero value = streaming (unchanged default).
	NonStreaming bool
	WorkingDir   string
	AgentID      string
	// ToolOutputDir, when set, makes oversized tool output spill to a file there
	// (full content kept) instead of being discarded; MaxToolOutputChars sets the
	// per-tool output budget (head size; 0 = default 30000). See tool.Capture.
	ToolOutputDir      string
	MaxToolOutputChars int

	// Tasks, when non-nil, is the background-task manager shared with the tools.
	// The loop drains its completion notifications into the conversation between
	// tool turns so the model learns when background work finishes.
	Tasks *tool.Manager

	// Todos, when non-nil, enables todo system-reminders: when the model hasn't
	// used TodoWrite for TodoReminderTurnsSinceWrite turns (and it's been at least
	// TodoReminderTurnsBetweenReminder turns since the last reminder), the current
	// list is re-surfaced as a <system-reminder> user message. Throttle turns are
	// derived by scanning the API-visible history, so it survives compaction and
	// resume. 0 thresholds default to 10/10 (mirrors claude-code).
	Todos                            *tool.TodoStore
	TodoReminderTurnsSinceWrite      int
	TodoReminderTurnsBetweenReminder int

	// BashEnv holds extra "KEY=VALUE" entries injected into Bash subprocess
	// environments (foreground + background). Empty = inherit unchanged.
	BashEnv []string

	// Permission policy.
	PermissionMode     permission.Mode
	PermissionModeFunc func() permission.Mode
	Allowed            []string
	Disallowed         []string
	CanUseTool         permission.CanUseTool

	// Hooks is optional lifecycle instrumentation. Nil disables it.
	Hooks HookRunner
	// Compactor is optional context-window management. Nil disables it.
	Compactor Compactor
	// Recorder, when non-nil, is notified as the conversation grows so a host can
	// persist a transcript. It fires for genuinely new messages (births) in
	// order, and for compaction boundaries; it is never called for compaction's
	// ephemeral working-set edits (MicroCompact tool-result clearing), so the
	// recorded transcript is the raw, full-fidelity history.
	Recorder Recorder

	// TokenBudget, when > 0, enables budget-driven continuation: after a turn
	// with no tool calls, the agent is nudged to keep working until ~90% of the
	// budget (output tokens), stopping early on diminishing returns.
	TokenBudget int
	// EscalateMaxTokens, when true, first retries a max_tokens truncation with a
	// raised output cap (64k) before falling back to resume-recovery.
	EscalateMaxTokens bool

	// Settlement, when non-nil with a Prompt, runs a bounded wrap-up phase the
	// moment a budget (MaxTurns/MaxDuration) is hit: it injects Prompt as a user
	// turn and hides DisabledTools, so a budget-terminated run can checkpoint its
	// findings instead of ending abruptly. The run still finishes with the budget's
	// reason (ReasonMaxTurns/ReasonTimeout), never ReasonCompleted. Nil = off.
	Settlement *Settlement
}

QueryInput is a fully-resolved request to run the loop.

type Recorder

type Recorder interface {
	RecordMessage(m llm.Message, usage llm.Usage)
	RecordBoundary(meta llm.BoundaryMeta)
}

Recorder receives conversation events for durable transcript persistence (implemented in package transcript). RecordMessage fires once per new message in birth order; usage carries the token delta attributed to that turn (assistant messages only; zero otherwise). RecordBoundary fires when compaction inserts a boundary, for observability — boundaries are not part of the raw reconstructable history.

type Settlement

type Settlement struct {
	Prompt        string   // wrap-up instruction injected as a user turn (empty = settlement off)
	MaxTurns      int      // turn budget for the wrap-up phase itself (0 = default 2)
	DisabledTools []string // tool names hidden from the model during wrap-up (e.g. ["Bash"])
	// PromptByReason optionally overrides Prompt based on WHY the run settled
	// (keyed by the terminal reason, e.g. ReasonTimeout / ReasonMaxTurns). When the
	// settling reason has a non-empty entry here it is used; otherwise Prompt is used.
	// Lets a caller inject a different wrap-up depending on whether a run was cut by
	// its time budget vs its step budget, without knowing which at build time.
	PromptByReason map[TerminalReason]string
}

Settlement configures the wrap-up phase that runs when a budget is hit.

type Terminal

type Terminal struct {
	Reason   TerminalReason
	Err      error
	Usage    llm.Usage
	Turns    int
	Messages []llm.Message // full conversation snapshot (non-destructive: retains history)
	Text     string        // the assistant's final text, for convenience
}

Terminal is the final state of a Query, delivered as the KindResult event.

type TerminalReason

type TerminalReason string

TerminalReason explains why the loop stopped.

const (
	ReasonCompleted         TerminalReason = "completed"           // model ended its turn
	ReasonBlockingLimit     TerminalReason = "blocking_limit"      // hard context limit hit pre-request
	ReasonImageError        TerminalReason = "image_error"         // unsupported media (multimodal)
	ReasonModelError        TerminalReason = "model_error"         // provider/API failure
	ReasonAbortedStreaming  TerminalReason = "aborted_streaming"   // canceled during streaming
	ReasonAbortedTools      TerminalReason = "aborted_tools"       // canceled during tool execution
	ReasonPromptTooLong     TerminalReason = "prompt_too_long"     // overflow recovery exhausted
	ReasonStopHookPrevented TerminalReason = "stop_hook_prevented" // a Stop hook blocked continuation
	ReasonHookStopped       TerminalReason = "hook_stopped"        // a tool/hook stopped continuation
	ReasonMaxTurns          TerminalReason = "max_turns"           // hit MaxTurns
	ReasonTimeout           TerminalReason = "timeout"             // hit a wall-clock run budget (host-imposed)
)

Jump to

Keyboard shortcuts

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