loop

package
v1.37.1 Latest Latest
Warning

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

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package loop implements the ReAct (Reasoning + Acting) agent loop.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func IngestRecorderFrom added in v1.10.0

func IngestRecorderFrom(ctx context.Context) func(source, content string)

IngestRecorderFrom extracts the ingest recorder from ctx, if any.

func PartialSummaryReason added in v1.28.0

func PartialSummaryReason(final string) (string, bool)

PartialSummaryReason classifies a final answer produced by one of the engine's budget-exhaustion paths. It returns (reason, true) when the text carries a partial-summary marker — "iteration_budget", "execution_budget", or "time_budget" — so callers (the sub-agent CLI's result contract) can set status/partial_reason without string-matching the markers themselves.

func WithIngestRecorder added in v1.10.0

func WithIngestRecorder(ctx context.Context, fn func(source, content string)) context.Context

WithIngestRecorder returns a context that carries fn as the active ingest recorder. Callers such as cmd/odek wrapUntrusted use IngestRecorderFrom to read it back. Using a context value removes the package-global recorder that previously caused cross-session races in the WebUI.

Types

type DeltaHandler added in v1.25.0

type DeltaHandler func(llm.Delta) error

DeltaHandler receives streamed LLM output fragments when streaming is enabled (see SetStream / docs/STREAMING.md). It is invoked synchronously from the SSE reader and must be non-blocking. Returning a non-nil error aborts generation; the loop then fails the turn with the wrapped *llm.StreamAbortedError instead of retrying.

type Engine

type Engine struct {

	// PromptCaching enables Anthropic prompt caching markers. When enabled
	// and the LLM endpoint is Anthropic, the system prompt and first user
	// message are annotated with cache_control markers, and the system
	// prompt is moved to the dedicated "system" field. For non-Anthropic
	// endpoints (OpenAI, DeepSeek) the markers are skipped entirely — those
	// providers cache automatically or reject the Anthropic request shape.
	PromptCaching bool

	// MaxToolParallel controls how many tool calls run concurrently per
	// iteration. 0 = use default (4). Models that support parallel tool
	// calling (Claude 3.5+, GPT-4o, DeepSeek V4) can emit multiple tool
	// calls in one response — this setting bounds concurrency so tools
	// like read_file, search_files, and web_search run in parallel while
	// avoiding resource exhaustion.
	MaxToolParallel int

	// Token accounting — accumulated across all iterations of the most recent run.
	// Reset on each Run/RunWithMessages call and read by callers (e.g. WebUI).
	TotalInputTokens  int
	TotalOutputTokens int

	// Cache metrics accumulated across all iterations.
	TotalCacheCreationTokens int  // Anthropic: tokens written to cache
	TotalCacheReadTokens     int  // Anthropic: tokens read from cache
	TotalCachedTokens        int  // OpenAI: cached prompt tokens
	TotalCacheReported       bool // provider returned cache metrics at least once
	// contains filtered or unexported fields
}

Engine runs the agent loop: observe → think → act → repeat.

func New

func New(client *llm.Client, registry *tool.Registry, maxIterations int, systemMessage string, renderer *render.Renderer, maxContext int) *Engine

New creates a new loop Engine. maxContext is the model's maximum context window in tokens. Pass 0 for no limit enforcement.

func (*Engine) BudgetSnapshot added in v1.28.0

func (e *Engine) BudgetSnapshot() budget.Snapshot

BudgetSnapshot implements budget.View: a point-in-time view of the run's remaining budget with the engine's cumulative token totals applied. Returns the zero Snapshot when no run is active or no limits are configured. Safe for tools to call: during a tool batch the loop goroutine is blocked, so there is no concurrent Checker access.

func (*Engine) EmitEvent added in v1.28.0

func (e *Engine) EmitEvent(ev events.Event)

