The agent package provides the core ReAct loop engine — the component that drives an AI agent through iterative Thought → Action → Observation cycles until a task is finished or the step budget is exhausted. This document covers the Executor, the data types it produces and consumes, the circuit breakers that protect it, and the context-management interfaces it depends on.
import (
"github.com/v0lka/sp4rk/agent"
"github.com/v0lka/sp4rk/llm"
"github.com/v0lka/sp4rk/tools"
)The Executor is the heart of the agent. Given a set of tools and a ContextManager (which owns the conversation history), it repeatedly:
- Builds a prompt from the current context.
- Calls the LLM.
- Parses the response into a thought and zero or more tool calls.
- Executes each tool call (subject to human-in-the-loop approval).
- Records the observation back into the context.
- Repeats — unless the agent calls the
finishtool or a limit is hit.
Each iteration is represented as a Step. The full sequence of steps is returned in an ExecutorResult.
Executor runs the ReAct loop. It is not safe for concurrent use on a single instance — Run must be called one at a time. Orchestrators that need parallelism create a fresh Executor per step (see Subagents).
type Executor struct {
// ...configuration fields, set via NewExecutor and the Set* methods
}NewExecutor constructs an Executor with the required dependencies and optional configuration supplied via functional options. Both the event emitter and the HITL handler are nil-safe — nil is replaced with NoopEvents and NoopHITLHandler respectively.
func NewExecutor(
llmRouter LLMCaller, // the LLM provider/router to call
toolRegistry ToolExecutor, // tool execution + source/untrusted metadata
maxSteps int, // hard cap on ReAct iterations
opts ...Option, // optional configuration (see below)
) *ExecutorAvailable options:
| Option | Purpose |
|---|---|
WithTokenCounter(c llm.TokenCounter) |
Token counting for context management. |
WithEvents(e Events) |
Lifecycle events (nil → NoopEvents). |
WithSuppressAssistantEvents(bool) |
Hide AssistantChunk/AssistantDone (for sub-steps). |
WithToolResultBudget(b ToolResultBudget) |
Stage 2 token-based truncation. Defaults to DefaultToolResultBudget() when unset. |
WithCircuitBreaker(c CircuitBreakerConfig) |
Loop-protection thresholds. Defaults to DefaultCircuitBreakerConfig() when unset. |
WithHITL(h HITLHandler) |
Human-in-the-loop hooks (nil → NoopHITLHandler). |
WithResumeSteps(steps []Step) |
Seeds prior ReAct steps so Run resumes from a checkpoint instead of starting fresh. The step counter starts at len(steps)+1 and the full trajectory (seeded plus new steps) syncs to the TrajectoryStore. The resumed steps count against maxSteps; the caller must seed the ContextManager with the same steps (e.g. via memory.ContextWindow.SeedSteps). Nil/empty (or omitted) is the default fresh-start behavior. |
WithPauseChecker(func(context.Context) bool) |
Installs a cooperative pause signal checked once per step boundary, after cancellation and before live-message polling. A true return yields ErrPaused, Finished: false, and the resumable trajectory. |
WithUserMessageSource(func(context.Context) string) |
Polls a host-owned live-message queue once per step boundary. A non-empty return becomes the final user message in the next LLM request; at most one message is drained per boundary. |
A minimal construction using the SDK defaults:
exec := agent.NewExecutor(
llmRouter,
toolRegistry,
25, // maxSteps
agent.WithTokenCounter(tokenCounter),
agent.WithEvents(&myEvents{}), // Events implementation
agent.WithToolResultBudget(agent.DefaultToolResultBudget()),
// CircuitBreaker defaults to DefaultCircuitBreakerConfig() when omitted.
// HITL defaults to NoopHITLHandler when omitted.
)
exec.SetLogger(slog.Default())Run is the main execution method. The caller is responsible for setting up the task context (workspace path, task description, etc.) before calling Run.
func (e *Executor) Run(
ctx context.Context,
taskTools []tools.ToolDescriptor,
cw ContextManager,
) (*ExecutorResult, error)ctx— carries cancellation, workspace path, trajectory store, and other injected dependencies (see Context helpers).taskTools— the tools available for this run. Thefinishtool is appended automatically if not already present.cw— theContextManagerthat owns the prompt history and compaction logic.
Returns an *ExecutorResult and an error. A non-nil error normally indicates a fatal failure (LLM error, context cancellation). The recoverable exception is ErrPaused: errors.Is(err, agent.ErrPaused) is true, the result is non-nil, Finished is false, and Steps is a resumable checkpoint. A nil error with Finished == false means the step budget was exhausted or a circuit breaker aborted the loop.
result, err := exec.Run(ctx, taskTools, cm)
if err != nil {
return err
}
if !result.Finished {
// budget exhausted or circuit breaker fired
log.Printf("did not finish: %s", result.Output)
}
fmt.Println(result.Output)Each iteration of Run proceeds as follows:
- Trajectory sync — if a
TrajectoryStoreis in the context, the current step history is synced so tools (e.g. a reflector) can read it. - Step-limit boundary — if the step budget is reached,
OnStepLimitis consulted via the HITL handler to decide whether to grant more steps. - StepStart event and cancellation —
emitter.StepStart(stepNum)fires, then context cancellation is checked. - Cooperative pause — a configured pause checker runs. A true result returns
ErrPausedwith the trajectory collected so far; no live message is drained on that boundary. - Live user message — a configured source is polled. One non-empty message is recorded as a nudge-only step and becomes the final user message in this iteration's LLM request.
- LLM call — the prompt is built from the context manager and sent to the LLM. If the provider reports a context-window-exceeded error, a reactive compaction is triggered and the call is retried. A pending resume interjection is consumed only after a successful response, so it remains present on the retry.
- Thought event —
emitter.Thought(stepNum, content, reasoning)fires with the model's reasoning and content. - Implicit finish check — if the model returns no tool calls, the executor checks whether this is a legitimate finish or a failure mode (e.g. printed tool-call syntax as text). A nudge may be injected to force an explicit
finishcall. - Truncation detection — if the stop reason is
max_tokensand tool calls were present, the call was truncated; a nudge is injected and the truncation counter is checked against the circuit breaker. - Tool execution — each tool call is executed (after HITL approval). Results are truncated in two stages, cached if applicable, and recorded as observations.
ToolCallandToolResultevents fire per call. If verify-on-edit is enabled, successfulwrite_file/edit_filecalls schedule one verification run at the end of the response group. - StepComplete event —
emitter.StepComplete(stepNum, duration)fires. - Compaction — the context fill is checked; if it crosses a threshold, compaction runs and
ContextFill/ContextCompactionevents fire.
The loop terminates when the finish tool is called (Finished: true) or the budget is exhausted (Finished: false).
These three mechanisms all deliver control at an LLM step boundary, but they have different ownership and lifetime:
- Cooperative pause — configure
WithPauseCheckerat construction orSetPauseCheckerbeforeRun. The checker runs after cancellation and before live-message polling. When it returns true,RunreturnsErrPausedand the completed trajectory without consuming a queued message. Resume by seeding both the executor (WithResumeSteps) and context manager (SeedSteps). - Live user messages — configure
WithUserMessageSourceorSetUserMessageSource. The host callback drains at most one message per boundary; a non-empty value is recorded asStep{UserNudge: msg}and rendered as the final{role:user}message in the immediate LLM request. Delivery emits anExecutorDiagnosticnamedlive_user_message. - Resume interjection — the Conductor sets a one-shot
PendingUserInterjectionon a context manager implementingorchestration.InterjectionAware. An executor recognizesInterjectionConsumerand retires the nudge only after a successful LLM response. If the first call fails with context overflow and triggers reactive compaction, the rebuilt prompt still contains the nudge.
exec := agent.NewExecutor(router, registry, 25,
agent.WithResumeSteps(checkpoint),
agent.WithPauseChecker(func(context.Context) bool { return pause.Load() }),
agent.WithUserMessageSource(func(context.Context) string {
return liveQueue.PopOne() // "" when empty
}),
)
cw.SeedSteps(checkpoint)
result, err := exec.Run(ctx, registry.List(), cw)
if errors.Is(err, agent.ErrPaused) {
checkpoint = result.Steps
}The callbacks must be cheap and safe to invoke from the Run goroutine. Queue persistence and undelivered-message handling remain host responsibilities.
SetVerifyOnEdit installs a mechanical post-edit verifier (tests, lint, or build) supplied by trusted user configuration:
func (e *Executor) SetVerifyOnEdit(runner EditVerifyRunner, maxOutputChars int)type EditVerifyRunner func(ctx context.Context) EditVerifyResult
type EditVerifyResult struct {
Output string
ExitCode int
TimedOut bool
Timeout time.Duration
Err error
}A successful write_file or edit_file marks the current LLM response group dirty. The runner executes once at the end of that group, even when the response contains several edits. Failed edits, HITL-rejected edits, reads, deletes, directory operations, and shell calls do not schedule verification. Pending verification is flushed before terminal paths that bypass normal group completion, so a finish cannot silently skip it.
The formatted [verify_on_edit] note is appended to the final observation and visible both to the model and event consumers. Exit 0 records a pass; a positive exit code asks the model to fix failures; timeout, infrastructure failure (Err), or a negative/no exit status records that the edit was not verified. Output is trimmed and rune-safely capped; maxOutputChars <= 0 selects DefaultVerifyOnEditCap (4000 characters). A nil runner disables the hook. The command itself must come from user/host configuration, never from model output.
A Step is a single iteration of the ReAct loop.
type Step struct {
Thought string
ReasoningContent string
ReasoningItems []llm.ReasoningItem
Action llm.ToolCall
Observation string
IsError bool
TokensUsed int
UserNudge string
ResponseGroup int64
IsUntrusted bool
CacheHash string
}| Field | Description |
|---|---|
Thought |
The model's textual response (the "thought" in ReAct). |
ReasoningContent |
Chain-of-thought text from reasoning models. Populated by providers that surface a separate reasoning channel. |
ReasoningItems |
Structured reasoning output items from the OpenAI Responses API. Each item carries an ID required to round-trip the reasoning chain across iterations. Empty for non-Responses-API providers. |
Action |
The llm.ToolCall the model chose to execute (tool name + arguments). |
Observation |
The tool result content, recorded back into the context as the next user/tool message. |
IsError |
true when the tool returned an error result. Used by the mutation gate to avoid counting failed mutating tools as successful mutations. |
TokensUsed |
Token usage for this step's LLM call. |
UserNudge |
An optional user message injected after the step's normal messages (e.g. step-limit nudges, circuit-breaker warnings). |
ResponseGroup |
Links steps that came from a single LLM response containing multiple tool calls. Steps sharing a non-zero value are rendered as one assistant message with multiple tool calls. Zero means a standalone step. |
IsUntrusted |
true when the observation came from an untrusted external source (web, MCP, filesystem). Such observations are wrapped in <untrusted-content> tags before entering the LLM context as a prompt-injection defense. |
CacheHash |
SHA-256 hash of the full (pre-truncation) tool result, stored in the ToolResultCache. Empty for non-cacheable tools whose result is not truncated, or when caching is disabled. A non-cacheable tool that is truncated is cached on demand (cache-on-truncate) and gets a hash, so the full result stays recoverable via tool_result_read. Used to replace old tool results with a cache reference, reducing replay cost. |
The result of Executor.Run.
type ExecutorResult struct {
Output string
Steps []Step
Finished bool
}Output— the final answer (from thefinishtool) or, when not finished, the abort reason (e.g. circuit breaker, fruitless abort, max steps).Steps— the full sequence of executed steps.Finished—trueif the agent calledfinish;falseif the budget was exhausted or a circuit breaker aborted.
FillCheck represents the result of a context-window fill check.
type FillCheck struct {
Percent float64
Status string // "ok", "compact", "warning", "emergency", "reject"
Used int
Max int
}| Status | Meaning |
|---|---|
ok |
Context is comfortably within limits. |
compact |
Compaction should run to free space. |
warning |
Context is filling up; a pre-compaction nudge may be emitted. |
emergency |
Context is nearly full; aggressive compaction is required. |
reject |
Context is too full to proceed even after compaction. |
Used and Max are the current and maximum token counts.
FinishTool is the special tool that signals task completion. The executor appends it automatically to every run's tool set (unless a finish tool is already registered). When the model calls finish, the loop terminates with Finished: true and the tool's answer argument becomes ExecutorResult.Output.
finishTool := agent.NewFinishTool()
registry.Register(finishTool)The finish tool's input schema requires a single answer string. Internally, the tool unmarshals the input into an anonymous struct:
var params struct {
Answer string `json:"answer"`
}Why it is required: an LLM that simply stops emitting tool calls has not necessarily completed the task — it may have hit a failure mode (e.g. printing tool-call syntax as text). The executor treats a text-only response as an implicit finish and injects a nudge demanding an explicit finish call. Only an actual finish tool call is accepted as completion (subject to the finish guard and mutation/checklist gates).
When an update_checklist tool is present, the checklist gate nudges the model to keep its progress checklist current. It is enabled by default (SetChecklistGateEnabled, default true) and combines finish-time sub-gates with proactive mid-step nudges.
A productive call counts real work: any tool call except the empty-Action nudges, finish, and update_checklist itself (a bookkeeping call). The productive-call count drives the trivial-step test and the staleness counter, so updating the checklist never inflates either.
Finish-time sub-gates (each one nudge attempt before finish is accepted):
- Missing-checklist — a non-trivial step (more than
checklistTrivialThreshold, 2, productive calls) that never calledupdate_checklistsuccessfully → a nudge is injected and the call retried. - Unchecked-items — the last successful checklist still has unchecked items → a nudge is injected and the call retried.
Proactive mid-step nudges (run after tool execution, before compaction):
- Staleness nudge — once a checklist exists, if the model makes
checklistStalenessThreshold(3) or more productive calls since its last successfulupdate_checklist, a nudge prompts an immediate incremental update. The counter resets after each update and the nudge is capped atchecklistStaleNudgeCap(2) per step. Fired as anExecutorDiagnosticwith eventchecklist_stale_nudge. - Batching detection — after each successful
update_checklist, the new checklist is diffed against the previous one. Checking more than one previously-unchecked item at once appends a correction to that call's observation; checking exactly one earns brief positive reinforcement. The first (initialization) update is exempt. Batches fire anExecutorDiagnosticwith eventchecklist_batched_update.
These nudges steer the model toward updating the checklist incrementally — one item at a time, right after completing each sub-task — so progress stays visible throughout the step.
CircuitBreakerConfig holds thresholds that protect the executor from unproductive infinite loops. When a threshold is crossed, the executor injects a nudge (a corrective system message) and, if the behavior persists, aborts the loop with Finished: false.
type CircuitBreakerConfig struct {
RepeatNudgeThreshold int
RepeatAbortThreshold int
TruncationAbortThreshold int
ParseErrorAbortThreshold int
FruitlessNudgeThreshold int
FruitlessAbortThreshold int
FruitlessMaxResultLen int
SameToolRepeatNudgeThreshold int
SameToolRepeatAbortThreshold int
SameToolResultSizeDelta int
}| Field | Description |
|---|---|
RepeatNudgeThreshold |
Consecutive identical tool calls (same name + same arguments) before a nudge is injected. |
RepeatAbortThreshold |
Consecutive identical tool calls before the loop aborts. |
TruncationAbortThreshold |
Consecutive max_tokens-truncated responses containing tool calls before abort. |
ParseErrorAbortThreshold |
Consecutive parse errors on the same tool before abort. |
FruitlessNudgeThreshold |
Consecutive minimal-result calls before a nudge. |
FruitlessAbortThreshold |
Consecutive minimal-result calls before abort. |
FruitlessMaxResultLen |
Result length at or below which a call is considered "fruitless". |
SameToolRepeatNudgeThreshold |
Same tool called with varied args but similar results, before a nudge. |
SameToolRepeatAbortThreshold |
Same-tool varied-args repetition before abort. |
SameToolResultSizeDelta |
Maximum result-length difference to consider results "similar". |
Use the SDK defaults unless you have a specific reason to tune:
cfg := agent.DefaultCircuitBreakerConfig()
// RepeatNudgeThreshold: 3, RepeatAbortThreshold: 5, TruncationAbortThreshold: 3,
// ParseErrorAbortThreshold: 3, FruitlessNudgeThreshold: 5, FruitlessAbortThreshold: 8,
// FruitlessMaxResultLen: 48, SameToolRepeatNudgeThreshold: 8,
// SameToolRepeatAbortThreshold: 12, SameToolResultSizeDelta: 128Tool outputs are truncated in two stages to keep the context window manageable.
ToolTruncationConfig applies line- and byte-based limits before token-based budgeting. Byte truncation is UTF-8 safe (it walks back to the last valid codepoint boundary).
type ToolTruncationConfig struct {
MaxLines int // 0 = no line-based truncation
MaxBytes int // 0 = no byte-based truncation
}Configure per-tool defaults via SetPerToolTruncation. The SDK ships sensible defaults:
// agent.DefaultToolTruncationConfig — a function returning a fresh map copy.
func DefaultToolTruncationConfig() map[string]ToolTruncationConfig {
return map[string]ToolTruncationConfig{
"read_file": {MaxLines: 50000},
"ripgrep": {MaxLines: 5000},
"glob": {MaxLines: 5000},
"list_directory": {MaxLines: 5000},
"web_fetch": {MaxBytes: 2097152}, // 2 MiB
"bash_exec": {MaxLines: 10000},
}
}ToolResultBudget caps how much of the context a single tool result may occupy.
type ToolResultBudget struct {
HardCapTokens int // absolute token cap per tool result
MaxFillFraction float64 // max fraction of the context window a result may fill
}budget := agent.DefaultToolResultBudget()
// HardCapTokens: 30000, MaxFillFraction: 0.4When a result exceeds the budget, it is truncated and a fragmentation nudge is appended telling the model how to retrieve the full output in fragments (see Tool result cache).
ToolResultCache stores raw (pre-truncation) tool outputs indexed by SHA256(toolName + "\x00" + content). When a result is truncated, the cache holds the full content and the executor appends a nudge instructing the model to retrieve fragments via a tool_result_read tool using the cache hash.
cache := agent.NewToolResultCache(5 * time.Minute) // TTL for MCP-sourced entries
exec.SetToolCache(cache)Key behaviors:
- Hashing —
ComputeToolResultHash(toolName, content)returns the same hashStorewould produce. Identical content from different tools gets different hashes. - Deduplication — repeated identical calls produce the same hash; no duplicate entries.
- File coherence — for file-based tools (
read_file,write_file,edit_file), the cache records the file path, mtime, and size.CheckCoherence(hash)returnsfalseif the file has changed since caching. - MCP TTL — MCP-sourced entries expire after the configured TTL. Non-MCP entries never expire. Expired entries are swept periodically.
- Non-cacheable tools — internal meta-tools (
finish,tool_result_read,store_fact, etc.) are excluded from caching by default. Add your own meta-tools viaAddNonCacheableTools.
// Exclude an application-layer meta-tool from caching.
exec.AddNonCacheableTools("delegate", "reflect")ContextManager is the interface the executor needs for context-window management. It owns the prompt history and the compaction strategy.
type ContextManager interface {
BuildPrompt() []llm.Message
AddStep(step Step)
Compact(ctx context.Context) *CompactionResult
SetStrategy(strategy CompactionStrategy)
CheckFill() FillCheck
CorrectTokenCount(apiInputTokens int)
FillPercent() float64
AvailableTokens() int
OutputLimit() int
VulnerableOutputs() []VulnerableOutput
}| Method | Description |
|---|---|
BuildPrompt() |
Returns the current message history for the next LLM call. |
AddStep(step) |
Records a completed step (thought + action + observation) into the history. |
Compact(ctx) |
Runs the compaction strategy, returning before/after fill percentages. |
SetStrategy(strategy) |
Sets the CompactionStrategy used by Compact. |
CheckFill() |
Returns a FillCheck describing the current fill state. |
CorrectTokenCount(apiInputTokens) |
Reconciles the local token estimate with the actual count reported by the provider. |
FillPercent() |
Returns the current context fill as a percentage. |
AvailableTokens() |
Returns the number of tokens still available. |
OutputLimit() |
Returns the maximum output tokens the model may produce. |
VulnerableOutputs() |
Returns tool outputs that will be pruned on the next pruning cycle. |
CompactionStrategy defines an algorithm for compressing step history into a smaller set of messages when the context fills up.
type CompactionStrategy interface {
Compact(ctx context.Context, steps []Step, budgetTokens int) []llm.Message
}A compaction produces a CompactionResult with before/after fill percentages:
type CompactionResult struct {
BeforePercent float64
AfterPercent float64
}VulnerableOutput describes a tool output that will be pruned on the next pruning cycle. The pre-compaction nudge lists these so the model can preserve key findings (e.g. via a fact-store tool) before they are lost.
type VulnerableOutput struct {
ToolName string
InputHint string // human-readable summary of tool input (file path, pattern, etc.)
}The executor depends on two narrow interfaces so it is decoupled from any specific LLM provider or tool registry.
type LLMCaller interface {
Call(ctx context.Context, req llm.ChatRequest) (resp *llm.ChatResponse, err error)
}
type ToolExecutor interface {
Execute(ctx context.Context, name string, input json.RawMessage) (result tools.ToolResult, err error)
GetToolSource(name string) string
IsToolUntrusted(name string) bool
CacheStrategy(ctx context.Context, name string, input json.RawMessage) tools.CacheMode
}LLMCaller.Call— sends a chat request and returns the response. The executor handles context-exceeded errors by triggering reactive compaction and retrying.ToolExecutor.Execute— runs a tool by name with raw JSON input.GetToolSource— returns the source of a tool (e.g."core", the MCP server name like"filesystem"); empty string if not found. Used to detect MCP tools for cache TTL handling.IsToolUntrusted— reports whether a tool's output is from an untrusted external source (MCP tools and tools flaggedIsUntrusted()). Drives the<untrusted-content>wrapping of observations.CacheStrategy— reports the cache mode the executor should use for a tool's result:tools.CacheModeDefaultkeeps the existing heuristic (read_fileis file-backed), whiletools.CacheModeContentBackedcaches the result in memory. A read tool opts into the latter by implementingtools.ContentBackedReader; this is a required method on the interface (returnCacheModeDefaultto preserve prior behavior).
TrajectoryStore is a mutable holder for the executor's current trajectory. The executor syncs its step history to the store at each loop iteration so tools (e.g. a reflector) can access the trajectory via context.
type TrajectoryStore interface {
Sync(steps []Step)
Steps() []Step
}Inject and retrieve a store through the context:
ctx = agent.WithTrajectoryStore(ctx, &myTrajectoryStore{})
// ... inside a tool:
if ts := agent.TrajectoryStoreFromContext(ctx); ts != nil {
steps := ts.Steps()
}A minimal thread-safe implementation:
type trajStore struct {
mu sync.Mutex
steps []agent.Step
}
func (s *trajStore) Sync(steps []agent.Step) { s.mu.Lock(); s.steps = steps; s.mu.Unlock() }
func (s *trajStore) Steps() []agent.Step { s.mu.Lock(); defer s.mu.Unlock(); return s.steps }These setters configure optional behavior. Call them before Run.
| Method | Description |
|---|---|
SetLogger(l *slog.Logger) |
Sets the structured logger. Defaults to a discard handler. |
SetReasoningEffort(effort string) |
Sets the reasoning effort passed to LLM calls (empty = no reasoning control). |
SetToolCache(cache *ToolResultCache) |
Sets the shared tool-result cache. All tool results are stored before truncation. |
SetPerToolTruncation(cfg map[string]ToolTruncationConfig) |
Sets per-tool Stage 1 truncation defaults. The provided map is deep-copied, so the caller may mutate it after the call. |
SetPreWarningPercent(percent int) |
Sets the context-fill percentage that triggers the pre-compaction store_fact warning nudge (0 = disabled). |
SetFinishGuard(fn func(ctx) error) |
Sets a callback invoked before finish is accepted. A non-nil error rejects finish with a nudge. Used to prevent abandoning pending async work. |
AddNonCacheableTools(names ...string) |
Adds tool names to the non-cacheable set (extends SDK defaults). |
SetMutationRequired(required bool) |
When true, finish is rejected unless a mutating tool ran successfully. |
SetChecklistGateEnabled(enabled bool) |
Enables/disables the checklist gate (default: enabled). |
SetPlanContext(stepID string, index, total int) |
Sets plan-step metadata for structured logging. |
SetHITLHandler(h HITLHandler) |
Sets the human-in-the-loop handler (nil-safe). |
The finish guard lets a caller block premature completion — for instance, to prevent an agent from finishing while async sub-tasks are still running:
exec.SetFinishGuard(func(ctx context.Context) error {
if pendingSubTasks() {
return errors.New("async sub-tasks are still running; wait for them before finishing")
}
return nil
})If the guard returns an error, finish is rejected and a nudge containing the error message is injected. On the second attempt, finish is accepted regardless (soft gate).
The executor reads several optional values from the context. These are injected by the caller or by the orchestration layer before Run.
| Helper | Purpose |
|---|---|
WithStepID(ctx, id) / StepIDFromContext(ctx) |
Attaches the current plan-step ID. |
WithTrajectoryStore(ctx, store) / TrajectoryStoreFromContext(ctx) |
Injects a trajectory store. |
WithToolResultCache(ctx, cache) / ToolResultCacheFromContext(ctx) |
Makes the cache available to the tool_result_read tool. |
WithPerToolTruncation(ctx, cfg) / PerToolTruncationFromContext(ctx) |
Shares truncation config with tool_result_read for num_lines enforcement. |
WithStepTodoUpdateFunc(ctx, fn) / StepTodoUpdateFuncFromContext(ctx) |
Callback for checklist/to-do updates. |
WithDumpWriter(ctx, w) / DumpWriterFromContext(ctx) |
Injects an io.Writer for LLM request/response dumps. |
Additional stores for inter-step communication and user-provided context live in the agent package: StepOutputStore, FactStore, AttachmentStore, and FinalResultStore, each with a With*/*FromContext pair. AttachmentStore is read-only access to user-attached files (converted to markdown). See Subagents for how these are wired during parallel plan execution.
The executor talks to the model through the LLMCaller interface, which makes it trivially wrappable. The agent package ships two debugging decorators you can stack onto the caller the executor receives — no changes to the executor itself:
import "github.com/v0lka/sp4rk/agent"func NewDumpCaller(inner agent.LLMCaller, w io.Writer, logger *slog.Logger) agent.LLMCallerWraps inner so that every Call writes the full, untruncated ChatRequest and ChatResponse as JSONL records to w. Each record carries a UTC timestamp, a direction (request/response), the payload, and (for responses) an error field. Pass nil for w (or logger) to no-op. This is the mechanism behind WithDumpWriter above — but NewDumpCaller lets you attach a dump writer to the caller directly, independent of context plumbing (e.g. when wiring an executor that runs many steps into one shared file).
func NewLoggingLLMCaller(inner agent.LLMCaller, provider string, logger *slog.Logger) agent.LLMCallerLogs, at DEBUG level, the outgoing request (provider, model, message/tool counts) and the response token usage (input/output/total tokens, stop reason, tool-call count). provider is a logical label (e.g. "anthropic") used only for log attribution; a nil logger returns inner unchanged. Because logging is DEBUG-level, it is toggled purely by the logger's level — no code changes needed to enable it in production.
Both decorators are transparent — they implement LLMCaller and forward the response/error untouched — so they compose freely and can be stacked with llm.NewTrackingCaller (for usage tracking):
caller := agent.NewLoggingLLMCaller(
agent.NewDumpCaller(rawCaller, dumpFile, logger),
"anthropic",
logger,
)
exec := agent.NewExecutor(caller, toolRegistry, 25)For per-step dumps orchestrated by the conductor (one step_<id>.jsonl file per plan step), the orchestration layer uses a StepDumpTracker and feeds each step's writer into the context via WithDumpWriter.
A complete standalone executor run:
exec := agent.NewExecutor(
llmRouter,
toolRegistry,
25,
agent.WithTokenCounter(tokenCounter),
agent.WithEvents(&myEvents{}),
agent.WithToolResultBudget(agent.DefaultToolResultBudget()),
)
exec.SetLogger(logger)
exec.SetToolCache(agent.NewToolResultCache(5 * time.Minute))
exec.SetPerToolTruncation(agent.DefaultToolTruncationConfig())
exec.SetPreWarningPercent(80)
ctx := tools.WithWorkspacePath(context.Background(), workspaceDir)
ctx = agent.WithTrajectoryStore(ctx, &trajStore{})
result, err := exec.Run(ctx, taskTools, cm)
if err != nil {
log.Fatalf("execution failed: %v", err)
}
fmt.Printf("finished=%v, steps=%d\n", result.Finished, len(result.Steps))