loop

package
v0.18.0 Latest Latest
Warning

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

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

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

View Source
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.

View Source
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.

View Source
const MaxCriterionOutput = 2000

MaxCriterionOutput is how much of one failing criterion reaches the model.

The same number as qualifier.MaxOutput, and the same by decision rather than by coincidence: it is the same information, from the same runner, read by different readers. Two ceilings for one concept would be two behaviours.

Per criterion, never per report. A set with four red criteria delivers four blocks, because cutting the fourth on account of the first three would hide whatever the map's iteration order happened to hide — and a map's order is not a product decision.

Variables

This section is empty.

Functions

func DelegateInstructions

func DelegateInstructions(toolNames []string, owns []string) string

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

func IsRepeat(calls []ce.ToolCall, n int) bool

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 SortedPlan

func SortedPlan(items []protocol.PlanItem) []protocol.PlanItem

SortedPlan returns the session plan in a stable order, for clients that render it.

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"
	CriterionUnavailable CriterionState = "unavailable" // nothing to run
)

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

type DoneSet struct {
	Criteria  []Criterion
	Protected []string
}

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

func (d DoneSet) TouchedProtected(written []string) []string

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

type Emitter interface {
	Emit(ev protocol.EventType, payload any)
}

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 New

func New(cfg Config, session ce.Session) *Engine

New builds an engine over an initial 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) DoneCriteria added in v0.10.0

func (e *Engine) DoneCriteria() int

Mode reports the sandbox mode and approval policy in force right now.

Every reader goes through here — evaluate decides one call with it, childConfig builds a delegated turn from it — because SetMode writes the pair from the HTTP handler while a turn runs. Reading the fields directly is a data race, which is what the first version of this did on the delegation path while guarding only the other one. DoneCriteria is how many criteria this engine is measured against.

Under the same lock as Mode, and for the same reason: the definition of done is frozen when the session is born, but reading it beside a writer without the lock is how a value gets copied from a truth that moves.

func (*Engine) Exec added in v0.6.0

func (e *Engine) Exec(ctx context.Context, command string) (string, error)

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) Mode added in v0.7.0

func (*Engine) Report

func (e *Engine) Report() Report

Report is what the most recent done check found.

func (*Engine) Run

func (e *Engine) Run(ctx context.Context, input string, images ...ce.Image) (Outcome, error)

Run executes one turn: appends the input, then cycles until the model stops asking for tools.

func (*Engine) Session

func (e *Engine) Session() ce.Session

Session returns the current session state.

func (*Engine) SetMode added in v0.7.0

func (e *Engine) SetMode(mode policy.SandboxMode, pol policy.ApprovalPolicy)

now is the engine's clock. Injectable so a test can assert a duration without waiting for one. SetMode switches the engine sandbox mode and approval policy at runtime.

The pair moves together because it is one decision: plan is read-only AND never asks, and applying half of that would leave a mode nobody chose in force between the two writes.

Live turns are NOT interrupted. The next tool call observes the new mode; a call already in flight finishes under whatever was in force when it started.

func (*Engine) Undo

func (e *Engine) Undo() (restored, refused []string, err error)

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 Movement added in v0.16.0

type Movement int

Movement is what one cycle did to the unmet set.

Three answers where there used to be two. Progressed returned a boolean, so drawing and regressing collapsed into "not progress" and the loop counted both as a stall — it knew a cycle had made things worse and the fact died on that line, because there was nothing to do with it.

const (
	// MovedForward: the set shrank and everything left was already in it.
	MovedForward Movement = iota
	// MovedNowhere: nothing got better and nothing got worse.
	MovedNowhere
	// MovedBackward: something that was met is not met any more.
	MovedBackward
)

func Moved added in v0.16.0

func Moved(before, after []string) Movement

Moved classifies one cycle against the last.

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 movement — deterministic, cheap, and immune to effort that produces nothing.

Regression is a name in `after` that was not in `before`: the criterion passed, and now it does not. Deliberately narrow — it is the only reading that justifies throwing away a cycle's work, and a wider one would undo cycles that merely failed to finish.

Swapping one failure for another is REGRESSION, not a draw. {a,b} → {a,c} means c passed and stopped passing, and the fact that b was fixed in the same cycle does not put c back. Progressed already refused to call this progress; what changes is that it now has a consequence.

func (Movement) String added in v0.16.0

func (m Movement) String() string

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 Output added in v0.15.0

type Output struct {
	Text string
	// Truncated marks output that did not fit. Nothing in this codebase cuts
	// output without saying so.
	Truncated bool
}

Output is what one criterion printed, bounded.

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
	// Outputs is what each criterion that did not pass printed, by name.
	//
	// A separate map rather than a field on the state: CriterionState is an
	// enum compared between cycles and printed to a person, and hanging text
	// off it would change what that comparison means. Progressed still reads
	// names and nothing else.
	//
	// Only the ones that did not pass. A green criterion's output is noise
	// paid for on every round, and what it had to say was said by its exit
	// code. This is what the loop used to throw away on the line that ran the
	// command — the model was told a criterion had failed and never what
	// broke, while the qualifier, reading the same runner, kept it.
	Outputs map[string]Output
}

Report is what a turn ended knowing.

func Check

func Check(ctx context.Context, set DoneSet, run CriterionRunner, timeout time.Duration) Report

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.

func (Report) OutputTexts added in v0.15.0

func (r Report) OutputTexts(set DoneSet) map[string]string

OutputTexts is what the unmet criteria printed, ready for the prefix.

The truncation marker is added HERE rather than stored, because it is a sentence for a reader and Output.Truncated is a fact about the bytes. Storing the sentence would put prose in a struct the loop compares, and would have to be written in the product's voice by whoever ran the command.

func (Report) String

func (r Report) String() string

String renders the report for a person.

func (Report) Unmet

func (r Report) Unmet() []string

Unmet returns the names of the criteria not met, sorted.

Sorted because the set is compared between cycles and printed to a person, and a set that reshuffles is one nobody can diff.

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
	VerificationUnavailable Verification = "unavailable" // changed, and no command is known
)

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.

Directories

Path Synopsis
Package loopcommand is the /loop façade over the agent loop's done definition.
Package loopcommand is the /loop façade over the agent loop's done definition.
Package qualifier raises a definition of done when there is none to read.
Package qualifier raises a definition of done when there is none to read.

Jump to

Keyboard shortcuts

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