emitEvent fires a structured runtime event if a handler is configured, stamping the timestamp when the caller left it zero. Safe to call unconditionally. Run-level metadata (schema, run_id, session_id) is stamped centrally by the events.Emitter the handler is wired to. EmitEvent exposes the runtime event stream to holders of an emitter reference (view-pattern, like budget.View) — e.g. delegate_tasks surfacing child policy denials as subagent_denied events. Redaction and the non-blocking dispatch contract are inherited from emitEvent.

func (*Engine) RequestFinalization added in v1.28.0

func (e *Engine) RequestFinalization()

RequestFinalization asks the active run to conclude at the next iteration boundary: no new tool batches start and the engine produces the partial-progress summary prefixed with timeBudgetSummaryMarker instead of running to the iteration cap. Non-blocking and safe to call from any goroutine — typically a watcher on the caller's soft deadline. The flag is reset when the next run starts; a request arriving after the run finished is a no-op.

func (*Engine) Run

func (e *Engine) Run(ctx context.Context, task string) (string, error)

Run executes the loop for a given task and returns the final response.

func (*Engine) RunWithMessages

func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (string, []llm.Message, error)

RunWithMessages executes the agent loop starting from a pre-built message history. The messages must include the system prompt (if any), all prior conversation turns, and the new user message as the last entry. Returns the final answer plus the full updated message history so callers can persist it (e.g. to a session file).

Use this for multi-turn conversations: load the session, append the new user message, call RunWithMessages, then save the returned messages.

func (*Engine) SetApprover

func (e *Engine) SetApprover(a danger.Approver)

SetApprover sets the approval gate for dangerous operations. When set and the LLM returns multiple tool calls in one iteration, a single batch approval prompt is shown. Individual tool-level approval is bypassed when the batch is approved (if the approver supports SetTrustAll).

func (*Engine) SetBudgetHints added in v1.28.0

func (e *Engine) SetBudgetHints(on bool)

SetBudgetHints enables budget-awareness telemetry for runs of this engine. Sub-agents enable it via the operator subagent config section; top-level runs default to off so interactive behaviour is unchanged.

func (*Engine) SetCompaction added in v1.15.8

func (e *Engine) SetCompaction(enabled bool)

SetCompaction enables or disables LLM-based rolling compaction of dropped context. When enabled, turn groups dropped by context trimming are summarized into a rolling digest system message instead of vanishing entirely. The digest is derived from (potentially untrusted) tool output, so it is wrapped with the engine's untrusted-content wrapper when set.

func (*Engine) SetDangerousConfig

func (e *Engine) SetDangerousConfig(cfg *danger.DangerousConfig)

SetDangerousConfig provides the DangerousConfig for batch gate pre-classification. Without it, the batch gate cannot know which risk classes require approval and would skip pre-checking.

func (*Engine) SetDeltaHandler added in v1.25.0

func (e *Engine) SetDeltaHandler(cb DeltaHandler)

SetDeltaHandler sets the streamed-fragment callback (see DeltaHandler).

func (*Engine) SetEpisodeContextFunc

func (e *Engine) SetEpisodeContextFunc(ef EpisodeContextFunc)

SetEpisodeContextFunc sets the optional per-turn episode search callback. When set, it is called once per new user message to search for relevant past session episodes. The returned context is injected as a system message before the LLM invocation.

func (*Engine) SetEventHandler added in v1.24.0

func (e *Engine) SetEventHandler(cb func(events.Event))

SetEventHandler sets the optional structured runtime event sink (schema odek.event/v1). The handler must be non-blocking — events fire inside the hot loop; odek.New wires it to a drop-on-full events.Emitter. Passing nil disables event emission.

func (*Engine) SetEventsIncludeArgs added in v1.27.0

func (e *Engine) SetEventsIncludeArgs(enabled bool)

SetEventsIncludeArgs opts tool_call_started events into carrying the raw (secret-redacted) tool-call arguments alongside the digest. Off by default: raw args can include sensitive task content, but incident review on an opt-in basis is strictly better than a stream that cannot answer "what actually ran?" once the session is gone.

