Skip to content

feat(loop): the loop decides its own steps — one model call per step - #34

Merged
linhdmn merged 1 commit into
mainfrom
feat/reasoner
Sep 21, 2026
Merged

linhdmn merged 1 commit into
mainfrom
feat/reasoner

Conversation

@linhdmn

@linhdmn linhdmn commented Sep 21, 2026

Copy link
Copy Markdown
Member

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.
  • The tool picker was tools[step%4] with {step, goal}.
  • onegw was called on exit paths only — never mid-loop.

It acted, but never observed → reasoned. That 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. 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

Property Why
a failed decision is not fatal the run falls back to the rotation and records the reason in reason_errors and on the step's why. 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" bugs
done is the goal predicate, not a bound exits success with a new exit_reason=goal_met instead of spinning to max_steps (move 2: separate success from stopping). 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

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 itself the bug

NewRunnerWithPlannerAndGate passed nextToolDefault as 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 read 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.

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 in cmd/agentloop.

make check green: gofmt, vet, 14/14 packages, lint 0 issues, PRD OK, selftest 12/12.

CI shows red on this PR — the org's Actions billing limit is still unraised (#31's caveat, recorded in USAGE §2). make check runs the identical five steps.

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
linhdmn merged commit 2bceba8 into main Sep 21, 2026
1 of 3 checks passed
@linhdmn
linhdmn deleted the feat/reasoner branch September 21, 2026 08:54
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant