feat(loop): the loop decides its own steps — one model call per step - #34
Merged
Merged
Conversation
The loop could not find anything: `planner.Plan()` emitted "Execute the next
action for: <goal>" and the runner read only the step's *tier* from it, so a
plan's words never reached a tool. The tool picker was
`tools[step%4]` with `{step, goal}`, and onegw was called on exit paths only.
It acted, but never observed-then-reasoned — which is the whole loop.
## internal/loop/reason.go
One model call per step. The model is handed the goal, the context, the plan
step if there is one, and the **verbatim result of the previous step** — the
thing a rotation cannot use — and answers:
{"tool":"query","args":{"query":"parseConfig"},"why":"locate the symbol","done":false}
The tool contract in its system prompt is the registry's own descriptions,
`DO NOT USE WHEN` clauses included (PRD move 4 calls that the highest-ROI
prompt hour). A name that is not in the registry is refused before the gate
sees it — otherwise the gate fails closed and holds a step nobody can
approve.
Answer parsing tolerates what models actually send: bare JSON, fenced JSON,
and JSON prologued with a sentence. The extractor is brace-aware, so
`{"content":"f(x) { return 1; }"}` does not truncate at the first `}`.
## Three properties that are deliberate
- **A failed decision is not fatal.** The run falls back to the rotation,
records the reason in `reason_errors`, and writes it on the step's `why`.
A silent fallback is how a run looks fine while the model is
unreachable — the same failure class as this repo's other "green check
that cannot fail" bugs.
- **`done` is the goal predicate, not a bound.** It exits `success` with
the new `exit_reason=goal_met` rather than spinning to `max_steps`
(move 2: separate success from stopping). It is the one exit a model can
reach on its own.
- **A resume replays the approved decision.** Re-asking lets the model
return a *different* tool, so the operator would have approved one action
and a different one would run. Cost is the smaller reason.
## Trace
`StepRecord` gained `Args` and `Why`. A trace carrying only an args *hash*
cannot answer "what did it actually try?" — the first question anyone asks of
a surprising step, and what a replay needs.
## The production constructor was the bug
`NewRunnerWithPlannerAndGate` passed `nextToolDefault` as the picker, so in
production the rotation always won and the reasoner was unreachable. Found by
an end-to-end test through the HTTP API (which is why that test exists): the
step said `why: "picker: injected"` while a gateway sat idle.
## Verified live, against a stub gateway
goal: "fix the off-by-one in parseConfig"
step 0 query args={"query":"parseConfig"} why="locate the symbol before guessing"
step 1 (none) why="the fix is written and the tests are the verification step"
state: success exit: goal_met usage: 5 model calls, 210 tokens
The tool, its arguments, and the rationale are all the model's.
## Tests
10 new in `internal/loop` (choice honoured, prompt carries the last result,
fenced/prologued parsing, invented tool refused, transport failure falls back
and records, no-model is byte-identical, system prompt contract, usage
counted, args recorded, resume replays) plus one end-to-end through the HTTP
API in `cmd/agentloop`. `make check` green: 14/14 packages, lint 0 issues,
PRD OK, selftest 12/12.
Docs: PRD §4 records the chooser and the three properties; USAGE separates
"the chooser is real, the planner is not".
linhdmn
added a commit
that referenced
this pull request
Sep 21, 2026
…inters (#38) The index said "the three remaining stub tools, the model-driven planner and tier-combo names are the next milestone". Three of those four are now landed (#33 execution, #34 the reasoner, #35 tier routing), so the note was stale and the real next step was unnamed. Rewritten as a handoff, with each item verified against the code rather than recalled: 1. **The loop cannot read a file** — four tools, none of which returns file *content*. `query` returns graph elements with a 400-char excerpt and a rung; it is not a reader. So the reasoner locates parseConfig, learns it is in config.go:41, and cannot look at it — which is exactly why the live demo wrote `func parseConfig() {}`. `write_file`'s description already promises a "read twin" that does not exist. 2. **The run has no answer** — `goal_met` stores its synthesis in `PartialSynthesis`, a field named for the bound case, and the model's rationale sits in a step's `why`. 3. **Success criteria are prose** — `ps.Success` appears exactly once in the codebase: as prompt text in reason.go. Nothing evaluates it. 4. `web_search` is the last stub (or should be deleted). 5. The two gaps #37 recorded: reply-out unscreened, ~740ms/call unmetered. 6. Planning is still a rule table (no longer blocking — the chooser carries). Plus the working notes a new session loses time rediscovering: the worktree rule, `make check` as the gate, the shell quirks (nohup for backgrounded servers, pkill matching its own command line, ports 8080/9699 taken), and the one that matters most — every defect this repo shipped lately was a check that could not fail (#25, #29, #32, #37, and #8's checklist ticked on the author's behalf). Verify by breaking the thing, not by watching it pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The missing piece
The loop could not find anything, and the reason was concrete rather than vague:
planner.Plan()emitted "Execute the next action for: <goal>" and the runner read only the step's tier from it — a plan's words never reached a tool.tools[step%4]with{step, goal}.It acted, but never observed → reasoned. That is the whole loop.
internal/loop/reason.goOne model call per step. The model is handed the goal, the context, the plan step if there is one, and the verbatim result of the previous step — the thing a rotation cannot use — and answers:
{"tool":"query","args":{"query":"parseConfig"},"why":"locate the symbol","done":false}The tool contract in its system prompt is the registry's own descriptions,
DO NOT USE WHENclauses included. A name that is not in the registry is refused before the gate sees it — otherwise the gate fails closed and holds a step nobody can approve.Answer parsing tolerates what models actually send: bare JSON, fenced JSON, and JSON prologued with a sentence. The extractor is brace-aware, so
{"content":"f(x) { return 1; }"}does not truncate at the first}.Three deliberate properties
reason_errorsand on the step'swhy. A silent fallback is how a run looks fine while the model is unreachable — the same class as this repo's other "green check that cannot fail" bugsdoneis the goal predicate, not a boundsuccesswith a newexit_reason=goal_metinstead of spinning tomax_steps(move 2: separate success from stopping). The one exit a model can reach on its ownStepRecordgainedArgsandWhy: a trace carrying only an args hash cannot answer "what did it actually try?": the first question anyone asks of a surprising step, and what a replay needs.The production constructor was itself the bug
NewRunnerWithPlannerAndGatepassednextToolDefaultas the picker — so in production the rotation always won and the reasoner was unreachable. Caught by the end-to-end test through the HTTP API (which is why that test exists): the step readwhy: "picker: injected"while a gateway sat idle.Verified live, against a stub gateway
The tool, its arguments, and the rationale are all the model's.
What is still not model-driven
The planner. The chooser carries the run; the plan is a hint — phases and instructions still come from
planStepCount(goal word count). Stated in USAGE §9 rather than implied.Tests
10 new in
internal/loop— choice honoured, prompt carries the last result, fenced/prologued parsing, invented tool refused, transport failure falls back and records, no-model is byte-identical, system-prompt contract, usage counted, args recorded, resume replays — plus one end-to-end through the HTTP API incmd/agentloop.make checkgreen: gofmt, vet, 14/14 packages, lint 0 issues, PRD OK, selftest 12/12.