func (*Engine) SetExtendedMemoryContextFunc added in v1.12.0

func (e *Engine) SetExtendedMemoryContextFunc(ef ExtendedMemoryContextFunc)

SetExtendedMemoryContextFunc sets the optional per-turn Extended Memory search callback. The returned context is injected as a system message after the legacy memory prompt block.

func (*Engine) SetInteractionMode

func (e *Engine) SetInteractionMode(mode string)

SetInteractionMode sets how progress is surfaced. "off" suppresses all per-iteration render output except the final answer.

func (*Engine) SetIterationCallback

func (e *Engine) SetIterationCallback(cb IterationCallback)

SetIterationCallback sets the iteration progress callback. If nil, no callback is fired.

func (*Engine) SetLimits added in v1.24.0

func (e *Engine) SetLimits(l budget.Limits, model string)

SetLimits configures hard execution budgets (odek-extension/v1): runtime, tool-call count, input/output token totals, and estimated cost. The zero value disables enforcement. Wired by odek.New from Config.Limits. The model ID is fixed per run, so per-model prices (Limits.ModelPrices) are resolved once here into the effective flat prices every cost check uses.

func (*Engine) SetMaxToolParallel

func (e *Engine) SetMaxToolParallel(n int)

SetMaxToolParallel sets the maximum concurrency for tool execution per iteration. 0 or negative = use default (4).

func (*Engine) SetMemoryPromptFunc

func (e *Engine) SetMemoryPromptFunc(fn func() string)

SetMemoryPromptFunc sets the optional memory prompt callback. When set, it is called before each LLM invocation to get fresh memory content. This ensures the agent sees the latest facts even if it modifies memory during a session.

func (*Engine) SetMessagesPersistCallback added in v1.19.0

func (e *Engine) SetMessagesPersistCallback(cb MessagesPersistCallback)

SetMessagesPersistCallback sets the per-step message persistence callback. If nil, no callback is fired.

func (*Engine) SetModel

func (e *Engine) SetModel(model string)

SetModel updates the LLM model used by this engine at runtime. The model string must be a valid OpenAI-compatible model identifier.

func (*Engine) SetNarrator

func (e *Engine) SetNarrator(n *narrate.Narrator)

SetNarrator sets the optional narrator for engaging mode. When nil (the default), tools render in verbose mode via the Renderer.

func (*Engine) SetPlanStore added in v1.27.0

func (e *Engine) SetPlanStore(s *PlanStore)

SetPlanStore wires the shared plan state (internal/loop/plan.go). The CLI layer creates one store and hands it to both the plan tool and the engine; nil (the zero behavior) disables planning end-to-end — no sync, no render, no protected-message logic, and no plan events.

Wiring the store here also registers the engine's event emitter as the store's change callback: the store knows when an effective mutation happened, the engine owns how that reaches the odek.event/v1 stream (same emitEvent path as iteration_completed). Replacing an already-wired store detaches the old one so no stale notification path survives.

func (*Engine) SetSideCallTimeout added in v1.20.0

func (e *Engine) SetSideCallTimeout(d time.Duration)

SetSideCallTimeout sets the bound for the compaction digest and progress-summary side calls. 0 or negative restores the default (30s).

func (*Engine) SetSignalHandler added in v1.2.0

func (e *Engine) SetSignalHandler(cb SignalHandler)

SetSignalHandler sets the optional agent-loop signal callback. Passing nil disables signal emission.

func (*Engine) SetSkillLoader

func (e *Engine) SetSkillLoader(sl SkillLoader)

SetSkillLoader sets the optional skill loader callback.

func (*Engine) SetSkillVerbose

func (e *Engine) SetSkillVerbose(verbose bool)

SetSkillVerbose controls whether skill loading shows full banners (true) or condensed markers (false, default). Condensed saves context window space.

