Documentation
¶
Overview ¶
Package loop is the turn cycle: it orchestrates the context engine, the provider, the tools and the sandbox. Everything else exists to serve it.
It is also where the product gains or loses its character. A loop that aborts on the first tool error produces a brittle agent; one with no ceiling produces an expensive one; one that executes outside the boundary produces a dangerous one.
Spec: docs/specs/architecture/agent-loop/202608072335-*.
Index ¶
- Constants
- func DelegateInstructions(toolNames []string, owns []string) string
- func IsRepeat(calls []ce.ToolCall, n int) bool
- func Progressed(before, after []string) bool
- func SortedPlan(items []protocol.PlanItem) []protocol.PlanItem
- type Approver
- type Config
- type Criterion
- type CriterionRunner
- type CriterionState
- type DelegateLimits
- type DelegateResult
- type DoneSet
- type Emitter
- type Engine
- func (e *Engine) Close()
- func (e *Engine) Delegate(ctx context.Context, task, path string, lim DelegateLimits, owns []string) (DelegateResult, error)
- func (e *Engine) Exec(ctx context.Context, command string) (string, error)
- func (e *Engine) Explore(ctx context.Context, task, path string, owns []string) (string, []string, []string, []string, bool, error)
- func (e *Engine) Report() Report
- func (e *Engine) Run(ctx context.Context, input string, images ...ce.Image) (Outcome, error)
- func (e *Engine) Session() ce.Session
- func (e *Engine) Undo() (restored, refused []string, err error)
- func (e *Engine) Verification() Verification
- type Group
- type Limits
- type Outcome
- type Report
- type ToolExecution
- type Verification
Constants ¶
const ExecName = "bash"
ExecName is the tool a typed command runs through.
Through the tool and not around it, deliberately. `!` is a shortcut past the model, never past the sandbox: the command declares its paths, the policy evaluates it, and a crossing is put to the person exactly as it would be had the model asked. A shell escape that skipped that would be a hole in the one boundary this product is built around.
const ExploreToolName = "explore"
ExploreToolName is the delegating tool, named here so the child's registry can exclude it without importing the tools package's own definition.
Variables ¶
This section is empty.
Functions ¶
func DelegateInstructions ¶
DelegateInstructions is what a child turn is told, exported so a harness that runs one uses the product's own words rather than a copy.
The same reason BudgetText is exported: a fixture that paraphrases the product measures the paraphrase. The copy drifts, and the drift is invisible until a contract fails for a reason nobody can find.
func IsRepeat ¶
IsRepeat reports whether the last n calls are identical.
Identity is over the tool *and* its canonicalised input. That is what lets the detector coexist with recovery: retrying with a different approach is not an identical call, so it never trips, while the same failing command three times does.
func Progressed ¶
Progressed reports whether the unmet set shrank strictly between cycles.
This is the exit condition, and it is deliberately not "everything is met". A loop that cannot exit until everything passes has four failure modes, and the fourth inverts the result: if the way out is green tests and the agent cannot get them, the shortest path out becomes weakening the test. The loop would exist to prevent a false report and would produce a false test.
So the loop runs on progress. Strict shrinkage of a set — deterministic, cheap, and immune to effort that produces nothing. A set that GROWS is a regression and counts as non-progress, not as movement.
Types ¶
type Approver ¶
type Approver interface {
Approve(ctx context.Context, req protocol.ApprovalRequest) (protocol.ApprovalDecision, error)
}
Approver resolves a boundary crossing. Returning deny on timeout is the implementation's responsibility, not the loop's: with nobody to ask, the alternative to denying would be granting in silence.
type Config ¶
type Config struct {
Provider provider.Provider
Tools *tools.Registry
State *tools.State
Emitter Emitter
Approver Approver
Limits Limits
Mode policy.SandboxMode
Policy policy.ApprovalPolicy
Model string
Parallel int
CtxConfig ce.Config
// Summarise generates compaction text. Supplied by the caller because it
// needs a model call, which is what keeps the context engine pure.
Summarise func(ctx context.Context, msgs []ce.Message) (string, error)
// Skills are indexed in the prefix and loaded here, on trigger (RN-7).
Skills []behavior.Skill
// InstructionChain is the set of instruction files frozen at session
// creation. Anything found outside it becomes a reminder, never a prefix
// change (RN-6 of the configuration spec).
InstructionChain []string
// ReadFile backs the changed-on-disk check. Injected so the loop stays
// testable without a filesystem; nil disables the check.
ReadFile func(path string) (string, error)
// Rules are the patterns that ask a question the sandbox cannot.
Rules policy.Rules
// NetworkGrant answers whether reaching out is already authorized. Looked
// up per call rather than read once, because somebody can grant it while a
// turn is running.
NetworkGrant policy.NetworkGrant
// ShowReasoning forwards the model's thinking to clients.
ShowReasoning bool
// Reminders disables the appended-notice channel when false.
Reminders bool
// Backoff is how long to wait after a retryable provider error. The zero
// value uses the shipped policy.
Backoff provider.Backoff
// BudgetNotice switches the occupancy warning on. Off leaves the
// post-compaction notice as the only signal, which is what it was before.
BudgetNotice bool
// Done is the definition of done for this workspace. Empty means the turn
// ends when the model stops calling tools, which is what it did before.
Done DoneSet
// DoneEnabled switches re-entry on unmet criteria on.
DoneEnabled bool
// MaxStallCycles is how many cycles without progress end the turn in
// StopIncomplete. Two, because the legitimate case exists: one cycle to
// diagnose, another to fix. Three is the model going in circles.
MaxStallCycles int
// DoneTimeout caps one criterion. A check that never finishes is not a
// check, and hanging the turn is worse than reporting the overrun.
DoneTimeout time.Duration
// RunCriterion executes a criterion command. Injected, and it goes through
// the sandbox like everything else.
RunCriterion CriterionRunner
// DelegateMaxIterations caps a child turn. Smaller than the parent's on
// purpose: the child answers ONE question, and one that needs fifty
// iterations should have been split.
DelegateMaxIterations int
// DelegateMaxResultBytes caps the child's report. A child returning 50KB
// defeats the point — the cost of reading returns through the answer.
DelegateMaxResultBytes int
// WrittenPaths reports what the session has written, for the protected-path
// check. Nil disables it.
WrittenPaths func() []string
// AfterTurn is told what the session has written, once per turn, after the
// turn ends. Injected like every other side effect the loop needs: it knows
// when a turn finishes and what was touched, and nothing about what any of
// it means.
//
// Called on every ending, including an interrupted one — the files a
// half-finished turn wrote are the ones most likely to need attention.
AfterTurn func(written []string)
// WriteSeq reports how many writes the session has made, ever-increasing.
//
// A COUNT, not a set and not a clock. The set cannot answer this: rewriting
// a file already in it leaves it unchanged, and rewriting a file after the
// check is the exact case being caught. A clock would answer it and put a
// value that varies per run into something a person compares between turns.
//
// Nil leaves the seal as it was — staleness unknown, never asserted.
WriteSeq func() uint64
// Steer hands over what the person said while this turn was running, or ""
// when they said nothing. Called once at the top of every round.
//
// A puller rather than a channel: the session owns the engine, so the
// session is what holds the queue, and a channel here would be a second
// place for a message to sit and be forgotten. Nil is the old behaviour and
// costs nothing.
Steer func() string
// Sleep is how the loop waits out a backoff. Injectable for the same reason
// Now is: a test that asserts retry behaviour should not spend fifteen
// seconds proving it. Nil means the real one.
Sleep func(ctx context.Context, d time.Duration) bool
// Now is the clock used to time tool calls. Nil means the real one.
Now func() time.Time
}
Config wires a turn.
type Criterion ¶
type Criterion struct {
Name string // how it appears in the report
Command string // what runs
ExitCode int // what counts as met; zero by default
}
Criterion is a condition of done that can be CHECKED.
Prose does not qualify. "Tests pass" is a fact; "the code is clean" is not, and a criterion judged by the model hands the decision about done back to the model — now with twenty turns spent on the way there.
type CriterionRunner ¶
type CriterionRunner func(ctx context.Context, command string) (exitCode int, output string, err error)
Runner runs one criterion. Injected so the loop stays testable without spawning processes, and so every command still passes through the sandbox.
type CriterionState ¶
type CriterionState string
CriterionState is the outcome of checking one.
const ( CriterionMet CriterionState = "met" CriterionUnmet CriterionState = "unmet" )
type DelegateLimits ¶
type DelegateLimits struct {
// MaxIterations is the child's own cap, still smaller than the parent's:
// a child does ONE piece of work, and one that needs hundreds of rounds is
// a piece that should have been split.
//
// It was 20, sized when a child could only answer a question. A child that
// reads a package and writes a note about it does more than answer, and 20
// truncates that before it starts.
MaxIterations int
// MaxResultBytes caps the report. Exceeded, it truncates and declares it.
// A child returning its whole context defeats the point — the cost of
// reading comes back through the answer.
//
// The cap is on the ANSWER, not on the work. Sized so a child can describe
// what it did without the parent paying to re-read what it read.
MaxResultBytes int
}
DelegateLimits bound a delegated turn.
None of them is optional. Delegation without a ceiling is a cost multiplier, not a saving.
type DelegateResult ¶
type DelegateResult struct {
// Conclusion is the answer, in prose.
Conclusion string
// Read are the paths the child actually opened.
//
// This is the mitigation for the problem that survives read-only
// delegation: "I found nothing wrong in the payment module" — did it find
// nothing, or did it not look? Redoing the work to check would cancel the
// whole gain. A list of paths does not prove the child understood, but it
// proves it looked, and it turns "trust me" into something a person can
// spot-check.
Read []string
// Wrote are the paths the child changed.
//
// Travelling with the conclusion for the same reason Read does: it does not
// prove the work was right, but it turns "trust me" into something a person
// can spot-check — and here it also says which of several children touched
// what, which is the question a divided piece of work raises first.
Wrote []string
// Unread are paths a rule refused. Reported, never swallowed: a conclusion
// with an undeclared hole is a wrong conclusion wearing the face of a
// complete one.
Unread []string
// Unwritten are the changes a rule refused.
//
// Separate from Unread because they answer different questions. An unread
// path is a hole in what the child knows; an unwritten one is work the
// parent asked for that did not happen, and reporting it as "could not
// read" would say the child never looked when in fact it looked, decided,
// and was stopped.
Unwritten []string
// Truncated reports that the conclusion was cut.
Truncated bool
}
DelegateResult is what a child turn hands back.
func (DelegateResult) String ¶
func (r DelegateResult) String() string
String renders the result for the parent's history.
type DoneSet ¶
DoneSet is the definition of done for a workspace.
Protected are the paths that ARE the measurement — test files, typically. Without this the rest is theatre: an agent that cannot leave the loop finds that the shortest way out is to weaken the thing measuring it, and a false test is strictly worse than a false report. A false report is discovered by running something; a false test sits in the repository pretending to be coverage forever.
It is not a prohibition. Sometimes fixing the test IS the work. It is visibility, and that is the whole difference between a loop that ensures quality and one that manufactures the appearance of it.
func (DoneSet) TouchedProtected ¶
TouchedProtected returns the written paths that match a protected pattern.
Matching reuses policy.Glob, which already exists and already governs the confirm-write rules — one glob dialect in the product, not two.
type Emitter ¶
Emitter publishes observable facts. The loop never writes to a terminal: every client — TUI, IDE, none at all — sees the same session through this.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine runs turns against one session.
func (*Engine) Close ¶
func (e *Engine) Close()
Close releases what the engine owns beyond a single turn.
Today that is the background processes started through bash. The chain is deliberate — session owns engine owns tool state owns processes — because it makes "a process dies with the session" a consequence of ownership rather than a cleanup step someone has to remember to write. There is no handler to forget to register, and no path where the session ends and the process does not.
func (*Engine) Delegate ¶
func (e *Engine) Delegate(ctx context.Context, task, path string, lim DelegateLimits, owns []string) (DelegateResult, error)
Delegate runs a read-only sub-turn and returns its report.
The lock is structural, not conventional. The child is built here with policy.ModeReadOnly, and the tool it would need to delegate again is not in its registry — so nesting is impossible rather than forbidden. Compare DoctrineOverlay, where Safety is not a field: the guarantee is the absence, not a condition somewhere that could be edited out.
func (*Engine) Exec ¶ added in v0.6.0
Exec runs a command the PERSON typed, outside any turn.
The output goes two places, and both matter. It reaches the screen as the tool events the stream already knows how to draw, and it reaches the history as one user message — because the user did run it, and the next turn has to know what they saw. Anything else makes the model answer about a workspace it cannot see the state of.
It is a user message rather than a tool result on purpose: a tool result with no tool call before it is a shape no provider accepts, and inventing an assistant call the model never made would put words in its mouth to hold an output it did not ask for.
func (*Engine) Explore ¶
func (e *Engine) Explore(ctx context.Context, task, path string, owns []string) (string, []string, []string, []string, bool, error)
Explore adapts the engine to the tools.Delegator interface.
The adapter lives here rather than in the tools package because everything it decides — read-only mode, the reduced registry, the denying approver, the budget debit — is a property of what a turn is, and turns belong to the loop.
func (*Engine) Run ¶
Run executes one turn: appends the input, then cycles until the model stops asking for tools.
func (*Engine) Undo ¶
Undo puts back what the last turn changed.
Not a tool: the model does not get to undo its own work, because the judgment undo exists for is the person's. It is an operation on the session, asked for through the client, and the loop only owns it because the loop owns the state that knows what changed.
func (*Engine) Verification ¶
func (e *Engine) Verification() Verification
Verification is the single-criterion seal for the client.
type Group ¶
type Group []ToolExecution
Group is a set of executions that may run concurrently.
func Schedule ¶
func Schedule(execs []ToolExecution, maxParallel int, needsApproval func(ToolExecution) bool) []Group
Schedule splits calls into groups that are safe to run together.
Pure, and deliberately so: scheduling is the easiest part to get subtly wrong and the cheapest to test in isolation.
Two calls are separated when running them together would make the outcome depend on a race:
- two writes to the same path;
- a read and a write of the same path;
- any two system commands, whose side effects are arbitrary;
- anything that needs approval, since a user decision is sequential.
Parallelism is kept for reads and searches, which is where it is both safe and where the wall-clock gain actually is. needsApproval, when supplied, reports the calls that must run alone because the user will be asked about them. It is a predicate rather than a field because only the engine can evaluate a verdict, and a scheduler that demanded one would put policy inside a function whose whole job is ordering. Nil means the caller has nothing to say, and the grouping is what it always was.
type Limits ¶
type Limits struct {
// MaxIterations is the backstop. Zero means "use the family default",
// because the work horizon is a property of the model: a cap sized for a
// ten-file refactor truncates legitimate work on a long-horizon model.
MaxIterations int
// MaxIdenticalCalls is the real mechanism. A pathological loop shows up in
// three repeats, long before any sane iteration cap.
MaxIdenticalCalls int
// MaxTurnTokens is a hard cost ceiling. Zero is unlimited, and that is the
// default: cutting mid-turn leaves the workspace half-edited, which is a
// worse state than the one it started in. The iteration cap already bounds
// spend by construction.
MaxTurnTokens int
}
Limits bound a turn.
The three are deliberately redundant, defence in depth. If you find yourself lowering MaxIterations to control cost, the real problem is usually the repeat detector switched off or a tool returning uninformative errors.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits returns the documented defaults. MaxIterations stays zero so the family supplies it.
type Outcome ¶
type Outcome struct {
TurnID string
Reason string
Iterations int
Usage provider.Usage
// ContextTokens is what the assembled context costs now, not summed over
// the rounds. It lives here and not in provider.Usage because the size of a
// context is not something a provider reports — the two families this
// package speaks to disagree about whether their input count already
// includes the cached prefix.
ContextTokens int
}
Outcome is how a turn ended.
type Report ¶
type Report struct {
States map[string]CriterionState
// TouchedProtected are protected paths written during the turn. Surfaced,
// never counted as progress in silence.
TouchedProtected []string
}
Report is what a turn ended knowing.
func Check ¶
Check runs every criterion and reports the states.
An unavailable criterion never provokes re-entry: there is nothing to run, and insisting only produces another guess. It appears in the final report as what could not be checked.
func (Report) Names ¶
func (r Report) Names(want CriterionState) []string
Names returns the criteria in a given state, sorted.
type ToolExecution ¶
type ToolExecution struct {
// Index is the position the model emitted it at. Results are appended in
// this order regardless of which finishes first.
Index int
Call ce.ToolCall
Declare policy.Request
// Err is a declaration failure; such a call is scheduled alone so its
// error reaches the model in the right position.
Err error
}
ToolExecution pairs a tool call with its position in the model's emission and what it declared it would touch. ToolExecution is one call, from the model's emission to its result.
Named as the .p spec names it. The code called it ToolExecution, which was a different name for the same contract in a document whose first line is "use exactly these names" — someone following it literally wrote code that did not compile, and concluded the spec was decorative.
type Verification ¶
type Verification string
Verification is the single-criterion case, which is what the client shows.
There are not two mechanisms. A verify command is a DoneSet of one, and this type is how that one is named on screen.
const ( VerificationClean Verification = "clean" // nothing changed; nothing to check VerificationPassed Verification = "passed" // ran after the last edit, exited zero VerificationFailed Verification = "failed" // ran, exited non-zero VerificationStale Verification = "stale" // changed since the last check )
func VerificationOf ¶
func VerificationOf(r Report, changed, stale bool) Verification
VerificationOf collapses a report of one criterion into the client's seal.
stale says a file was written AFTER the check ran. It is the boolean the seal was missing: without it "passed" means "the check exited zero at some point this turn", which is true of a session that ran the suite and then edited everything it touched. That is the precise false confidence this whole mechanism exists to prevent, wearing the badge that says it was prevented.