func (*Engine) SetStream added in v1.25.0

func (e *Engine) SetStream(on bool)

SetStream enables SSE streaming of the main think step (docs/STREAMING.md). Requires a delta handler (SetDeltaHandler) to change anything user-visible; without one the transport still streams but nothing is displayed incrementally.

func (*Engine) SetThinking added in v1.0.0

func (e *Engine) SetThinking(thinking string)

SetThinking updates the thinking/reasoning mode used by this engine at runtime. Accepts the same values as Config.Thinking: "enabled", "disabled", "low", "medium", "high", or "" (provider default). Safe to call between RunWithMessages calls.

func (*Engine) SetToolEventHandler

func (e *Engine) SetToolEventHandler(cb ToolEventHandler)

SetToolEventHandler sets the optional tool event callback for live streaming.

func (*Engine) SetUntrustedWrapper added in v1.8.0

func (e *Engine) SetUntrustedWrapper(fn func(source, content string) string)

SetUntrustedWrapper sets a function that wraps externally-sourced content (skill context, episode context) with a nonce'd boundary before injecting it into the model's system context. When nil, that content is injected directly.

func (*Engine) SetUserMessageHandler added in v1.12.0

func (e *Engine) SetUserMessageHandler(fn UserMessageHandler)

SetUserMessageHandler sets an optional callback invoked once per new user message. It is used by callers to trigger Extended Memory atom extraction.

func (*Engine) SideCallTimeout added in v1.20.0

func (e *Engine) SideCallTimeout() time.Duration

SideCallTimeout returns the effective bound for the compaction digest and progress-summary side calls (default 30s).

type EpisodeContextFunc

type EpisodeContextFunc func(userInput string) string

EpisodeContextFunc is an optional callback that the loop engine calls before each LLM invocation to discover relevant past session episodes. The callback receives the latest user input as a search query and returns formatted episode context to inject, or empty string if nothing matches.

type ExtendedMemoryContextFunc added in v1.12.0

type ExtendedMemoryContextFunc func(ctx context.Context, userInput string) string

ExtendedMemoryContextFunc is an optional callback that returns formatted Extended Memory context for the latest user input. It is injected as a system message after the legacy memory prompt block.

type IterationCallback

type IterationCallback func(info IterationInfo)

IterationCallback is an optional callback invoked after each iteration of the agent loop. Used by Telegram/WebUI for progress reporting.

type IterationInfo

type IterationInfo struct {
	Turn                int           // current iteration (1-indexed)
	MaxTurns            int           // max iterations configured
	ToolNames           []string      // tools called this turn (duplicates possible)
	InputTokens         int           // cumulative input tokens
	OutputTokens        int           // cumulative output tokens
	CacheCreationTokens int           // cumulative cache creation tokens
	CacheReadTokens     int           // cumulative cache read tokens
	CachedTokens        int           // cumulative cached tokens (OpenAI)
	CacheReported       bool          // provider returned cache metrics at least once
	TotalLatency        time.Duration // cumulative wall time
	HasFinalAnswer      bool          // true when the agent reached a final answer
	ReasoningContent    string        // LLM reasoning before tool calls (empty if none)
	IsPreTool           bool          // true when fired BEFORE tool execution (shows reasoning + tools)
}

IterationInfo holds data about a single agent loop iteration, passed to the IterationCallback after each turn. Used for progress reporting.

type MessagesPersistCallback added in v1.19.0

type MessagesPersistCallback func(messages []llm.Message)

MessagesPersistCallback is an optional callback invoked after each completed step of the agent loop (after a tool batch's result messages are appended, and after the final assistant message). It receives a freshly-allocated copy of the current message history so callers can persist per-turn progress; an interrupted run can then be resumed from the last completed step instead of losing the whole in-progress turn.

type PlanChange added in v1.27.0

type PlanChange struct {
	Created    bool // true when the mutation was a create verb (wholesale replace)
	Steps      int  // total step count after the mutation
	Done       int
	InProgress int
	Blocked    int
	Pending    int
	Version    int // store version after the mutation
}

PlanChange describes one effective plan mutation for the change notification path (see PlanStore.SetOnChange). It carries aggregate counts and the new version ONLY — never step titles or notes — so it can be mapped straight onto the minimality-constrained odek.event/v1 stream (plan_created / plan_updated).

type PlanState added in v1.27.0

type PlanState struct {
	Version int        `json:"version"`
	Steps   []PlanStep `json:"steps"`
}

PlanState is the authoritative plan. Version bumps on every mutation and is echoed in the rendered message so drift is correlatable.

func ExtractPlan added in v1.27.0

func ExtractPlan(messages []llm.Message) (*PlanState, bool)

ExtractPlan parses the newest parseable plan message out of a message history. It is the shared read-only surface for `odek serve`'s GET /api/sessions/{id}/plan endpoint and the Telegram /plan_status command, so the parsing logic stays single-sourced with the engine's resume path: recognition requires role system + the "[Current plan:" prefix (isPlanMessage), each candidate goes through the same strict total parser (parsePlanState), corrupt messages are skipped fail-closed (a stale or mangled plan must never render as authoritative), and the newest parseable message wins. ok=false when no parseable plan exists.

Unlike syncPlanFromMessages this never mutates the input history and has no engine state to seed; it is safe to call on any transcript snapshot.

type PlanStep added in v1.27.0

type PlanStep struct {
	ID     string     `json:"id"`
	Title  string     `json:"title"`
	Status StepStatus `json:"status"`
	Note   string     `json:"note,omitempty"`
}

PlanStep is one unit of planned work. IDs are model-chosen short tokens (e.g. "s1"); they exist so updates can target steps without positional ambiguity when the list is reordered.

type PlanStore added in v1.27.0

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

PlanStore holds the engine's plan behind a dedicated mutex: plan calls can arrive inside a parallel tool batch (max_tool_parallel defaults to 4), so every mutation must serialize. Caps come from resolved config values — never raw project config.

func NewPlanStore added in v1.27.0

func NewPlanStore(maxSteps, maxRenderChars int) *PlanStore

NewPlanStore creates a store with the given resolved caps. Degenerate values fall back to the defaults above.

func (*PlanStore) Execute added in v1.27.0

func (s *PlanStore) Execute(argsJSON string) (string, error)

Execute runs one plan tool call (the full argument envelope) and returns the model-facing result. Serialized internally; safe inside parallel batches. Every effective mutation fires the OnChange callback exactly once per call — never per-step within an atomic batch.

func (*PlanStore) Reset added in v1.27.0

func (s *PlanStore) Reset()

Reset clears the state (run start with no persisted plan).

func (*PlanStore) Restore added in v1.27.0

func (s *PlanStore) Restore(st PlanState)

Restore replaces the state wholesale (restart-resume path). The caller owns validation — see parsePlanState.

func (*PlanStore) SetOnChange added in v1.27.0

func (s *PlanStore) SetOnChange(fn func(PlanChange))

SetOnChange registers an optional callback fired exactly once per effective mutation (create, or update/complete that bumped the version). Idempotent no-ops and the read-only get verb never fire it; Restore and Reset are resume-path bookkeeping, not model actions, and never fire it.

The engine registers its event emitter here at SetPlanStore time — the store knows WHEN a mutation happened, the engine owns HOW it reaches the odek.event/v1 stream. The callback is invoked while the store mutex is held so notification order always matches mutation (== version) order even when parallel tool batches race: fn must therefore be non-blocking and must not call back into the PlanStore.

func (*PlanStore) Snapshot added in v1.27.0

func (s *PlanStore) Snapshot() (PlanState, bool)

Snapshot returns a copy of the current plan (ok=false when none exists).

type PlanTool added in v1.27.0

type PlanTool struct {
	Store *PlanStore
}

PlanTool implements the built-in `plan` tool. It delegates everything to the shared PlanStore (the memory-tool pattern: the CLI layer creates one store and hands it to both this tool and the engine via SetPlanStore, so mutations are visible to the loop without any late-bound plumbing).

func NewPlanTool added in v1.27.0

func NewPlanTool(store *PlanStore) *PlanTool

NewPlanTool creates a PlanTool bound to the given store.

func (*PlanTool) Call added in v1.27.0

func (t *PlanTool) Call(argsJSON string) (string, error)

func (*PlanTool) Description added in v1.27.0

func (t *PlanTool) Description() string

func (*PlanTool) Name added in v1.27.0

func (t *PlanTool) Name() string

func (*PlanTool) Schema added in v1.27.0

func (t *PlanTool) Schema() any

type SignalEvent added in v1.2.0

type SignalEvent struct {
	// Type is the signal kind. One of:
	//   "context_trimmed"  — prior message groups were dropped to fit the token
	//                        budget (Count = groups dropped, Detail = "proactive"
	//                        for the pre-call budget trim, "survival" for the
	//                        post-error nuclear trim, or "margin_calibrated"
	//                        when the safety margin tightened after the provider
	//                        reported more input tokens than estimated)
	//   "tool_recovery"    — a tool failed repeatedly, or the same successful
	//                        call was repeated with identical arguments, and
	//                        the engine injected a corrective hint so the model
	//                        changes approach
	//                        (Tool = failing/stalled tool, Detail = the
	//                        correction or "repeated identical call (Nx)")
	//   "tool_running"     — a single tool call is still executing after the
	//                        heartbeat interval (Tool = tool name, Detail =
	//                        human-readable elapsed, e.g. "running for 2m0s").
	//                        Fires every interval until the call returns, so
	//                        long-running tools no longer look like a hang.
	//   "budget_warning"   — the run crossed 50/75/90% of its iteration or
	//                        wall-clock budget and the engine injected a
	//                        budget-awareness hint (Detail = threshold and
	//                        usage, e.g. "threshold_75: 11/15 iterations")
	Type      string
	Detail    string    // human-readable detail (mode, correction text, etc.)
	Tool      string    // tool name for tool_recovery
	Count     int       // groups dropped (context_trimmed)
	Timestamp time.Time // when the signal fired (UTC)
}

SignalEvent represents an internal agent-loop signal that was previously invisible to the operator — moments where the engine silently intervened to keep the session alive or productive. Surfacing these closes observability gaps around context management and tool-failure recovery.

Not every field is set for every Type; the zero value means "not applicable".

type SignalHandler added in v1.2.0

type SignalHandler func(event SignalEvent)

SignalHandler receives agent-loop signal events. Implementations must be non-blocking — signals fire inside the hot loop.

type SkillLoader

type SkillLoader func(userInput string) string

SkillLoader is an optional callback that the loop engine calls before each LLM invocation to discover contextually relevant skills. The callback receives the latest user input and returns additional system context (formatted skill content) to inject, or empty string if no skills match.

type StepStatus added in v1.27.0

type StepStatus string

StepStatus is the lifecycle state of one plan step.

const (
	StepPending    StepStatus = "pending"
	StepInProgress StepStatus = "in_progress"
	StepDone       StepStatus = "done"
	StepBlocked    StepStatus = "blocked"
)

type ToolEventHandler

type ToolEventHandler func(event string, name string, data string)

ToolEventHandler is an optional callback invoked for each tool execution during the agent loop — fires before (tool_call) and after (tool_result) each tool invocation. Used by the WebUI for live streaming of tool events.

type UserMessageHandler added in v1.12.0

type UserMessageHandler func(ctx context.Context, msg string)

UserMessageHandler is an optional callback invoked once per new user message. It is used by callers (e.g. odek.New) to trigger Extended Memory atom extraction.

Jump to

Keyboard shortcuts

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