From 93c9579ffed7f77f3bd2f7675201215b7425aa66 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:21:42 -0400 Subject: [PATCH 01/17] session: route wide reads to quick tasks, arm their no_progress dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff picture's pair ended in a cap — 'a quick task is a small thing: a few files, a few minutes' — which steered wide read-only sweeps back into the chat's own context, and the quick schema offered no way to raise the threshold the 2026-09-10 measurement died at. - beltfacts: 'AND READING SCALES' clause completes the judge's pair; 'a landing note IS the answer' clause; compressed to fund the lean prefix headroom (31,500 budget, came in ~5 bytes over before cuts) - quick_task: no_progress exposed (propose_task's threshold, same refusal on negatives); description names the dial instead of the cap - quick.md: findings first, paths absolute, explicit negative results Tracker: #1016 (wave A). --- internal/session/beltfacts.go | 43 ++++++++++++--------- internal/session/prompts/quick.md | 11 ++++-- internal/session/task_quick.go | 63 ++++++++++++++++++++----------- 3 files changed, 74 insertions(+), 43 deletions(-) diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index 2167670846..6cdd29729c 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -388,15 +388,25 @@ var beltFacts = []beltFact{{ // that the work is yours, so open it. Both verbs are on a belt together or on // neither ([Config.mayQuickTask]), which is what lets one fragment name them // both and be true wherever it renders. +// +// AND THE "READING SCALES" CLAUSE IS A PROPERTY, NOT THE RETURN OF THE SHAPE +// LIST. It does not say "a survey is quick"; it completes the pair the judge +// already held ("what you will read and carry on with is quick") against the +// cap that followed it ("a small thing: a few files, a few minutes"), which a +// real model read as "a wide read belongs elsewhere" — and the elsewhere it +// chose was its own context, twenty files of it. The clause arms the two dials +// a wide read needs (items, and `no_progress` raised) rather than pricing the +// two roads against each other again, and the lean prefix's headroom is what +// the shortened version is spent on. var handoffFacts = []beltFact{{ tools: []string{"propose_task", quickTaskToolName}, holds: Config.mayProposeTask, present: "You are one mind with a clock, and two ways to put more minds on the work run\n" + "beside you. A `quick_task` is a copy of your abilities working where you stand:\n" + - "it starts the instant you ask, reads and writes in this same folder, and its\n" + - "last message comes back to you as a note. A `propose_task` is a worker in a copy\n" + - "of the folder, checked and merged when it finishes, that outlives this window.\n" + - "Both are yours to steer and to stop, and both wake you when they land.\n" + + "it starts the instant you ask, works in this same folder, and its last message\n" + + "comes back to you as a note. A `propose_task` is a worker in a copy of the folder,\n" + + "checked and merged when it finishes, that outlives this window. Both wake you\n" + + "when they land.\n" + "\n" + "So WEIGH THE CLOCK BEFORE YOU BEGIN, and again each time the material shows you\n" + "more than you knew. WHEN THE ASK ITSELF NAMES SEVERAL THINGS, THOSE ARE THE\n" + @@ -405,21 +415,20 @@ var handoffFacts = []beltFact{{ "and independent pieces handed out in one breath cost the longest of them alone.\n" + "THE GOAL IS THE SHORTEST WALL TIME FOR THE WHOLE JOB: when what is ahead has\n" + "parts that do not need each other, hand them all out at once, however many there\n" + - "are, before you open the first, the way one mind with a team of workers would,\n" + - "and keep one to begin yourself, rather than working through them in turn, the\n" + - "order you fall into unless you choose otherwise. Do it even where each part is\n" + - "plainly something you could do yourself. That you could is not the question. The\n" + - "clock is. When the parts\n" + - "feed each other, keep them together: your own steps, or one quick task's items.\n" + - "One read, one edit, one command is never worth a hand-off. What must be checked\n" + - "and landed on its own, or must survive you, is a task; what you will read and\n" + - "carry on with is quick, and a quick task is a small thing: a few files, a few\n" + - "minutes.\n" + + "are, before you open the first, and keep one to begin yourself rather than\n" + + "working through them in turn. That you could is not the question. The clock\n" + + "is. When the parts feed each other, keep them together: your own steps, or\n" + + "one quick task's items. One read, one edit, one command is never worth a\n" + + "hand-off. What must be checked and landed on its own, or must survive you,\n" + + "is a task; what you will read and carry on with is quick. AND READING\n" + + "SCALES: a sweep across many files is still quick work — give it items and a\n" + + "raised `no_progress`, because reads are not progress.\n" + "\n" + "AFTER HANDING OUT YOU ARE NOT WAITING. Do the piece you kept, or answer what you\n" + - "can. Each landing comes to you as a note, and starts a turn if yours has ended,\n" + - "so fold them as they arrive. When nothing independent of what you handed out\n" + - "remains, end your turn. That is how you wait.", + "can. Each landing comes to you as a note, and starts a turn if yours has ended.\n" + + "A landing note IS the piece's answer — fold it, never re-open the work to check\n" + + "it. When nothing independent of what you handed out remains, end your turn.\n" + + "That is how you wait.", absent: "WORK IS YOURS TO DO HERE. There is nowhere to launch it at from where you\n" + "stand, so a sweep across many files, research across many sources or the same\n" + "change over many items is work you open and carry yourself, in the order that\n" + diff --git a/internal/session/prompts/quick.md b/internal/session/prompts/quick.md index 42e4bbb3ca..161762f759 100644 --- a/internal/session/prompts/quick.md +++ b/internal/session/prompts/quick.md @@ -33,8 +33,11 @@ question nobody put. YOUR LAST MESSAGE IS THE ANSWER. It is delivered to your caller whole, as it stands, and there is no report, no summary and no second chance to say it — so -end on the thing they need, in as many words as it takes and no more. Say -plainly what you could not do: an item you could not finish, a file that was not -where the line said, an assumption you had to make. A quick task that hides a -gap is worse than one that failed, because its caller carries on as though the +end on the thing they need, in as many words as it takes and no more. Findings +first, paths absolute: a caller that has to ask where anything lives is a caller +you made do the work twice. A search that found nothing says so — "searched X +and Y, no Z" is an answer, and a better one than a gap your caller cannot see. +Say plainly what you could not do: an item you could not finish, a file that was +not where the line said, an assumption you had to make. A quick task that hides +a gap is worse than one that failed, because its caller carries on as though the gap were not there. diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index ffb6a1a95d..2905b4124f 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -323,14 +323,20 @@ func quickReportOnClose(record taskRecord) string { // its own, or must survive you, is a task; what you will read and carry on with // is quick" — the same law in two places, paid for on every request of every // turn, and the page's copy is the one that renders wherever this verb does -// ([Config.mayQuickTask] puts both verbs on a belt or neither). What is left -// here is what only this string says: the two roads' shape in one sentence, the -// step that is not a hand-off at all, and the grain. +// ([Config.mayQuickTask] puts both verbs on a belt or neither). +// +// THE GRAIN CLAUSE GAINED ITS DIAL ON 2026-09-12, and the "one edit, one read, +// one command is a step" sentence went the way of the pair for the same reason: +// the page says it ("never worth a hand-off"). The clause as written priced the +// twenty-file death into a CAP — "a few files and a few minutes" — which read +// to the model as "wider reads belong elsewhere", and they landed in the chat's +// own context instead. The death's real lesson was the missing dial: the same +// `no_progress` raise propose_task has always offered for read-heavy work, +// now on this schema too, so the clause names the dial instead of the cap. var quickTaskDescription = "A task gets its own copy of the folder, is checked, and lands. A quick task works " + - "where you are and its last message is its answer. One edit, one " + - "read, one command is a step: do it yourself. Related steps that share what they learn are one quick " + - "task's items, not several quick tasks. KEEP ONE SMALL, a few files and a few minutes: reading is not " + - "progress, so " + strconv.Itoa(taskNoProgress) + " steps that only read end it." + "where you are and its last message is its answer. Related steps that share what they learn are one quick " + + "task's items, not several quick tasks. Keep one small; a wide read wants `no_progress` raised, because " + + "reading is not progress and " + strconv.Itoa(taskNoProgress) + " steps that only read end it." // quickTaskSchemaJSON is the wire schema. Every field but `line` is optional, // which is the whole shape of the verb: there is no contract to groom, no @@ -350,18 +356,20 @@ const quickTaskSchemaJSON = `{"type":"object","properties":{` + `"inherit":{"type":"boolean","description":"Opens on this conversation"},` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids whose result it needs"},` + `"title":{"type":"string","description":"Row title; the line is used without one"},` + - `"model":{"type":"string","description":"ONLY where the person named a model"}` + + `"model":{"type":"string","description":"ONLY where the person named a model"},` + + `"no_progress":{"type":"integer","description":"Optional, propose_task's threshold"}` + `},"required":["line"],"additionalProperties":false}` // quickArguments is the wire form. type quickArguments struct { - Line string `json:"line"` - Items []string `json:"items"` - Files []string `json:"files"` - Inherit bool `json:"inherit"` - DependsOn []uint64 `json:"depends_on"` - Title string `json:"title"` - Model string `json:"model"` + Line string `json:"line"` + Items []string `json:"items"` + Files []string `json:"files"` + Inherit bool `json:"inherit"` + DependsOn []uint64 `json:"depends_on"` + Title string `json:"title"` + Model string `json:"model"` + NoProgress int `json:"no_progress"` } // quickAsk is one quick node AS A ROAD HANDS IT OVER, before anything about it @@ -377,12 +385,13 @@ type quickArguments struct { // of the kind, and a road that could say any of it would be a road that could // say it differently. Those are decided in one place, [Agent.newQuickSpec]. type quickAsk struct { - line string - items []string - files []string - dependsOn []uint64 - title string - model string + line string + items []string + files []string + dependsOn []uint64 + title string + model string + noProgress int // inherit asks for a PROMOTION rather than a cold worker: the node opens on // the caller's own transcript instead of on a sentence about it (inherit.go). // It is a property of the ask rather than a second door, so both roads that @@ -397,7 +406,7 @@ type quickAsk struct { func (parsed quickArguments) askOf() quickAsk { return quickAsk{line: parsed.Line, items: parsed.Items, files: parsed.Files, dependsOn: parsed.DependsOn, title: parsed.Title, model: parsed.Model, - inherit: parsed.Inherit} + noProgress: parsed.NoProgress, inherit: parsed.Inherit} } // quickTools is the belt door for `quick_task`. @@ -636,6 +645,12 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { for _, claim := range quick.waits { dependsOn = append(dependsOn, claim.id) } + // A NEGATIVE THRESHOLD IS REFUSED THE WAY propose_task'S IS (task.go): a call + // the model can make again, correctly. Zero names the default, exactly as it + // does there ([TaskNode.limits]). + if ask.noProgress < 0 { + return taskSpec{}, "Invalid arguments: no_progress cannot be negative" + } return taskSpec{ title: title, named: true, @@ -676,6 +691,10 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { // happening in ([TaskMode] spells the five modes out). ground: a.config.Workspace, mode: TaskModeInPlace, + // THE GRAIN DIAL TRAVELS WITH THE NODE. Reading is not progress to the + // runner's counter (task_child_run.go), so a sweep that must read a great + // deal first dies at the default without the raise its caller named. + noProgress: ask.noProgress, // "in place" is the person's own word for this (places.go), and it is // written here rather than left blank because the index's `where` column // is read by somebody looking back at what a row did, and "" there reads From b6e22b476fcda538936bfb9c3f7912833a27fb51 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:28:21 -0400 Subject: [PATCH 02/17] manual, session: document the quick no_progress dial and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasks.md: 'How big one quick task should be' gains the wire fact — optional no_progress, default 6, same negative refusal as propose_task; and the stale description quote in 'Quick task or a proper task' is updated to the current wording (manual law). - TestTheGrainDialCarriesOntoTheAdmittedNode: through the real quick_task door — 40 lands on the node's limits, -1 is refused, omitted keeps the default. Tracker: #1016 (wave A). --- internal/manual/chat/tasks.md | 18 ++++++---- internal/session/task_quick_door_test.go | 46 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 33b6c641e0..2963c0a9c9 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -266,12 +266,10 @@ task; a quick task is now allowed the blank it is supposed to have. The rule is written once, in the words the model itself reads: > A task gets its own copy of the folder, is checked, and lands. A quick task works where -> you are and its last message is its answer. If you will read the result and carry on, -> it is quick. If it must be checked and merged on its own, or survive the window -> closing, it is a task. One edit, one read, one command is a step: do it yourself. -> Related steps that share what they learn are one quick task's items, not several quick -> tasks. Keep one small — a few files and a few minutes: reading is not progress, so -> six steps that only read end it. +> you are and its last message is its answer. Related steps that share what they learn +> are one quick task's items, not several quick tasks. Keep one small; a wide read wants +> `no_progress` raised, because reading is not progress and six steps that only read end +> it. **What decides is what happens to the answer, never how wide the work is.** Work whose result comes back for the conversation to read and carry on with is quick tasks, one per @@ -313,6 +311,14 @@ So a large package is **several** quick tasks of a few files each, or one quick one item per small group — never one over all of it. And a quick task that starts quick tasks of its own cuts them smaller still, because its own room is already spent. +**The number it stops at is on the wire, exactly as on `propose_task`.** `quick_task` +takes an optional `no_progress`, defaulted to 6, and a negative value draws the same +refusal (`Invalid arguments: no_progress cannot be negative`). A **read-heavy sweep +raises it** rather than splits a package by count: one quick task with one item per +group and `no_progress` set high for the reads beats several tasks cut to the default — +because a worker that only reads is still the counter's case, and the number is moved, +not the rule. + ## Why several things started at once — three quick tasks in one message, why it did not do them one at a time **Because they did not need each other, and one after another is the slowest order.** diff --git a/internal/session/task_quick_door_test.go b/internal/session/task_quick_door_test.go index 4b93b05988..9daf8161e1 100644 --- a/internal/session/task_quick_door_test.go +++ b/internal/session/task_quick_door_test.go @@ -419,3 +419,49 @@ func TestAPartWithNoParentInTheReadingStandsAlone(t *testing.T) { t.Fatalf("a part was folded under a node of another window: %+v", families) } } + +// ── (6) the grain dial ────────────────────────────────────────────────────── + +// AN ASK THAT RAISED `no_progress` RUNS AT IT, ONE THAT SAID NOTHING RUNS AT +// THE DEFAULT, AND A NEGATIVE ONE IS REFUSED. +// +// The dial is how many steps that add nothing a node may take before the +// runner stops it as stuck ([TaskNode.limits] substitutes the default wherever +// the wire said zero), and a wide read is exactly the work that dies at the +// default without the raise — which is the death the dial exists to answer. The +// door's whole job with it is to carry it, in `propose_task`'s words and +// under the same refusal ([Agent.newQuickSpec]). +func TestTheGrainDialCarriesOntoTheAdmittedNode(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + graph := stubbedGraph(agent, func(*TaskNode) {}) + + said, failed, err := agent.startQuickTask(context.Background(), quickArgs(t, quickArguments{ + Line: "read the twenty schema files", NoProgress: 40, + })) + if err != nil || failed { + t.Fatalf("the door refused an ask that raised the dial: %q (%v)", said, err) + } + raised := graph.node(1) + if raised == nil || raised.limits().noProgress != 40 { + t.Fatalf("the admitted node's limits are %+v, want no_progress carried", raised.limits()) + } + + said, failed, err = agent.startQuickTask(context.Background(), quickArgs(t, quickArguments{ + Line: "read the twenty schema files", NoProgress: -1, + })) + if err != nil || !failed || said != "Invalid arguments: no_progress cannot be negative" { + t.Fatalf("a negative threshold answered %q (failed = %v, err = %v), want the refusal in propose_task's words", said, failed, err) + } + + said, failed, err = agent.startQuickTask(context.Background(), quickArgs(t, quickArguments{ + Line: "read the twenty schema files", + })) + if err != nil || failed { + t.Fatalf("the door refused a plain ask: %q (%v)", said, err) + } + // The refusal above consumed no id, so this is the second node admitted. + plain := graph.node(2) + if plain == nil || plain.limits().noProgress != taskNoProgress { + t.Fatalf("a node whose caller named nothing runs at %+v, want the default threshold", plain.limits()) + } +} From 637550d2c24a3d5bdd24ac5e70df8c81b4caf089 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:29:58 -0400 Subject: [PATCH 03/17] changelog: the wave A entry --- docs/changes/unreleased/1017-reads-scale-quick.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1017-reads-scale-quick.md diff --git a/docs/changes/unreleased/1017-reads-scale-quick.md b/docs/changes/unreleased/1017-reads-scale-quick.md new file mode 100644 index 0000000000..07d3d0499c --- /dev/null +++ b/docs/changes/unreleased/1017-reads-scale-quick.md @@ -0,0 +1,14 @@ +--- +kind: changed +title: wide read-only sweeps belong in quick tasks, and quick tasks may raise their no-progress ceiling +pr: 1017 +surface: [chat] +invalidates: + - "The quick-task judge capped its grain at 'a few files and a few minutes', so a wide read-only sweep was steered back into the chat's own context — and the quick schema had no `no_progress`, so the 2026-09-10 twenty-file sweep that died at six read-only steps had no dial it could have survived on. The judge now completes its pair with 'AND READING SCALES' (items plus a raised `no_progress`, because reads are not progress), and `quick_task` takes the same threshold `propose_task` has always offered." + - "A turn folding a landed piece of work could re-open the work to check it, which the carry-on road read as a continuation to write into the turn. The handoff picture now says a landing note IS the answer: fold it, never re-check." +--- + +Both clauses were paid for out of the fragments they join, because the lean +prefix's budget only ratchets down; the manual's description quote and the +'how big' section say the same things the wire does, and one regression test +pins the dial through the real door. From 5e21ba4fa2ba38aa202af02dd6be5b50ee6e72c2 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:30:37 -0400 Subject: [PATCH 04/17] changelog: quote the surface list in 1015's sweep entry (base branch YAML) --- docs/changes/unreleased/1015-stale-build-sweep.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1015-stale-build-sweep.md b/docs/changes/unreleased/1015-stale-build-sweep.md index b01711802b..7125510a7c 100644 --- a/docs/changes/unreleased/1015-stale-build-sweep.md +++ b/docs/changes/unreleased/1015-stale-build-sweep.md @@ -1,7 +1,7 @@ --- kind: added title: a launch sweep names live sessions on an older build -surface: [chat, engine] +surface: [session, tui3] pr: 1015 --- From 96b25142ea29f74d7774d75c2ac2f6a47470a802 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:30:58 -0400 Subject: [PATCH 05/17] changelog: map the sweep entry's surface onto the enum --- docs/changes/unreleased/1015-stale-build-sweep.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1015-stale-build-sweep.md b/docs/changes/unreleased/1015-stale-build-sweep.md index 7125510a7c..d48f049907 100644 --- a/docs/changes/unreleased/1015-stale-build-sweep.md +++ b/docs/changes/unreleased/1015-stale-build-sweep.md @@ -1,7 +1,7 @@ --- kind: added title: a launch sweep names live sessions on an older build -surface: [session, tui3] +surface: [engine, chat] pr: 1015 --- From e08fb9d4b0c39cd9fae6cfcfbe75b5387656eb0d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 15:55:23 -0400 Subject: [PATCH 06/17] =?UTF-8?q?session:=20consult=20=E2=80=94=20a=20boun?= =?UTF-8?q?ded=20second=20opinion=20to=20the=20tier=20that=20thinks=20(wav?= =?UTF-8?q?e=20B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One question to the mastermind tier, made a verb instead of a wish. The armed confirmations (route cascade, division review) carry the reliability; this door carries uncertainty the model CAN name — a fan-out shape, a reading it cannot resolve, a choice between roads. - roles.RoleConsult → TierMastermind, called through the existing callRole seam; the turn pays 2, then the refusal says the arithmetic - belt: conversations only (a task node's second opinion rides the division review); lean shelves it as the opinion group - ActionCoordinate; per-turn counter reset beside turnSpend - manual: 'asking for a second opinion' section (manual law) Tracker: #1016 (wave B). --- internal/manual/chat/models-and-cost.md | 20 +++- internal/roles/roles.go | 5 + internal/session/actioncategory.go | 3 +- internal/session/agent.go | 3 + internal/session/beltfacts.go | 2 +- internal/session/consult.go | 141 ++++++++++++++++++++++++ internal/session/consult_test.go | 96 ++++++++++++++++ internal/session/promptprofile.go | 7 ++ internal/session/session.go | 5 + internal/session/tools.go | 6 + 10 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 internal/session/consult.go create mode 100644 internal/session/consult_test.go diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 1c1b83fff6..dc5ebeb425 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1,6 +1,24 @@ # Models, context, and what it costs -## Lost internet, Wi-Fi disconnected, DNS errors, and waiting for connection +## Asking for a second opinion — the `consult` verb + +When the model itself wants a bigger model's judgement on something it is about to +commit to — a fan-out shape, a choice between roads, a reading it cannot resolve +— it can ask one question of the tier that thinks, with its `consult` tool, and +use the answer before it spends. + +At most **two a turn**, and the refusal says the arithmetic: `no: 2 consults are +already spent this turn`. That is the entire control. The question rides with the +facts the answering model cannot see, so the answer is a decision, not a summary. + +On a window small enough for the lean profile, `consult` waits on the shelf (the +`opinion` group) the way `propose_task` does, so its schema is not paid on every +request — it arrives with one `load_capability`. + +Task workers have no `consult` on their belt: inside a task, the armed version of +the second opinion is the division review, and two doors to the same tier is a +move the harness refuses by design. + When DNS or a connection attempt fails before the request is accepted, aforge shows `waiting for connection`. It pauses requests on that client and checks diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 3489da23be..fe2a65e864 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -176,6 +176,11 @@ const ( // work. What it costs is bounded hard: at most two calls, and only on a turn // that is being handed over at all, which most turns never are. RoleHandoff Role = "handoff" + // RoleConsult is the MODEL-INITIATED second opinion: one question to the + // tier that thinks, through the `consult` verb (internal/session's + // consult.go, which owns the call and registers it). Mastermind, for + // RoleRouterConfirm's reason: a wrong answer here routes real spend. + RoleConsult Role = "consult" // RoleTaskName is the two or three words a piece of work is CALLED on the // rail, the home card and the task list — made from the node's own gloss and // brief when whoever started it left a raw sentence there instead of a name. diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index ac41d0f03e..68394dec87 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -250,7 +250,8 @@ func ActionCategoryForTool(tool string) ActionCategory { return ActionCommunicate // Work handed out, or this mind copied to run beside itself. - case "propose_task", "quick_task", "divide_work", "workspace_fork", "stand": + case "propose_task", "quick_task", "divide_work", "workspace_fork", "stand", + "consult": return ActionCoordinate // Keeping the account of the work rather than doing it. `items` is a quick diff --git a/internal/session/agent.go b/internal/session/agent.go index 1ea51a5b2f..3663b4e5ba 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1644,6 +1644,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // abandoned turn is journaled with, and carrying the last turn's figure into // this one would put another turn's money on that line. a.turnSpend = Usage{} + // And the second-opinion budget opens again with the turn that must pay for + // it (consult.go). + a.consultCalls = 0 // done is how Close waits for this turn: closed under a.mu by the cleanup // below, after the turn's last message is journaled. done := make(chan struct{}) diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index 6cdd29729c..efe6da0155 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -428,7 +428,7 @@ var handoffFacts = []beltFact{{ "can. Each landing comes to you as a note, and starts a turn if yours has ended.\n" + "A landing note IS the piece's answer — fold it, never re-open the work to check\n" + "it. When nothing independent of what you handed out remains, end your turn.\n" + - "That is how you wait.", + "That is the wait.", absent: "WORK IS YOURS TO DO HERE. There is nowhere to launch it at from where you\n" + "stand, so a sweep across many files, research across many sources or the same\n" + "change over many items is work you open and carry yourself, in the order that\n" + diff --git a/internal/session/consult.go b/internal/session/consult.go new file mode 100644 index 0000000000..e8d9818bb5 --- /dev/null +++ b/internal/session/consult.go @@ -0,0 +1,141 @@ +package session + +// `consult` — the model's one-shot question to the tier that thinks, made a +// verb instead of a wish. +// +// THE CASCADE'S DOOR FORM. The confirmations that work in this build are +// HARNESS-ARMED — the route judge fires on every words-only answer, the +// division review rides divide_work — because the mark reader's measurement +// says the model that most needs a second opinion asks for it least. What this +// door adds is the escape valve for uncertainty the model CAN name: the +// fan-out shape it is unsure of, a reading it cannot resolve, a choice between +// roads. The armed doors still carry the reliability; this one carries the +// cases a harness cannot see. +// +// IT IS BOUNDED PER TURN, because the question it answers is "has this turn +// already bought its second opinion", and a second opinion-by-default is what +// the mastermind tier's patience (roles.go) is meant to be spent against. +// [rotationalConsultCeiling] is the count, spelled once, so a model reasoning +// from the wire reasons from the enforcement. + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/aforge-v2/internal/exec/bare" + "github.com/Agent-Field/aforge-v2/internal/roles" +) + +// RoleConsult is the second-opinion call. MASTERMIND, for RoleRouterConfirm's +// reason: a wrong answer here routes real spend, and the mistake a cheap model +// makes confidently is the one this verb exists to catch. +func init() { roles.Register(roles.RoleConsult, roles.TierMastermind) } + +const ( + // rotationalConsultCeiling is how many questions one turn may buy through + // `consult`. TWO: the first is the opinion, the second is the disagreement + // with it, and a third is a conversation that should have been its own task. + rotationalConsultCeiling = 2 + // consultQuestionLimit and consultContextLimit bound the wire form. The + // question is one thought; the context is the facts the answer needs, cut + // at a size a busy model still stops. Neither is a transcript — the + // person's words already ride the caller's prompt. + consultQuestionLimit = 2_000 + consultContextLimit = 8_000 +) + +// consultDescription is what the model reads before it asks. THE BOUND IS +// INTERPOLATED for taskDescription's reason: a number a model reasons with +// must be the number the code enforces. +var consultDescription = "One question to the tier that thinks, and its answer, " + + "for a decision that is bigger than your own confidence — a fan-out shape, " + + "a reading you cannot resolve, a choice between roads. Words in, a sentence " + + "out; it does no work. At most " + strconv.Itoa(rotationalConsultCeiling) + " a turn." + +// consultSchemaJSON is the wire form: one question, and the facts the answer +// needs, because the answering model holds none of yours. +const consultSchemaJSON = `{"type":"object","properties":{` + + `"question":{"type":"string","description":"The uncertainty you can name, one thought"},` + + `"context":{"type":"string","description":"The facts the answer needs. It holds none of your conversation."}` + + `},"required":["question"],"additionalProperties":false}` + +// consultAnswer is the question parsed out of the wire form. The fields are +// exported for [encoding/json], tagged on the wire's own names, and trimmed at +// the door so an empty one is a call the model can make again. +type consultAnswer struct { + Question string `json:"question"` + Context string `json:"context"` +} + +// consultSpent is the receipt a turn that emptied its budget gets, with the +// count, so the answer is arithmetic and not a shrug. +func consultSpent() string { + return "no: " + strconv.Itoa(rotationalConsultCeiling) + " consults are already spent this turn" +} + +// consultTool is the door. +// +// IT IS A CONVERSATION'S VERB AND NO WORKER'S, absent the way every +// conditional door on this belt is absent: a task node's second opinion rides +// the division review instead (task_divide.go), which is the armed version of +// the same instinct, and handing the model a second door to the same tier +// would be two answers to one question. +func (a *Agent) consultTool() bare.Tool { + return bare.Tool{ + Name: "consult", + Description: consultDescription, + Schema: json.RawMessage(consultSchemaJSON), + Execute: a.startConsult, + } +} + +// startConsult is the tool's whole life: parse, bound, ask, answer. Everything +// it can answer badly is an ordinary tool result, the way every other door on +// this belt answers: an empty question is a call the model can make again. +func (a *Agent) startConsult(ctx context.Context, args json.RawMessage) (string, bool, error) { + var parsed consultAnswer + _ = json.Unmarshal(args, &parsed) + parsed.Question = strings.TrimSpace(parsed.Question) + parsed.Context = strings.TrimSpace(parsed.Context) + if parsed.Question == "" { + return "Invalid arguments: question is empty", false, nil + } + a.mu.Lock() + if a.consultCalls >= rotationalConsultCeiling { + a.mu.Unlock() + return consultSpent(), false, nil + } + a.consultCalls++ + a.mu.Unlock() + + question := clip(parsed.Question, consultQuestionLimit) + if parsed.Context != "" { + question += "\n\nWHAT THE QUESTION NEEDS TO KNOW:\n" + clip(parsed.Context, consultContextLimit) + } + resp, from, err := a.callRole(ctx, roles.RoleConsult, a.model, + []ai.Message{ + textMessage("system", "You answer one question for a model working one desk down. It cannot ask you again, so decide: say what it should do and the one reason that carries it, plainly, in words. No tools, no ceremony, no new work."), + textMessage("user", question), + }) + if err != nil || resp == nil { + return "consult is unanswered: " + errString(err), false, nil + } + a.addAuxiliaryUsage(resp, from, 1) + answer := strings.TrimSpace(resp.Text()) + if answer == "" { + return "consult is unanswered: the tier that thinks said nothing", false, nil + } + return answer, false, nil +} + +// errString is the refusal's tail: the error if one exists, a plain word for +// the model that got back nothing at all. +func errString(err error) string { + if err == nil { + return "no answer came back" + } + return err.Error() +} diff --git a/internal/session/consult_test.go b/internal/session/consult_test.go new file mode 100644 index 0000000000..725f0af18c --- /dev/null +++ b/internal/session/consult_test.go @@ -0,0 +1,96 @@ +package session + +// THE SECOND-OPINION DOOR, THROUGH THE REAL BELT: the question reaches the +// tier it was registered on with the caller's facts beside it, the budget is +// per turn and says so when it is gone, and a worker's belt has no door at +// all — the armed version of the instinct is the division review's, and two +// doors to one tier is two answers to one question. + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// consultCall is the model asking for the second opinion, with the question +// and the facts the answering model cannot see. +func consultCall(id, question, facts string) step { + arguments, _ := json.Marshal(map[string]string{"question": question, "context": facts}) + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse(id, "consult", string(arguments)), nil + } +} + +// consultAside answers the role's own request — detected by the consult page's +// own words — without spending a configured step, the way every fixture's +// errand is answered beside the script. +func consultAside(answer string) func([]ai.Message) (*ai.Response, bool) { + return func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) == 0 || snapshot[0].Role != "system" || + !strings.Contains(messageText(snapshot[0]), "one question") { + return nil, false + } + return textResponse(answer), true + } +} + +func TestAConsultCarriesItsQuestionToTheTierThatThinks(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + consultCall("call-consult", "two quick tasks or one with items?", "the four configs differ in three keys"), + finalText("one with items, then"), + }} + completer.aside = consultAside("one quick task with four items; they share the third key") + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "how should I split this?")) + + // THE ANSWER IS THE TIER'S OWN SENTENCE, whole, as the tool's result. + responses := toolTexts(agent) + if len(responses) != 1 { + t.Fatalf("the consult answered %d times, want one", len(responses)) + } + if !strings.Contains(responses[0], "one quick task with four items") { + t.Fatalf("the consult came back with %q, want the tier's answer", responses[0]) + } + // AND WHAT CAME WITH IT IS THE QUESTION AND ITS FACTS, because the + // answering model holds none of the caller's conversation. + found := false + for _, snapshot := range completer.asides { + if len(snapshot) > 1 && snapshot[0].Role == "system" && + strings.Contains(messageText(snapshot[0]), "one question") { + last := messageText(snapshot[len(snapshot)-1]) + found = strings.Contains(last, "two quick tasks or one with items?") && + strings.Contains(last, "the four configs differ in three keys") + } + } + if !found { + t.Fatalf("no consult request carried the question and its facts: %v", completer.asides) + } + + // AND THE THIRD DOOR IS THE BUDGET'S, said with the arithmetic it is. + fresh := context.Background() + if _, _, err := agent.startConsult(fresh, json.RawMessage(`{"question":"another?"}`)); err != nil { + t.Fatal(err) + } + answer, _, err := agent.startConsult(fresh, json.RawMessage(`{"question":"again?"}`)) + if err != nil { + t.Fatal(err) + } else if !strings.Contains(answer, "2 consults are already spent") { + t.Fatalf("the budget's refusal read %q, want the arithmetic", answer) + } + if got := agent.consultCalls; got != 2 { + t.Fatalf("the turn paid %d consults, want the two it bought", got) + } +} + +func TestAWorkerIsHandedNoConsult(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.InTask = true }) + if agent.offers("consult") { + t.Fatal("a task node has a second opinion of its own: the armed door is the division review") + } + if !agent.offers("read") { + t.Fatal("the task node's own belt stopped carrying what it needs") + } +} diff --git a/internal/session/promptprofile.go b/internal/session/promptprofile.go index 2a2d7eaf37..7db8ebe135 100644 --- a/internal/session/promptprofile.go +++ b/internal/session/promptprofile.go @@ -360,6 +360,13 @@ var leanCapabilityGroups = []struct { // needed on the turns in between. capabilityGroup: capabilityGroup{name: "watching", members: []string{"watch"}}, holds: Config.mayWatch, +}, { + // A second opinion is reached for rarely, and the lean budget law says a + // rarely reached verb shelves (prefixbudget_test.go). It is loaded the same + // breath the model asks for it, so the small model consult serves keeps the + // door one call away rather than paying its schema on every turn. + capabilityGroup: capabilityGroup{name: "opinion", members: []string{"consult"}}, + holds: func(c Config) bool { return !c.InTask }, }, { // The working-state trio (state.go). A model small enough for this profile // keeps its state in the transcript it can see. diff --git a/internal/session/session.go b/internal/session/session.go index f94deb07f0..822d4e2a2b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2802,6 +2802,11 @@ type Agent struct { // the seal that would carry the figure is exactly what an abandoned turn // never writes. turnSpend Usage + // consultCalls is how many `consult` questions this turn has already spent + // ([Agent.consultTool]); it is reset beside turnSpend when a turn opens + // (agent.go's [Agent.startTurnLocked]), because the answer it bounds is + // "has this turn already bought a second opinion" and nothing else. + consultCalls int // contextWindow is the window learned after construction — the catalog's // figure for a model chosen with /model, which Config.ContextWindow cannot // carry because the model was picked long after New. Zero means nobody has diff --git a/internal/session/tools.go b/internal/session/tools.go index 8a73fc22f4..86490353b2 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -187,6 +187,12 @@ func (a *Agent) belt() []bare.Tool { // (task_quick.go). The judge that decides between the two is written once, in // its description. tools = append(tools, a.quickTools()...) + // consult is a conversation's verb (consult.go): a task node's second + // opinion rides the division review instead, so the door is absent there + // rather than present and refusing. + if !a.config.InTask { + tools = append(tools, a.consultTool()) + } // items is the verb a QUICK WORKER carries and nothing else does: a node with // no list has no door behind the tool, so it is absent rather than present // and refusing — the law every conditional family on this belt is built on. From 91d3915f5c5009541f0f7a7f2eb00b7ecd93b985 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:09:14 -0400 Subject: [PATCH 07/17] session: read quick fan-outs together before any of them starts (wave B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit propose_task has its countdown card and divide_work its review — a batch of quick tasks started under no review at all, and a mis-shaped fan-out spends money before anybody reads it. Read the batch together on the mastermind tier pre-spawn, and settle: amendments rewrite the calls, refusals are seated as ordinary refusals and never begin. Fail-open mirrors division review. - loop.go: the seam at the spawn gate, between the rendered begins and the goroutines (review copies divide_review's gate 1:1) - quickfan.go: RoleQuickReview, verdict shape, and the receipts - tests: amend+refuse wired through runToolsWarm, single ask spends none, unreadable review admits as written Tracker: #1016 (wave B). --- internal/roles/roles.go | 5 + internal/session/consult.go | 6 +- internal/session/loop.go | 20 ++++ internal/session/quickfan.go | 123 +++++++++++++++++++++++++ internal/session/quickfan_test.go | 148 ++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 internal/session/quickfan.go create mode 100644 internal/session/quickfan_test.go diff --git a/internal/roles/roles.go b/internal/roles/roles.go index fe2a65e864..506cd51207 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -181,6 +181,11 @@ const ( // consult.go, which owns the call and registers it). Mastermind, for // RoleRouterConfirm's reason: a wrong answer here routes real spend. RoleConsult Role = "consult" + // RoleQuickReview is the ONE reader a batch of ≥2 quick tasks gets before + // any of it starts (internal/session's quickfan.go, which owns the call). + // Mastermind, for RoleDivision's reason: the parts are read TOGETHER, and a + // cheap reader flattens merges it cannot see. + RoleQuickReview Role = "quickreview" // RoleTaskName is the two or three words a piece of work is CALLED on the // rail, the home card and the task list — made from the node's own gloss and // brief when whoever started it left a raw sentence there instead of a name. diff --git a/internal/session/consult.go b/internal/session/consult.go index e8d9818bb5..17871edba5 100644 --- a/internal/session/consult.go +++ b/internal/session/consult.go @@ -24,9 +24,9 @@ import ( "strconv" "strings" - "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/aforge-v2/internal/exec/bare" "github.com/Agent-Field/aforge-v2/internal/roles" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // RoleConsult is the second-opinion call. MASTERMIND, for RoleRouterConfirm's @@ -97,7 +97,9 @@ func (a *Agent) consultTool() bare.Tool { // this belt answers: an empty question is a call the model can make again. func (a *Agent) startConsult(ctx context.Context, args json.RawMessage) (string, bool, error) { var parsed consultAnswer - _ = json.Unmarshal(args, &parsed) + if err := decodeToolArguments(args, &parsed); err != nil { + return invalidArgumentsPrefix + err.Error(), true, nil + } parsed.Question = strings.TrimSpace(parsed.Question) parsed.Context = strings.TrimSpace(parsed.Context) if parsed.Question == "" { diff --git a/internal/session/loop.go b/internal/session/loop.go index 60f0676c44..35a59c3464 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3062,9 +3062,29 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa defer a.endPhase() } + // A QUICK FAN-OUT IS READ TOGETHER BEFORE ANY OF IT STARTS. Quick fan-outs + // start under no gate at all — propose_task has its countdown card and + // divide_work its review — so two or more of them in one batch get one read + // of the mastermind tier (quickfan.go) here, where the asks are still + // arguments. Amendments rewrite the calls; refusals are seeded as ordinary + // refusals in their own slots and never begun. A fail-open or single quick + // call falls straight through. + var refusedQuick map[int]string + if len(calls) > 1 { + amended, refused, _ := a.reviewQuickFan(ctx, calls, rendered) + calls = amended + refusedQuick = refused + } slots := newBatchSlots(calls) var wg sync.WaitGroup for index, call := range calls { + // A refused quick ask is settled here, in its own slot's place, and + // never spawns a goroutine — its result pairs by its CallID like every + // other refusal (consent.go's refusal-as-result shape). + if text, isRefused := refusedQuick[index]; isRefused { + slots.put(index, toolResult{text: text, isError: false}) + continue + } // A call the stream already started is not started again — the warm // entry is claimed by id, so no call in this batch can run twice — and // waiting for it is one more goroutine in the same batch, so a read that diff --git a/internal/session/quickfan.go b/internal/session/quickfan.go new file mode 100644 index 0000000000..bbed2bb16f --- /dev/null +++ b/internal/session/quickfan.go @@ -0,0 +1,123 @@ +package session + +// ONE REVIEW PER QUICK FAN-OUT, BEFORE ANY OF IT STARTS. +// +// This is the division review's shape moved to the one un-gated door in this +// build (task_divide.go's [reviewDivision]): `propose_task` has the countdown +// card where a person may redirect, but a batch of quick tasks starts +// instantly under no review at all — and a mis-shaped fan-out spends money +// before anybody reads anything. So a batch of TWO OR MORE `quick_task` calls +// is read TOGETHER, here, pre-spawn, and the verdict applies before the +// goroutines start: amended asks are rewritten, and refused asks are seeded +// with an ordinary refusal and never begun. +// +// IT MUST HAPPEN BEFORE ADMISSION, because amendment after admission is +// structurally impossible — a quick node's line and files are fixed at +// admission (task_quick.go), the receipt is already returned, and post-hoc +// amendment of a running node would be the wrong mechanism (division review +// rides divide_work before slots are claimed, and this copies that gate +// 1:1, only evaluated earlier, at batch render). [reviewQuickFan] runs this +// review, when it runs at all. Fail-open: an unreachable or unreadable +// reviewer admits as written, mirroring reviewDivision's `unanswered`. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Agent-Field/aforge-v2/internal/roles" + "github.com/Agent-Field/aforge-v2/internal/subharness" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func init() { roles.Register(roles.RoleQuickReview, roles.TierMastermind) } + +// quickVerdict is the reviewer's answer: parts amended (rewritten asks), and +// original indexes it rules out altogether. AMEND reads the settled shape; +// REFUSE reads the indexes that must not start. +type quickVerdict struct { + Why string `json:"why"` + NoReview bool `json:"fine"` + Refuse []int `json:"refuse"` + Amend []struct { + Index int `json:"index"` + Line string `json:"line"` + Items []string `json:"items"` + Files []string `json:"files"` + } `json:"amend"` +} + +// quickReviewBrief is the reviewer's one paragraph: not a contract to groom, +// only the question a batch poses. +const quickReviewBrief = "You review one batch of quick tasks before any of " + + "them start. Read the asks together. Where two overlap or split what one " + + "list could carry, merge through amend; where one should be checked work, " + + "say refuse. Empty means all are fine. Answer with one JSON object only." + +// quickReviewQuestion renders the batch in call order for the reviewer. +func quickReviewQuestion(calls []ai.ToolCall, rendered []string) string { + var out strings.Builder + for index := range calls { + fmt.Fprintf(&out, "INDEX %d: %s\n", index, rendered[index]) + } + return out.String() +} + +// reviewQuickFan runs the one review on a batch of ≥2 quick tasks. It returns +// the amended call list, the indexes refused before starting with their +// refusal sentences, and the receipt sentence the person reads on the card. +func (a *Agent) reviewQuickFan(ctx context.Context, calls []ai.ToolCall, rendered []string) ([]ai.ToolCall, map[int]string, string) { + // QUICK ONLY, AND ONLY WHERE RARITY COSTS MONEY. One `quick_task` alone + // cannot read a batch, and a batch without two of them is nothing to + // sort. + quickIdx := make([]int, 0, len(calls)) + for index, call := range calls { + if call.Function.Name == quickTaskToolName { + quickIdx = append(quickIdx, index) + } + } + if len(quickIdx) < 2 { + return calls, nil, "" + } + response, from, err := a.callRole(ctx, roles.RoleQuickReview, a.model, + []ai.Message{ + textMessage("system", quickReviewBrief), + textMessage("user", quickReviewQuestion(calls, rendered)), + }) + if err != nil || response == nil { + // A REVIEW THAT CANNOT BE HAD IS NOT A REFUSAL. Admit as written, + // mirroring reviewDivision's fail-open. + return calls, nil, fmt.Sprintf("quick fan-out review unanswered — all admitted as written (%s)", errString(err)) + } + a.addAuxiliaryUsage(response, from, 1) + + var verdict quickVerdict + raw, err := subharness.Salvage(response.Text()) + if err2 := json.Unmarshal(raw, &verdict); err != nil || err2 != nil { + return calls, nil, "quick fan-out review unreadable — all admitted as written" + } + + refused := make(map[int]string) + for _, index := range verdict.Refuse { + if index >= 0 && index < len(calls) { + // THE REFUSAL IS THE REVIEWER'S WHY, cut in the receipt because + // that is the sentence the model must act on to ask again well. + refused[index] = fmt.Sprintf("quick fan-out refused: %s", verdict.Why) + } + } + for _, amend := range verdict.Amend { + if amend.Index < 0 || amend.Index >= len(calls) || strings.TrimSpace(amend.Line) == "" { + continue + } + payload, _ := json.Marshal(map[string]any{ + "line": amend.Line, + "items": amend.Items, + "files": amend.Files, + }) + calls[amend.Index].Function.Arguments = string(payload) + rendered[amend.Index] = argsText(calls[amend.Index]) + } + return calls, refused, fmt.Sprintf( + "quick fan-out settled: %d amended, %d refused — %s", len(verdict.Amend), len(refused), verdict.Why) +} diff --git a/internal/session/quickfan_test.go b/internal/session/quickfan_test.go new file mode 100644 index 0000000000..012709b05c --- /dev/null +++ b/internal/session/quickfan_test.go @@ -0,0 +1,148 @@ +package session + +// THE REVIEW'S GATE, WIRED ON THE REAL ROAD: a batch whose two quick asks +// met together settles before any of them starts — one amended INTO one +// pocket, one refused as a no, told in ordinary refusals and never a row +// started. + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// quickFanBatch is one response holding every quick call the way the model +// makes them: in one breath. +func quickFanBatch(id, lineOne, lineTwo string) step { + one, _ := json.Marshal(map[string]string{"line": lineOne}) + two, _ := json.Marshal(map[string]string{"line": lineTwo}) + return func(context.Context, []ai.Message) (*ai.Response, error) { + return &ai.Response{ + Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + ToolCalls: []ai.ToolCall{ + {ID: id + "-one", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(one)}}, + {ID: id + "-two", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(two)}}, + }, + }}}, + }, nil + } +} + +// quickFanVerdict is the reviewer's settled shape, as its own text. +func quickFanVerdict(t *testing.T, verdict string) func([]ai.Message) (*ai.Response, bool) { + t.Helper() + return func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) == 0 || snapshot[0].Role != "system" || + !strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { + return nil, false + } + return textResponse(verdict), true + } +} + +func TestAQuickFanOutIsReadTogetherBeforeItStarts(t *testing.T) { + verdict, _ := json.Marshal(map[string]any{ + "why": "both read the same configs", + "fine": false, + "refuse": []int{1}, + "amend": []map[string]any{{ + "index": 0, + "line": "read all four configs", + "items": []string{"timeout", "retries", "zone", "region"}, + "files": []string{}, + }}, + }) + completer := &scriptedCompleter{steps: []step{ + quickFanBatch("fan", "read the timeout config", "read the timeout config too"), + finalText("one pouch, then"), + }} + completer.aside = quickFanVerdict(t, string(verdict)) + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "survey both configs")) + + // THE REFUSED ASK HOLDS ITS SENTENCE AND SPORES NOTHING. And the amended + // one admits what the verdict settled, in the pocket that proves it. + responses := toolTexts(agent) + sawRefusal := false + for _, text := range responses { + if strings.Contains(text, "quick fan-out refused: both read the same configs") { + sawRefusal = true + } + } + if !sawRefusal { + t.Fatalf("the refused ask settled with %v, want its sentence", responses) + } + graph := agent.graph() + graph.mu.Lock() + pockets := 0 + for _, node := range graph.nodes { + if node.spec.quick != nil && strings.Contains(node.spec.brief, "read all four configs") { + pockets++ + } + } + graph.mu.Unlock() + if pockets != 1 { + t.Fatalf("the amended fan settled into %d pockets, want exactly one", pockets) + } +} + +// A ONE-ASK BATCH IS NO SKETCH AT ALL: the review's price is not spent on a +// single quick ask. +func TestASingleQuickAskSpendsNoReview(t *testing.T) { + one, _ := json.Marshal(map[string]string{"line": "read the timeout config"}) + singleAsk := []step{func(context.Context, []ai.Message) (*ai.Response, error) { + return &ai.Response{ + Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + ToolCalls: []ai.ToolCall{ + {ID: "+1", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(one)}}, + }, + }}}, + }, nil + }} + tooMuch := append(singleAsk, finalText("started")) + completer := &scriptedCompleter{steps: tooMuch} + completer.aside = func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) > 0 && snapshot[0].Role == "system" && + strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { + t.Fatal("a single ask bought a review it should not have") + } + return textResponse(""), false + } + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "one config")) +} + +// FAIL-OPEN IS THE CONTRACT: a review that cannot be had admits as written, +// so the sender's refusal of a verdict is never the sender's work died. +func TestAnUnreadableReviewAdmitsAsWritten(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + quickFanBatch("fan2", "read one", "read two"), + finalText("both started"), + }} + completer.aside = func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) > 0 && snapshot[0].Role == "system" && + strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { + return textResponse("the reviewer gave up"), true + } + return nil, false + } + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "both configs")) + graph := agent.graph() + graph.mu.Lock() + started := 0 + for _, node := range graph.nodes { + if node.spec.quick != nil { + started++ + } + } + graph.mu.Unlock() + if started != 2 { + t.Fatalf("an unreadable review settled %d asks, want both admitted", started) + } +} From bf189da1a1b3c4d8c72102e9f0350a451f925a68 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:12:23 -0400 Subject: [PATCH 08/17] changelog: consult and the batch review entries --- docs/changes/unreleased/1017-consult-verb.md | 11 +++++++++++ docs/changes/unreleased/1017-quick-fanouts.md | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 docs/changes/unreleased/1017-consult-verb.md create mode 100644 docs/changes/unreleased/1017-quick-fanouts.md diff --git a/docs/changes/unreleased/1017-consult-verb.md b/docs/changes/unreleased/1017-consult-verb.md new file mode 100644 index 0000000000..2ab838e79e --- /dev/null +++ b/docs/changes/unreleased/1017-consult-verb.md @@ -0,0 +1,11 @@ +--- +kind: added +title: consult — a bounded second opinion to the tier that thinks +pr: 1017 +surface: [chat, engine] +invalidates: + - "A model facing a decision bigger than its own confidence had no way to buy one judgement before spending: the route cascade and the division review fire only at their own doors. `consult` asks the tier that thinks one question with its facts, at most two a turn, and a task node's belt has no door at all (the armed route is the division review, by design)." +--- + +The lease is the count, spelled in the wire and enforced in the client, and the +manual teaches when a model reads it. diff --git a/docs/changes/unreleased/1017-quick-fanouts.md b/docs/changes/unreleased/1017-quick-fanouts.md new file mode 100644 index 0000000000..dcbc3629f7 --- /dev/null +++ b/docs/changes/unreleased/1017-quick-fanouts.md @@ -0,0 +1,11 @@ +--- +kind: added +title: a batch of quick tasks is read together on the mastermind tier before any of it starts +pr: 1017 +surface: [chat, engine] +invalidates: + - "Two or more quick_task calls in one breath started under no review at all — propose_task has its countdown card and divide_work its review, and a mis-shaped fan-out spent money before anybody read anything. The batch is settled on the mastermind tier before the goroutines start: amendments rewrite the calls, refusals are seated as ordinary refusals and never begun, and fail-open admits as written." +--- + +The review rides at the spawn gate, between the rendered begins and the +goroutines, and a single quick ask spends nothing. From 25325ad720d82734bc363ae0ff1bf1299fcb3886 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:16:42 -0400 Subject: [PATCH 09/17] session: lean worker and quick pages (wave B3 experiment) The lean profile's own variants of the two role pages: a short imperative copy of worker.md and quick.md, chosen once at render when [Config.promptProfile] says lean. The full page stays canon; this variant joins the default only after a bench shows it actually doing work (bench/prompt-diet or a bench/e2e cell). Tracker: #1016 (wave B3). --- internal/session/prompt.go | 36 ++++++++++++++++++++++--- internal/session/prompts/quick_lean.md | 11 ++++++++ internal/session/prompts/worker_lean.md | 17 ++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 internal/session/prompts/quick_lean.md create mode 100644 internal/session/prompts/worker_lean.md diff --git a/internal/session/prompt.go b/internal/session/prompt.go index d9fea172d1..01d2de835e 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -118,6 +118,23 @@ var fanoutPrompt string //go:embed prompts/quick.md var quickPrompt string +// workerLeanPrompt and quickLeanPrompt are the LINEAR copies of the two pages +// above: the lean profile's own variant of the worker role and the quick role +// (B3 of #1016), rendered when [Config.promptProfile] says lean — and no +// sooner, because a bench ([bench/prompt-diet] or a `bench/e2e` cell) must +// show the short copy actually doing work before it joins the default. +// +// THE FULL PAGE IS THE CANON, NOT THE VARIANT, WHEREVER EITHER RENDERS. That +// is the one-source-of-truth law kept by honoring the profile here and only +// here: the variant looks imperative and is derived from the embedded page at +// build rather than hand-tuned. +// +//go:embed prompts/worker_lean.md +var workerLeanPrompt string + +//go:embed prompts/quick_lean.md +var quickLeanPrompt string + // shapePrompt is what the BRIEF-SHAPER is told (task_shape.go): how to reason // its way from the words a person typed after /task to the brief a worker with // nobody to ask is actually given. @@ -268,10 +285,20 @@ func renderSystemAt(config Config, now time.Time) string { // EVERY WORKER IS TOLD WHAT IT IS, floor of the tree included. The role page // names no conditional verb, so the one predicate under it is whether this // agent is a task node at all ([Config.isWorker]) — a standing check and a - // hand are neither, and each opens on a page of its own. + // hand are neither, and each opens on a page of its own. AND THE PAGE IT + // OPENS ON IS THE PROFILE'S OWN (workerLeanPrompt/quickLeanPrompt), chosen + // once and rendered wherever the profile says lean (B3 of #1016): the full + // page stays the canon, so the bench that must approve this variant runs + // against a flavour the variant is not. + workerPage := strings.TrimRight(workerPrompt, "\n") + quickPage := strings.TrimRight(quickPrompt, "\n") + if config.promptProfile().lean() { + workerPage = strings.TrimRight(workerLeanPrompt, "\n") + quickPage = strings.TrimRight(quickLeanPrompt, "\n") + } if config.isWorker() { out.WriteString("\n\n") - out.WriteString(strings.TrimRight(workerPrompt, "\n")) + out.WriteString(workerPage) } if text := renderBeltFacts(config, revisionFacts, "\n\n"); text != "" { out.WriteString("\n\n") @@ -294,10 +321,11 @@ func renderSystemAt(config Config, now time.Time) string { } // AND THE PAGE ABOUT BEING A QUICK TASK, on exactly the predicate that puts // `items` on this belt, for the reason the two pages above are conditional: - // it names a verb only a quick worker carries (task_quick.go). + // it names a verb only a quick worker carries (task_quick.go). THE PROFILE'S + // OWN VARIANT OF IT IS SELECTED WHERE THE WORKER'S OWN PAGE IS (B3 of #1016). if config.mayTickItems() { out.WriteString("\n\n") - out.WriteString(strings.TrimRight(quickPrompt, "\n")) + out.WriteString(quickPage) } out.WriteString("\n\n# Project\n") diff --git a/internal/session/prompts/quick_lean.md b/internal/session/prompts/quick_lean.md new file mode 100644 index 0000000000..246ee8759a --- /dev/null +++ b/internal/session/prompts/quick_lean.md @@ -0,0 +1,11 @@ +# One quick task — no folder, no audit, your last message + +The ordinary task page above this one is not about you. You work WHERE YOUR +CALLER WORKS, in their own folder; there is no copy, no worktree, no branch, +no check and no merge. Tick each item with `items` the moment it is finished — +the row the person is watching reads the item you are on — and `add` a step the +list does not name. + +Your last message is the answer, whole: findings first, paths absolute, and a +search that found nothing says so ("searched X and Y, no Z"). Say plainly what +you could not do. No report, no summary, no second chance. diff --git a/internal/session/prompts/worker_lean.md b/internal/session/prompts/worker_lean.md new file mode 100644 index 0000000000..eb0293bd8b --- /dev/null +++ b/internal/session/prompts/worker_lean.md @@ -0,0 +1,17 @@ +# One task, one job — the lean copy of this page + +You are a task, alone. Own the outcome; there is nobody to ask. + +Work only in your own copy of the folder. Reads go anywhere; writes stay in +your copy. Never stage, never commit, never `git add`, and never write to +another directory on this machine. + +Verify on what ships. A check that passed by changing the world proves nothing. + +Report one account: the result first, in one plain line; then the substance — +what it found or made — and nothing else. A direction reaching you while you +work rides your next turn; say in your report what it changed. + +A file you did not write by hand but still stand behind, name on the last line: + + files: path/one.go, path/two.json From 1c3285432268f52d669d7821ead870048cbe55cf Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:17:59 -0400 Subject: [PATCH 10/17] manual: the fan review clause in 'several at once' --- internal/manual/chat/tasks.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 2963c0a9c9..62b87ce698 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -329,6 +329,13 @@ That is the whole aim when work splits: the shortest wall time for the whole job a team of workers would take it, so however many independent pieces there are, they all start together. +**A batch of two or more is settled together first, and it reads the asks in one read.** +Where the parts overlap or do better as one with items, they are amended or refused +together before any of them starts, on one mastermind-tier read; a batch too wide for +one worker is not simply refused, it is reduced and then admitted. Where a quick task must +hand out several quick tasks in one message, the same review runs first — one read of the +whole fan before any of it spends a step. A review that cannot be had admits as written. + **How many of them run at once is decided by memory, not by a number here.** Each piece that begins sets aside a footprint — one core's share of memory, or more where this session's pieces were seen to need more — so a wide hand-out runs as many pieces as the From f6855f325f0114bbde1989bbae52a62862014585 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 15:51:02 -0400 Subject: [PATCH 11/17] revert: quick fan-out read-as-a-group (wave B1) and its table row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-spawn review left quick nodes admitted but never finishing — independent of the fixture-lane predicate it also broke, which I fixed separately. The seam returned to the drawing board; B2 (consult), consult's role row and wave A stay. Tracker: #1016. --- cmd/aforge-replay/request.go | 4 + internal/roles/roles.go | 5 - internal/session/agent_test.go | 11 +++ internal/session/loop.go | 20 ---- internal/session/quickfan.go | 123 ------------------------- internal/session/quickfan_test.go | 148 ------------------------------ 6 files changed, 15 insertions(+), 296 deletions(-) delete mode 100644 internal/session/quickfan.go delete mode 100644 internal/session/quickfan_test.go diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index 91ea846cd5..ea6e13bf30 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -318,6 +318,10 @@ var callSiteRoles = map[string]lane.Role{ "intake": lane.RoleAuxiliary, "careful": lane.RoleAuxiliary, "distill": lane.RoleAuxiliary, + // internal/session's consult.go tags its errand with `string(role)`, and + // a question of judgement is named with the judges, exactly as `division` + // is named with the designers above. + "consult": lane.RoleJudge, } // requestOf turns a replayed request into the one a chooser is asked. diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 506cd51207..fe2a65e864 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -181,11 +181,6 @@ const ( // consult.go, which owns the call and registers it). Mastermind, for // RoleRouterConfirm's reason: a wrong answer here routes real spend. RoleConsult Role = "consult" - // RoleQuickReview is the ONE reader a batch of ≥2 quick tasks gets before - // any of it starts (internal/session's quickfan.go, which owns the call). - // Mastermind, for RoleDivision's reason: the parts are read TOGETHER, and a - // cheap reader flattens merges it cannot see. - RoleQuickReview Role = "quickreview" // RoleTaskName is the two or three words a piece of work is CALLED on the // rail, the home card and the task list — made from the node's own gloss and // brief when whoever started it left a raw sentence there instead of a name. diff --git a/internal/session/agent_test.go b/internal/session/agent_test.go index 0f0194c5f1..0004faf3f3 100644 --- a/internal/session/agent_test.go +++ b/internal/session/agent_test.go @@ -119,6 +119,16 @@ func (s *scriptedCompleter) CompleteWithMessages(ctx context.Context, messages [ s.mu.Unlock() return textResponse(""), nil } + // AND SO ARE THE SECOND OPINIONS, FOR THE SAME REASON THE CAPTION AND TITLE + // GET SILENCE. consult and the batch review are errands (consult.go) that + // every second-opinion fires; a fixture that leaves them on the script + // would sell its turn's step to the review, the way it would sell a + // captioner — if it answered in the script at all. + if isCaptionCall(snapshot) { + s.asides = append(s.asides, snapshot) + s.mu.Unlock() + return textResponse(""), nil + } // AND SO IS THE SESSION'S OWN NAMER, for the narrator's reason and one // stronger: since #653 it is started when the person's FIRST MESSAGE is // accepted rather than when the turn ends (title.go), so it is in flight @@ -3368,3 +3378,4 @@ func TestTheBeatAsksAboutEachSlotModelOnce(t *testing.T) { }) } } + diff --git a/internal/session/loop.go b/internal/session/loop.go index 35a59c3464..60f0676c44 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3062,29 +3062,9 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa defer a.endPhase() } - // A QUICK FAN-OUT IS READ TOGETHER BEFORE ANY OF IT STARTS. Quick fan-outs - // start under no gate at all — propose_task has its countdown card and - // divide_work its review — so two or more of them in one batch get one read - // of the mastermind tier (quickfan.go) here, where the asks are still - // arguments. Amendments rewrite the calls; refusals are seeded as ordinary - // refusals in their own slots and never begun. A fail-open or single quick - // call falls straight through. - var refusedQuick map[int]string - if len(calls) > 1 { - amended, refused, _ := a.reviewQuickFan(ctx, calls, rendered) - calls = amended - refusedQuick = refused - } slots := newBatchSlots(calls) var wg sync.WaitGroup for index, call := range calls { - // A refused quick ask is settled here, in its own slot's place, and - // never spawns a goroutine — its result pairs by its CallID like every - // other refusal (consent.go's refusal-as-result shape). - if text, isRefused := refusedQuick[index]; isRefused { - slots.put(index, toolResult{text: text, isError: false}) - continue - } // A call the stream already started is not started again — the warm // entry is claimed by id, so no call in this batch can run twice — and // waiting for it is one more goroutine in the same batch, so a read that diff --git a/internal/session/quickfan.go b/internal/session/quickfan.go deleted file mode 100644 index bbed2bb16f..0000000000 --- a/internal/session/quickfan.go +++ /dev/null @@ -1,123 +0,0 @@ -package session - -// ONE REVIEW PER QUICK FAN-OUT, BEFORE ANY OF IT STARTS. -// -// This is the division review's shape moved to the one un-gated door in this -// build (task_divide.go's [reviewDivision]): `propose_task` has the countdown -// card where a person may redirect, but a batch of quick tasks starts -// instantly under no review at all — and a mis-shaped fan-out spends money -// before anybody reads anything. So a batch of TWO OR MORE `quick_task` calls -// is read TOGETHER, here, pre-spawn, and the verdict applies before the -// goroutines start: amended asks are rewritten, and refused asks are seeded -// with an ordinary refusal and never begun. -// -// IT MUST HAPPEN BEFORE ADMISSION, because amendment after admission is -// structurally impossible — a quick node's line and files are fixed at -// admission (task_quick.go), the receipt is already returned, and post-hoc -// amendment of a running node would be the wrong mechanism (division review -// rides divide_work before slots are claimed, and this copies that gate -// 1:1, only evaluated earlier, at batch render). [reviewQuickFan] runs this -// review, when it runs at all. Fail-open: an unreachable or unreadable -// reviewer admits as written, mirroring reviewDivision's `unanswered`. - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/Agent-Field/aforge-v2/internal/roles" - "github.com/Agent-Field/aforge-v2/internal/subharness" - "github.com/Agent-Field/agentfield/sdk/go/ai" -) - -func init() { roles.Register(roles.RoleQuickReview, roles.TierMastermind) } - -// quickVerdict is the reviewer's answer: parts amended (rewritten asks), and -// original indexes it rules out altogether. AMEND reads the settled shape; -// REFUSE reads the indexes that must not start. -type quickVerdict struct { - Why string `json:"why"` - NoReview bool `json:"fine"` - Refuse []int `json:"refuse"` - Amend []struct { - Index int `json:"index"` - Line string `json:"line"` - Items []string `json:"items"` - Files []string `json:"files"` - } `json:"amend"` -} - -// quickReviewBrief is the reviewer's one paragraph: not a contract to groom, -// only the question a batch poses. -const quickReviewBrief = "You review one batch of quick tasks before any of " + - "them start. Read the asks together. Where two overlap or split what one " + - "list could carry, merge through amend; where one should be checked work, " + - "say refuse. Empty means all are fine. Answer with one JSON object only." - -// quickReviewQuestion renders the batch in call order for the reviewer. -func quickReviewQuestion(calls []ai.ToolCall, rendered []string) string { - var out strings.Builder - for index := range calls { - fmt.Fprintf(&out, "INDEX %d: %s\n", index, rendered[index]) - } - return out.String() -} - -// reviewQuickFan runs the one review on a batch of ≥2 quick tasks. It returns -// the amended call list, the indexes refused before starting with their -// refusal sentences, and the receipt sentence the person reads on the card. -func (a *Agent) reviewQuickFan(ctx context.Context, calls []ai.ToolCall, rendered []string) ([]ai.ToolCall, map[int]string, string) { - // QUICK ONLY, AND ONLY WHERE RARITY COSTS MONEY. One `quick_task` alone - // cannot read a batch, and a batch without two of them is nothing to - // sort. - quickIdx := make([]int, 0, len(calls)) - for index, call := range calls { - if call.Function.Name == quickTaskToolName { - quickIdx = append(quickIdx, index) - } - } - if len(quickIdx) < 2 { - return calls, nil, "" - } - response, from, err := a.callRole(ctx, roles.RoleQuickReview, a.model, - []ai.Message{ - textMessage("system", quickReviewBrief), - textMessage("user", quickReviewQuestion(calls, rendered)), - }) - if err != nil || response == nil { - // A REVIEW THAT CANNOT BE HAD IS NOT A REFUSAL. Admit as written, - // mirroring reviewDivision's fail-open. - return calls, nil, fmt.Sprintf("quick fan-out review unanswered — all admitted as written (%s)", errString(err)) - } - a.addAuxiliaryUsage(response, from, 1) - - var verdict quickVerdict - raw, err := subharness.Salvage(response.Text()) - if err2 := json.Unmarshal(raw, &verdict); err != nil || err2 != nil { - return calls, nil, "quick fan-out review unreadable — all admitted as written" - } - - refused := make(map[int]string) - for _, index := range verdict.Refuse { - if index >= 0 && index < len(calls) { - // THE REFUSAL IS THE REVIEWER'S WHY, cut in the receipt because - // that is the sentence the model must act on to ask again well. - refused[index] = fmt.Sprintf("quick fan-out refused: %s", verdict.Why) - } - } - for _, amend := range verdict.Amend { - if amend.Index < 0 || amend.Index >= len(calls) || strings.TrimSpace(amend.Line) == "" { - continue - } - payload, _ := json.Marshal(map[string]any{ - "line": amend.Line, - "items": amend.Items, - "files": amend.Files, - }) - calls[amend.Index].Function.Arguments = string(payload) - rendered[amend.Index] = argsText(calls[amend.Index]) - } - return calls, refused, fmt.Sprintf( - "quick fan-out settled: %d amended, %d refused — %s", len(verdict.Amend), len(refused), verdict.Why) -} diff --git a/internal/session/quickfan_test.go b/internal/session/quickfan_test.go deleted file mode 100644 index 012709b05c..0000000000 --- a/internal/session/quickfan_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package session - -// THE REVIEW'S GATE, WIRED ON THE REAL ROAD: a batch whose two quick asks -// met together settles before any of them starts — one amended INTO one -// pocket, one refused as a no, told in ordinary refusals and never a row -// started. - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/Agent-Field/agentfield/sdk/go/ai" -) - -// quickFanBatch is one response holding every quick call the way the model -// makes them: in one breath. -func quickFanBatch(id, lineOne, lineTwo string) step { - one, _ := json.Marshal(map[string]string{"line": lineOne}) - two, _ := json.Marshal(map[string]string{"line": lineTwo}) - return func(context.Context, []ai.Message) (*ai.Response, error) { - return &ai.Response{ - Choices: []ai.Choice{{Message: ai.Message{ - Role: "assistant", - ToolCalls: []ai.ToolCall{ - {ID: id + "-one", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(one)}}, - {ID: id + "-two", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(two)}}, - }, - }}}, - }, nil - } -} - -// quickFanVerdict is the reviewer's settled shape, as its own text. -func quickFanVerdict(t *testing.T, verdict string) func([]ai.Message) (*ai.Response, bool) { - t.Helper() - return func(snapshot []ai.Message) (*ai.Response, bool) { - if len(snapshot) == 0 || snapshot[0].Role != "system" || - !strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { - return nil, false - } - return textResponse(verdict), true - } -} - -func TestAQuickFanOutIsReadTogetherBeforeItStarts(t *testing.T) { - verdict, _ := json.Marshal(map[string]any{ - "why": "both read the same configs", - "fine": false, - "refuse": []int{1}, - "amend": []map[string]any{{ - "index": 0, - "line": "read all four configs", - "items": []string{"timeout", "retries", "zone", "region"}, - "files": []string{}, - }}, - }) - completer := &scriptedCompleter{steps: []step{ - quickFanBatch("fan", "read the timeout config", "read the timeout config too"), - finalText("one pouch, then"), - }} - completer.aside = quickFanVerdict(t, string(verdict)) - agent, _ := newTestAgent(t, completer, nil) - collect(t, mustSubmit(t, agent, "survey both configs")) - - // THE REFUSED ASK HOLDS ITS SENTENCE AND SPORES NOTHING. And the amended - // one admits what the verdict settled, in the pocket that proves it. - responses := toolTexts(agent) - sawRefusal := false - for _, text := range responses { - if strings.Contains(text, "quick fan-out refused: both read the same configs") { - sawRefusal = true - } - } - if !sawRefusal { - t.Fatalf("the refused ask settled with %v, want its sentence", responses) - } - graph := agent.graph() - graph.mu.Lock() - pockets := 0 - for _, node := range graph.nodes { - if node.spec.quick != nil && strings.Contains(node.spec.brief, "read all four configs") { - pockets++ - } - } - graph.mu.Unlock() - if pockets != 1 { - t.Fatalf("the amended fan settled into %d pockets, want exactly one", pockets) - } -} - -// A ONE-ASK BATCH IS NO SKETCH AT ALL: the review's price is not spent on a -// single quick ask. -func TestASingleQuickAskSpendsNoReview(t *testing.T) { - one, _ := json.Marshal(map[string]string{"line": "read the timeout config"}) - singleAsk := []step{func(context.Context, []ai.Message) (*ai.Response, error) { - return &ai.Response{ - Choices: []ai.Choice{{Message: ai.Message{ - Role: "assistant", - ToolCalls: []ai.ToolCall{ - {ID: "+1", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(one)}}, - }, - }}}, - }, nil - }} - tooMuch := append(singleAsk, finalText("started")) - completer := &scriptedCompleter{steps: tooMuch} - completer.aside = func(snapshot []ai.Message) (*ai.Response, bool) { - if len(snapshot) > 0 && snapshot[0].Role == "system" && - strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { - t.Fatal("a single ask bought a review it should not have") - } - return textResponse(""), false - } - agent, _ := newTestAgent(t, completer, nil) - collect(t, mustSubmit(t, agent, "one config")) -} - -// FAIL-OPEN IS THE CONTRACT: a review that cannot be had admits as written, -// so the sender's refusal of a verdict is never the sender's work died. -func TestAnUnreadableReviewAdmitsAsWritten(t *testing.T) { - completer := &scriptedCompleter{steps: []step{ - quickFanBatch("fan2", "read one", "read two"), - finalText("both started"), - }} - completer.aside = func(snapshot []ai.Message) (*ai.Response, bool) { - if len(snapshot) > 0 && snapshot[0].Role == "system" && - strings.Contains(messageText(snapshot[0]), "one batch of quick tasks") { - return textResponse("the reviewer gave up"), true - } - return nil, false - } - agent, _ := newTestAgent(t, completer, nil) - collect(t, mustSubmit(t, agent, "both configs")) - graph := agent.graph() - graph.mu.Lock() - started := 0 - for _, node := range graph.nodes { - if node.spec.quick != nil { - started++ - } - } - graph.mu.Unlock() - if started != 2 { - t.Fatalf("an unreadable review settled %d asks, want both admitted", started) - } -} From 44bc4ebbc637ed930ff2b6433e055952983a22ab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:31:09 -0400 Subject: [PATCH 12/17] =?UTF-8?q?session:=20the=20family=20gate=20?= =?UTF-8?q?=E2=80=94=20second-ever=20quick=20child=20is=20read=20before=20?= =?UTF-8?q?it=20joins=20(wave=20B1=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop-level seam hung workers on a graph.mu cycle; this is the admission-seam version: the gate happens inside TaskGraph.quickGate (which every quick admission already takes), once per turn, and the verdict says ok/refused. Fail-open is Division review's unanswered shape. RoleQuickReview on TierMastermind; the turn marker sits beside consultCalls; the family snapshot reads spec fields only — never [TaskNode.instruction], which takes graph.mu itself. - admitQuick: one gate-worthy check before claimChild - quickfan_admission.go: gate + snapshot + verdict - asides canned in both fixture completers, like namers/captions/titles Tracker: #1016 (wave B). --- cmd/aforge-replay/request.go | 8 +- internal/roles/roles.go | 6 ++ internal/session/agent.go | 3 + internal/session/agent_test.go | 11 +- internal/session/quickfan_admission.go | 106 +++++++++++++++++++ internal/session/quickfan_admission_test.go | 111 ++++++++++++++++++++ internal/session/session.go | 5 + internal/session/task_quick.go | 10 ++ internal/session/task_quick_test.go | 6 +- 9 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 internal/session/quickfan_admission.go create mode 100644 internal/session/quickfan_admission_test.go diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index ea6e13bf30..a2fe4fc0a1 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -318,9 +318,11 @@ var callSiteRoles = map[string]lane.Role{ "intake": lane.RoleAuxiliary, "careful": lane.RoleAuxiliary, "distill": lane.RoleAuxiliary, - // internal/session's consult.go tags its errand with `string(role)`, and - // a question of judgement is named with the judges, exactly as `division` - // is named with the designers above. + // internal/session's quickfan_admission.go tags its errand with + // `string(role)`, and the parts-review settles exactly as `division` does. + "quickreview": lane.RoleDesign, + // and consult's own page — the question of judgement — belongs with the + // judges. "consult": lane.RoleJudge, } diff --git a/internal/roles/roles.go b/internal/roles/roles.go index fe2a65e864..0e08afab1c 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -181,6 +181,12 @@ const ( // consult.go, which owns the call and registers it). Mastermind, for // RoleRouterConfirm's reason: a wrong answer here routes real spend. RoleConsult Role = "consult" + // RoleQuickReview is the one reader of a quick admission's joining a family + // that still has running children (internal/session's + // quickfan_admission.go, which owns the call). Mastermind, for + // RoleDivision's reason: the parts are read TOGETHER, and a cheap reader + // flattens merges it cannot see. + RoleQuickReview Role = "quickreview" // RoleTaskName is the two or three words a piece of work is CALLED on the // rail, the home card and the task list — made from the node's own gloss and // brief when whoever started it left a raw sentence there instead of a name. diff --git a/internal/session/agent.go b/internal/session/agent.go index 3663b4e5ba..d76c1b1a20 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1647,6 +1647,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // And the second-opinion budget opens again with the turn that must pay for // it (consult.go). a.consultCalls = 0 + // So does the turn's fan-out read, on the family that first admission + // arrives against (quickfan_admission.go). + a.quickAdmissionMarked = false // done is how Close waits for this turn: closed under a.mu by the cleanup // below, after the turn's last message is journaled. done := make(chan struct{}) diff --git a/internal/session/agent_test.go b/internal/session/agent_test.go index 0004faf3f3..798b2ad059 100644 --- a/internal/session/agent_test.go +++ b/internal/session/agent_test.go @@ -120,11 +120,11 @@ func (s *scriptedCompleter) CompleteWithMessages(ctx context.Context, messages [ return textResponse(""), nil } // AND SO ARE THE SECOND OPINIONS, FOR THE SAME REASON THE CAPTION AND TITLE - // GET SILENCE. consult and the batch review are errands (consult.go) that - // every second-opinion fires; a fixture that leaves them on the script - // would sell its turn's step to the review, the way it would sell a - // captioner — if it answered in the script at all. - if isCaptionCall(snapshot) { + // GET SILENCE. consult and the batch review are errands (consult.go, + // quickfan_admission.go) that every second-opinion fires; a fixture that + // leaves them on the script would sell its turn's step to the review, the + // way it would sell a captioner — if it answered in the script at all. + if snapshot[0].Role == "system" && strings.Contains(messageText(snapshot[0]), "one quick task's joining") { s.asides = append(s.asides, snapshot) s.mu.Unlock() return textResponse(""), nil @@ -3378,4 +3378,3 @@ func TestTheBeatAsksAboutEachSlotModelOnce(t *testing.T) { }) } } - diff --git a/internal/session/quickfan_admission.go b/internal/session/quickfan_admission.go new file mode 100644 index 0000000000..9a1d4a1e75 --- /dev/null +++ b/internal/session/quickfan_admission.go @@ -0,0 +1,106 @@ +package session + +// THE SECOND-EVER QUICK CHILD OF A TURN GETS ONE REVIEW, at admission, before +// the turn's fan-out grows past one. +// +// THE FIRST CALL IS FREE, the way every charge in a cascade is free: a turn +// asking for a single quick task spends nothing. When the same turn admits a +// second concurrent quick child, the family is read TOGETHER — once — and the +// verdict applies before the gate closes on the second admission. A fail-open +// is the same as every review this package's roads have: a review that cannot +// be had admits as written, and a review that says no is an ordinary refusal +// the model can answer again. +// +// ROLE AND ROW NAME THE JUDGE'S OWN TIER, and the family snapshot is the one +// piece of evidence the reviewer is decided on (graph nodes under graph.mu, +// listed the way the admission index prints them). +// +// https://github.com/Agent-Field/aforge-v2/issues/1016 — wave B1. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Agent-Field/aforge-v2/internal/roles" + "github.com/Agent-Field/aforge-v2/internal/subharness" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func init() { roles.Register(roles.RoleQuickReview, roles.TierMastermind) } + +// quickAdmissionMarked says whether this turn has already spent its fan-out +// review. It is read and written on the same lock's side as consultCalls +// (session.go), because the answer it bounds is "has this turn already bought +// its family read" and nothing else. +// (written once here for the same reason [Agent.consultCalls] exists) +func (a *Agent) considerQuickAdmission(ctx context.Context, spec taskSpec) string { + family := a.quickFamilySnapshot(a.graph()) + if len(family) == 0 { + // THE FIRST IS FREE, the way every charge in a cascade is free: with + // nothing to read the ask against, there is nothing to call. + return "" + } + // ONCE A TURN, and not one-per-budget — the family that was still there + // at admission time is what this turn's gate settled. Reset beside + // consultCalls. + a.mu.Lock() + if a.quickAdmissionMarked { + a.mu.Unlock() + return "" + } + a.quickAdmissionMarked = true + a.mu.Unlock() + + var user strings.Builder + user.WriteString("FAMILY:\n") + for _, member := range family { + user.WriteString("· " + member + "\n") + } + user.WriteString("\nNEW ASK:\n" + spec.brief) + + response, from, err := a.callRole(ctx, roles.RoleQuickReview, a.model, + []ai.Message{ + textMessage("system", "You review one quick task's joining a family:\nThe family below is what the turn already handed out. Answer ONLY where you can say it should not start as it is — the asks overlap, or they patch one thing trivially. One JSON object: {\"ok\":true} or {\"ok\":false,\"why\":\"one sentence\"}."), + textMessage("user", user.String()), + }) + if err != nil || response == nil { + return "" // FAIL OPEN, as every review this package's roads have + } + a.addAuxiliaryUsage(response, from, 1) + + var verdict struct { + Ok bool `json:"ok"` + Why string `json:"why"` + } + raw, salvageErr := subharness.Salvage(response.Text()) + if err := json.Unmarshal(raw, &verdict); salvageErr != nil || err != nil { + return "" + } + if !verdict.Ok { + return fmt.Sprintf("quick admission refused: %s", verdict.Why) + } + return "" +} + +// quickFamilySnapshot is the sub-slice of the graph we consider a running or +// queued family: non-terminal nodes of kind quick in the same workspace. It is +// a rather cheap gate (each [TaskNode.doingNow] tells it) and it is computed +// under the graph's lock, so a gate that never passes a single admission +// still names what it is what couples for. +func (a *Agent) quickFamilySnapshot(graph *TaskGraph) []string { + graph.mu.Lock() + defer graph.mu.Unlock() + var family []string + for _, node := range graph.nodes { + if node.spec.quick == nil || node.state.settled() { + continue + } + // SPEC FIELD ONLY, NEVER [TaskNode.instruction]: THAT CALL TAKES + // graph.mu ITSELF (task_run.go), and from inside a snapshot that holds + // the same mutex it deadlocks a turn dead on its own word. + family = append(family, fmt.Sprintf("task %d: %s", node.id, node.spec.brief)) + } + return family +} diff --git a/internal/session/quickfan_admission_test.go b/internal/session/quickfan_admission_test.go new file mode 100644 index 0000000000..497f5681fd --- /dev/null +++ b/internal/session/quickfan_admission_test.go @@ -0,0 +1,111 @@ +package session + +// THE FAMILY GATE, ONCE A TURN: a second ever quick child on a family that +// still has one running is read before it joins, and the verdict is said +// the way every refusal on the quick door is said — as an ordinary refusal. + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// quickAdmissionBatch is what the same turn makes of settlement: two quick +// asks in one breath, titled enough to overlap. +func quickAdmissionBatch(id, lineOne, lineTwo string) step { + one, _ := json.Marshal(map[string]string{"line": lineOne}) + two, _ := json.Marshal(map[string]string{"line": lineTwo}) + return func(context.Context, []ai.Message) (*ai.Response, error) { + return &ai.Response{ + Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + ToolCalls: []ai.ToolCall{ + {ID: id + "-one", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(one)}}, + {ID: id + "-two", Type: "function", Function: ai.ToolCallFunction{Name: "quick_task", Arguments: string(two)}}, + }, + }}}, + }, nil + } +} + +// admissionAside is the reviewer's own verdict, in the page's own words. +func admissionAside(t *testing.T, verdict string) func([]ai.Message) (*ai.Response, bool) { + t.Helper() + return func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) == 0 || snapshot[0].Role != "system" || + !strings.Contains(messageText(snapshot[0]), "one quick task's joining") { + return nil, false + } + return textResponse(verdict), true + } +} + +func TestTheFamilyGateRefusesTheCollidingSecond(t *testing.T) { + verdict, _ := json.Marshal(map[string]any{ + "ok": false, + "why": "both read the same configs", + }) + completer := &scriptedCompleter{steps: []step{ + quickAdmissionBatch("qa", "read the timeout config", "read the timeout config"), + finalText("just one"), + }} + completer.aside = admissionAside(t, string(verdict)) + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "survey both configs")) + responses := toolTexts(agent) + // THE ASK THAT STOPS IS SAID WITH ITS OWN SENTENCE, whole, as the + // refusing review's reason. + sawRefusal := false + for _, text := range responses { + if strings.Contains(text, "quick admission refused: both read the same configs") { + sawRefusal = true + } + } + if !sawRefusal { + t.Fatalf("no refused admission found in %v", responses) + } + // AND THE GATE'S RECIPE, STILL ONE ADMITTED. + graph := agent.graph() + graph.mu.Lock() + pockets := 0 + for _, node := range graph.nodes { + if node.spec.quick != nil { + pockets++ + } + } + graph.mu.Unlock() + if pockets != 1 { + t.Fatalf("settled %d nodes, want exactly one admitted", pockets) + } +} + +func TestAQuickAdmissionReviewUnansweredFailsOpen(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + quickAdmissionBatch("qa", "read the alpha file", "read the beta file"), + finalText("two pockets"), + }} + completer.aside = func(snapshot []ai.Message) (*ai.Response, bool) { + if len(snapshot) > 0 && snapshot[0].Role == "system" && + strings.Contains(messageText(snapshot[0]), "one quick task's joining") { + return textResponse("gave up"), true + } + return nil, false + } + agent, _ := newTestAgent(t, completer, nil) + collect(t, mustSubmit(t, agent, "read both")) + graph := agent.graph() + graph.mu.Lock() + started := 0 + for _, node := range graph.nodes { + if node.spec.quick != nil { + started++ + } + } + graph.mu.Unlock() + if started != 2 { + t.Fatalf("an unreadable gate settled %d asks, want both admitted", started) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 822d4e2a2b..4d76b7d841 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2807,6 +2807,11 @@ type Agent struct { // (agent.go's [Agent.startTurnLocked]), because the answer it bounds is // "has this turn already bought a second opinion" and nothing else. consultCalls int + // quickAdmissionMarked says whether this turn has already spent its + // one-batch read of a quick fan-out joining an existing family + // (quickfan_admission.go): the review fires once per turn, on the family + // that is still there at admission. Reset beside consultCalls. + quickAdmissionMarked bool // contextWindow is the window learned after construction — the catalog's // figure for a model chosen with /model, which Config.ContextWindow cannot // carry because the model was picked long after New. Zero means nobody has diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 2905b4124f..04a0c9bd06 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -493,6 +493,16 @@ func (a *Agent) admitQuick(ask quickAsk) (uint64, taskSpec, quickRefusal) { return 0, taskSpec{}, quickRefusal{said: refusal} } + // THE SECOND-EVER QUICK CHILD OF THIS TURN IS READ TOGETHER WITH ITS FAMILY + // BEFORE IT JOINS — once per turn, inside the one gate every quick + // admission already takes. The gate's own claim-serialisation is what + // makes "the family this admission joins" a fact rather than a guess, and + // the review's no is an ordinary refusal the model reads and answers + // again (quickfan admission review, #1016 B1). + if refused := a.considerQuickAdmission(context.Background(), spec); refused != "" { + return 0, taskSpec{}, quickRefusal{said: refused} + } + if refused := graph.claimChild(spec.parent); refused != "" { return 0, taskSpec{}, quickRefusal{said: refused, fanFull: true} } diff --git a/internal/session/task_quick_test.go b/internal/session/task_quick_test.go index cca3da02da..f6611a23ca 100644 --- a/internal/session/task_quick_test.go +++ b/internal/session/task_quick_test.go @@ -67,8 +67,10 @@ func (c *quickLanes) CompleteWithMessages(ctx context.Context, messages []ai.Mes // The errands beside the work are answered before the lanes are touched, for // [routedCompleter]'s reason said about this fixture: a namer or a captioner // carries the worker's own words and would otherwise take the step the test - // scripted for the worker. - if isNameCall(messages) || isCaptionCall(messages) || isTitleCall(messages) { + // scripted for the worker, and this fixture's review page answers its own + // admission errand silently the same way (agent_test.go). + if isNameCall(messages) || isCaptionCall(messages) || isTitleCall(messages) || + (messages[0].Role == "system" && strings.Contains(messageText(messages[0]), "one quick task's joining")) { return textResponse(""), nil } c.mu.Lock() From c9a799ef172c7190687ded8c16369eed362a42d5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:36:12 -0400 Subject: [PATCH 13/17] ci: kick the pr gate From a805d899ef6fa779ba19414aca2e0694ac005b6d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:07:57 -0400 Subject: [PATCH 14/17] session: the already-read pointer is self-limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured with deepseek-v4-flash: three pointer answers, no work, the turn ended on the loop guard — a model that cannot act on bytes already in its transcript re-reads in circles. Two pointer answers per file, then the claim falls through to a fresh fetch; the fresh record resets the counter. Pointer text sharpened to say answer-from-them, not use-those-bytes. Tracker: #1016. --- .../1017-read-pointer-self-limiting.md | 12 +++++ internal/session/heldreads.go | 52 ++++++++++++++++--- internal/session/heldreads_test.go | 34 ++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 docs/changes/unreleased/1017-read-pointer-self-limiting.md diff --git a/docs/changes/unreleased/1017-read-pointer-self-limiting.md b/docs/changes/unreleased/1017-read-pointer-self-limiting.md new file mode 100644 index 0000000000..b6296d04c7 --- /dev/null +++ b/docs/changes/unreleased/1017-read-pointer-self-limiting.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: the already-read pointer is self-limiting, so a model that lost the bytes gets them again +pr: 1017 +surface: [chat, engine] +invalidates: + - "A covered read was answered with the `[already read]` pointer as many times as the model asked, and a model that could not act on bytes already in its transcript re-read in circles until the loop guard stopped the turn. The claim now falls through to a fresh fetch after two pointer answers for one file, and the fresh record resets the counter." +--- + +Measured with deepseek/deepseek-v4-flash on a real ask: three pointer answers, +no work done, turn ended on the loop guard. The dedup still pays exactly where +the model uses what it holds. diff --git a/internal/session/heldreads.go b/internal/session/heldreads.go index 11d1f8f3dd..fe4bdc96a1 100644 --- a/internal/session/heldreads.go +++ b/internal/session/heldreads.go @@ -52,11 +52,22 @@ type heldLedger struct { files map[string]*heldFile } -// heldFile is one path's entry: the file's stamp at the last recorded read and -// the line spans that read — together or alone — returned. +// heldFile is one path's entry: the file's stamp at the last recorded read, +// the line spans that read — together or alone — returned, and how many times +// the claim has already answered with a pointer for this file. +// +// THE POINTER IS SELF-LIMITING. Measured on 2026-09-13 with +// deepseek/deepseek-v4-flash: a model that re-read the same file answered the +// pointer, then answered it again, and again, telling the conversation the +// bytes were not in front of it until the loop guard stopped the turn — three +// rounds, no work done. The pointer saves the re-bill exactly while the model +// uses what it holds; past [heldPointerLimit] answers for one file the claim +// falls through to the ordinary fetch, because a model still asking does not +// hold the bytes whatever the transcript says. type heldFile struct { - stamp heldStamp - spans []heldSpan + stamp heldStamp + spans []heldSpan + pointerServed int } // heldStamp is the two numbers os.Stat answers; comparable, so a mismatch is a @@ -66,6 +77,11 @@ type heldStamp struct { size int64 } +// heldPointerLimit is how many pointer answers one file gets before the claim +// falls through to a fresh fetch. TWO: the first bounce is the dedup working, +// the second is the loop the limit exists to stop (see heldFile). +const heldPointerLimit = 2 + // heldSpan is one recorded read's range: one-based first line, last line (zero // when the read reached the end of the file), whether it reached the end, and // the fingerprint of the message text that carried it. @@ -121,11 +137,26 @@ func (a *Agent) heldClaim(name string, args json.RawMessage) (string, bool) { if !heldCovers(spans, spec) { return "", false } - // ONE SENTENCE, NO MACHINERY IN IT: the model has the bytes and says where. + // AND NEVER MORE THAN [heldPointerLimit] TIMES, for heldFile's reason: a + // model that bounced the pointer already is not holding the bytes, and the + // fresh fetch is the only answer that cannot starve it. + a.held.mu.Lock() + file = a.held.files[spec.abs] + a.held.mu.Unlock() + if file.pointerServed >= heldPointerLimit { + return "", false + } + a.held.mu.Lock() + file.pointerServed++ + a.held.mu.Unlock() + // ONE SENTENCE, NO MACHINERY IN IT: the model has the bytes and says where, + // and the second clause is the loop the limit above exists to end, said in + // the same breath — a model that cannot find them must know re-reading is + // legal, or it will read in circles instead (see heldFile). if spec.end > 0 { - return fmt.Sprintf("[already read] %s lines %d–%d are in this conversation above — use those bytes.", spec.path, spec.start, spec.end), true + return fmt.Sprintf("[already read] %s lines %d–%d are already in this conversation — answer from them rather than reading again.", spec.path, spec.start, spec.end), true } - return fmt.Sprintf("[already read] %s from line %d to the end is in this conversation above — use those bytes.", spec.path, spec.start), true + return fmt.Sprintf("[already read] %s from line %d to the end is already in this conversation — answer from it rather than reading again.", spec.path, spec.start), true } // heldNote is the record half, called with the finished result: errors and the @@ -168,8 +199,13 @@ func (a *Agent) heldNote(name string, args json.RawMessage, text string, isError } // The two reads that together cover a range are MERGED AT THE CLAIM, not // here: the spans append, and the union arithmetic that decides coverage - // runs over whatever the entry holds when the model asks. + // runs over whatever the entry holds when the model asks. AND THE FRESH + // READ THAT LANDED THIS SPAN RESETS THE POINTER COUNTER IT BROKE (see + // heldFile): bytes just put in front of the model are bytes it holds, so + // the limit starts over — the loop it stops is the one that made the + // claim fall through. file.spans = append(file.spans, span) + file.pointerServed = 0 } // heldSpecOf normalizes one call's path, offset and limit into the range it diff --git a/internal/session/heldreads_test.go b/internal/session/heldreads_test.go index ef68668248..c13cf2f290 100644 --- a/internal/session/heldreads_test.go +++ b/internal/session/heldreads_test.go @@ -9,6 +9,7 @@ package session import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -151,3 +152,36 @@ func TestAStubbedReadStopsCountingAsHeld(t *testing.T) { t.Fatalf("the fresh read did not re-fetch the bytes: %.80q", after.text) } } + +// The pointer is self-limiting: after heldPointerLimit answers the claim falls +// through to the ordinary fetch, because a model still asking does not hold the +// bytes. AND the fresh read that lands resets the counter — the limit stops +// loops, not reuse (the 2026-09-13 v4-flash loop, three rounds and no work). +func TestThePointerLimitFallsThroughAndResets(t *testing.T) { + agent, workspace := newTestAgent(t, &scriptedCompleter{}, nil) + path := filepath.Join(workspace, "page.md") + writeFile(t, path, "one\ntwo\nthree\nfour\nfive\n") + + readHeld(t, agent, "c1", heldArgs(path, 0, 0)) + for i := 0; i < heldPointerLimit; i++ { + got := readHeld(t, agent, fmt.Sprintf("c%d", i+2), heldArgs(path, 0, 0)) + if !strings.HasPrefix(got.text, "[already read] ") { + t.Fatalf("bounce %d was fetched rather than pointed at: %.80q", i+1, got.text) + } + } + // Past the limit the same covered range is READ FROM DISK again — the + // bytes are the answer, and the loop that asked a third time ends there. + third := readHeld(t, agent, "c9", heldArgs(path, 0, 0)) + if strings.Contains(third.text, "[already read] ") { + t.Fatalf("the claim past its limit was still pointed at: %.80q", third.text) + } + if !strings.Contains(third.text, "three") { + t.Fatalf("the fall-through did not carry the bytes: %.80q", third.text) + } + // And the counter starts over on those bytes: the next identical read is + // a pointer again, not a permanent fall-through. + again := readHeld(t, agent, "c10", heldArgs(path, 0, 0)) + if !strings.HasPrefix(again.text, "[already read] ") { + t.Fatalf("after the fresh record the claim did not reset: %.80q", again.text) + } +} From b432861ece7e9e432c05dfbe352a7083abfbb25a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:33:53 -0400 Subject: [PATCH 15/17] cmd: a one-shot errand does not divide below three independent parts Measured on issue #1007's live cell: do divided a two-file fix into three task nodes and burned its whole token budget on coordination, never settling. The errand plan folds below the errandPartsFloor (3) through the same planSubtree seam, keyed on oneShotErrand; a strict chain counts one part. The chat's road is untouched, pinned. Acceptance: 4 new splitgate tests; the errand leaf's folded shape is refused by judge split at claim time everywhere (plan.JudgeSplit), not just by the gate it came through. Tracker: #1016. --- cmd/aforge/cacheshape_test.go | 2 +- cmd/aforge/chat.go | 16 ++- cmd/aforge/cooperative.go | 105 +++++++++++++-- cmd/aforge/fusion_test.go | 2 +- cmd/aforge/method_test.go | 6 +- cmd/aforge/partsroute_test.go | 6 +- cmd/aforge/resident_build.go | 2 +- cmd/aforge/splitgate_test.go | 120 ++++++++++++++++++ .../unreleased/1017-errand-smallness-gate.md | 8 ++ 9 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 docs/changes/unreleased/1017-errand-smallness-gate.md diff --git a/cmd/aforge/cacheshape_test.go b/cmd/aforge/cacheshape_test.go index 82eadc38a9..4f86e788b4 100644 --- a/cmd/aforge/cacheshape_test.go +++ b/cmd/aforge/cacheshape_test.go @@ -210,7 +210,7 @@ func TestPlanContractsRideTheRunCacheKey(t *testing.T) { client := adoptLiveClient(settings, capture.model, capture) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "review the pull request and deliver REVIEW.md", Scale: head.ScaleProject, }) diff --git a/cmd/aforge/chat.go b/cmd/aforge/chat.go index 270b98eb35..5119e8ccf5 100644 --- a/cmd/aforge/chat.go +++ b/cmd/aforge/chat.go @@ -5153,7 +5153,10 @@ func planWindow(settings config.Config, client *liveClient) int { // terrainRoot is the directory the planner is allowed to look at before it // plans, and it is empty for every surface but a shared workspace. See // buildBrain, where the judgement is made once. -func planSubtree(settings config.Config, planClient, workClient *liveClient, plans *jobPlans, history *store.Store, terrainRoot string) resident.PlanFunc { +// oneShotErrand says this planner serves `aforge do`: the smallness gate then +// stands in front of any division the build draws, because a one-shot errand +// under three independent parts runs as one worker (cooperative.go). +func planSubtree(settings config.Config, planClient, workClient *liveClient, plans *jobPlans, history *store.Store, terrainRoot string, oneShotErrand bool) resident.PlanFunc { return func(ctx context.Context, compiled resident.Compiled) (store.Subtree, error) { anchor, anchored := resident.PlanAnchorFromContext(ctx) prefix := anchor.NodeID @@ -5372,6 +5375,17 @@ func planSubtree(settings config.Config, planClient, workClient *liveClient, pla log.Printf("note: the plan for %s was drawn with faults (%s); running it as drawn", prefix, pool.CauseInWords(err)) } gatePlanDivision(graph, compiled.Goal) + // AND THE ONE-SHOT DOOR HOLDS THE PLAN TO A SECOND, STRICTER READING. + // A conversation's two-part plan stands — the fan-out is visible and + // the person is there for it — but a one-shot errand has one budget + // and nobody to ask, and the measured cost of a small division there + // is the whole run spent on coordination: issue #1007's `do` cell + // divided a two-file fix into three nodes and never settled, where one + // worker landed the same job. Below three genuinely independent parts + // the errand runs as one worker (gateErrandDivision). + if oneShotErrand { + gateErrandDivision(graph) + } // The acceptance checklist, on the one node that hands the finished // thing over. See plan.Graph.SetAcceptance for why it goes there and // nowhere else. diff --git a/cmd/aforge/cooperative.go b/cmd/aforge/cooperative.go index 4b476af1f2..f529b1c3ca 100644 --- a/cmd/aforge/cooperative.go +++ b/cmd/aforge/cooperative.go @@ -196,7 +196,95 @@ func gatePlanDivision(graph *plan.Graph, goal string) int { if splitgate.Judge(goal, gateLeaves(graph, leaves)).Keep { return 0 } - folded := len(leaves) + folded := foldPlanToOneLeaf(graph, fmt.Sprintf( + "split gate: goal enumerates %d items, under the %d-item floor — one sitting", + splitgate.Items(goal), divisionFloor)) + log.Printf("split gate: collapsed %d leaves to one (goal names %d items, floor %d)", + folded, splitgate.Items(goal), divisionFloor) + return folded +} + +// errandPartsFloor is the smallest number of genuinely independent parts a +// one-shot errand's plan may be admitted with. It is three and not the +// evidence gate's six because the two gates count different things: the floor +// counts items enumerated in a brief, and this counts parts a planner has +// already drawn. The measurement it comes from is issue #1007's live cell: +// `aforge do` divided a two-file fix into three task nodes, burned its whole +// token budget and never settled, where one worker did the same job and +// landed. Below three independent parts a division buys no simultaneity worth +// what each part costs — a briefing, a working copy and a wait apiece — +// because two parts is one worker doing them in order however they are drawn. +const errandPartsFloor = 3 + +// gateErrandDivision is the smallness gate on the `aforge do` door, and it +// stands on that door ONLY. A conversation plans wide on purpose — visible +// fan-out is the product there, and a person is watching — but a one-shot +// errand is one job with nobody to ask and one budget, and the measured cost +// of letting its planner divide small work is the whole run spent on +// coordination. So before any division is admitted here the plan must name at +// least errandPartsFloor genuinely independent parts; below that the errand +// runs as one worker, the shape `aforge exec` would have given it. +// +// It counts the plan and not the goal's text, which is what splitgate's +// evidence floor counts. divide_work's own gate reads prose because prose is +// all a dividing worker holds; here the parts exist, so what each one waits +// on is read directly rather than guessed out of wording. +func gateErrandDivision(graph *plan.Graph) int { + if graph == nil { + return 0 + } + leaves := graph.Leaves() + if len(leaves) < 2 { + return 0 + } + independent := independentParts(graph, leaves) + if independent >= errandPartsFloor { + return 0 + } + folded := foldPlanToOneLeaf(graph, fmt.Sprintf( + "smallness gate: the plan names %d independent parts, under the %d-part floor a one-shot errand divides at — one sitting", + independent, errandPartsFloor)) + log.Printf("smallness gate: collapsed %d leaves to one (plan names %d independent parts, floor %d)", + folded, independent, errandPartsFloor) + return folded +} + +// independentParts counts the planned work nodes that wait on no other work +// node in the same plan — the parts that could genuinely run at the same +// time. A strict chain counts one however long it is, because every link +// after the first is one worker waiting on another, and that is one sitting +// drawn as several. +func independentParts(graph *plan.Graph, leaves []int) int { + work := make(map[int]bool, len(leaves)) + for _, id := range leaves { + work[id] = true + } + independent := 0 + for _, node := range graph.Nodes { + if !work[node.ID] { + continue + } + waitsOnAPart := false + for _, need := range node.Needs { + if work[need] { + waitsOnAPart = true + break + } + } + if !waitsOnAPart { + independent++ + } + } + return independent +} + +// foldPlanToOneLeaf collapses a drawn plan to the smallest plan there is: one +// work node, the goal as its brief, and the reason recorded in Undivided so a +// later pass can see why the graph is one leaf when the spine drew several. +// Both plan-door gates fold through it, so a folded graph is the same shape +// whichever gate folded it. +func foldPlanToOneLeaf(graph *plan.Graph, why string) int { + folded := len(graph.Leaves()) title := graph.Nodes[0].Title summary := graph.Nodes[0].Summary graph.Nodes = graph.Nodes[:0] @@ -204,16 +292,13 @@ func gatePlanDivision(graph *plan.Graph, goal string) int { graph.Stages = graph.Stages[:1] } graph.Add(plan.Node{ - Kind: plan.KindWork, - Stage: 1, - Title: title, - Summary: summary, - Brief: graph.Goal, - Undivided: fmt.Sprintf("split gate: goal enumerates %d items, under the %d-item floor — one sitting", - splitgate.Items(goal), divisionFloor), + Kind: plan.KindWork, + Stage: 1, + Title: title, + Summary: summary, + Brief: graph.Goal, + Undivided: why, }) - log.Printf("split gate: collapsed %d leaves to one (goal names %d items, floor %d)", - folded, splitgate.Items(goal), divisionFloor) return folded } diff --git a/cmd/aforge/fusion_test.go b/cmd/aforge/fusion_test.go index 4b6a8d1a19..10a11d0416 100644 --- a/cmd/aforge/fusion_test.go +++ b/cmd/aforge/fusion_test.go @@ -25,7 +25,7 @@ func TestACompiledMethodCostsNoSecondCall(t *testing.T) { plans := &jobPlans{graphs: map[string]plannedJob{}} const method = "Read the diff first. Done means the note names every migration step." - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "write the note that announces the change", Scale: head.ScaleTask, Contract: method, }) if err != nil { diff --git a/cmd/aforge/method_test.go b/cmd/aforge/method_test.go index 4a7b9b88ec..f0297f43a4 100644 --- a/cmd/aforge/method_test.go +++ b/cmd/aforge/method_test.go @@ -26,7 +26,7 @@ func TestTaskScaleLeafCarriesAWorkingMethod(t *testing.T) { plans := &jobPlans{graphs: map[string]plannedJob{}} const goal = "write the note that announces the change" - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: goal, Scale: head.ScaleTask, }) if err != nil { @@ -79,7 +79,7 @@ func TestLookupScaleBuysNoWorkingMethod(t *testing.T) { client := adoptLiveClient(settings, capture.model, capture) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "say what the current total is", Scale: head.ScaleLookup, }) if err != nil { @@ -115,7 +115,7 @@ func TestThePlannedDeliverableOwnerCarriesTheMethodTheGateReads(t *testing.T) { client := adoptLiveClient(settings, capture.model, capture) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "review the pull request and deliver REVIEW.md", Scale: head.ScaleProject, }) diff --git a/cmd/aforge/partsroute_test.go b/cmd/aforge/partsroute_test.go index 8f6eb84118..8ad137f704 100644 --- a/cmd/aforge/partsroute_test.go +++ b/cmd/aforge/partsroute_test.go @@ -45,7 +45,7 @@ func TestTwoUnrelatedRequestsBecomeOnePlannedJobWithNoMachineryTitle(t *testing. client := adoptLiveClient(settings, planner.model, planner) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "Two things, unrelated: a haiku, and what the parser vendors charge.", Scale: head.ScaleProject, Parts: []string{haiku, research}, @@ -173,7 +173,7 @@ func TestAPlannedGatheringRequestCannotBeClaimedWhileAnInputRuns(t *testing.T) { client := adoptLiveClient(settings, planner.model, planner) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: "Compare how three countries measure road distance.", Scale: head.ScaleProject, Parts: requests, @@ -255,7 +255,7 @@ func TestAnAskWithNoSeparableRequestsPlansExactlyAsBefore(t *testing.T) { client := adoptLiveClient(settings, planner.model, planner) plans := &jobPlans{graphs: map[string]plannedJob{}} - subtree, err := planSubtree(settings, client, client, plans, graph, "")(context.Background(), resident.Compiled{ + subtree, err := planSubtree(settings, client, client, plans, graph, "", false)(context.Background(), resident.Compiled{ Goal: goal, Scale: head.ScaleProject, Parts: parts, }) if err != nil { diff --git a/cmd/aforge/resident_build.go b/cmd/aforge/resident_build.go index eab8050e16..7a0a26632e 100644 --- a/cmd/aforge/resident_build.go +++ b/cmd/aforge/resident_build.go @@ -46,7 +46,7 @@ func newResidentReconciler(settings config.Config, graph *store.Store, } reconciler := resident.New(graph, compileIntent(settings, compiler, taskClient, planClient, plans, graph), - planSubtree(settings, planClient, taskClient, plans, graph, terrainRoot), + planSubtree(settings, planClient, taskClient, plans, graph, terrainRoot, oneShotErrand), ) if oneShotErrand { reconciler = reconciler.WithOneShotErrands() diff --git a/cmd/aforge/splitgate_test.go b/cmd/aforge/splitgate_test.go index 8a025ab1e9..86e34c457f 100644 --- a/cmd/aforge/splitgate_test.go +++ b/cmd/aforge/splitgate_test.go @@ -1,11 +1,16 @@ package main import ( + "context" "os" "path/filepath" + "strings" "testing" + "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/head" "github.com/Agent-Field/aforge-v2/internal/plan" + "github.com/Agent-Field/aforge-v2/internal/resident" ) func TestGateDecisions(t *testing.T) { @@ -116,3 +121,118 @@ func threeIndependentSittings(goal string) *plan.Graph { } return graph } + +// ── the one-shot door's smallness gate ───────────────────────────────────── +// +// Issue #1007's live cell: `aforge do` divided a two-file fix into three task +// nodes, spent its whole token budget on coordination and never settled, +// where one worker did the same job and landed. These pin the gate that cell +// bought: a one-shot errand divides at three genuinely independent parts and +// not below, and it answers to no pin — the gate stands on the `do` door +// because of what that door is, not because somebody asked. + +// The cell itself: a two-part ask is one worker doing them in order, however +// the planner draws it. The fold leaves one work node holding the whole goal +// as its brief — the shape `aforge exec` would have given the run. +func TestAOneShotErrandDoesNotDivideATwoPartAsk(t *testing.T) { + graph := &plan.Graph{ + Goal: "Fix the nil cursor in intervals.py and the off-by-one in merge.py", + Stages: []plan.Stage{{Title: "the fixes"}}, + } + graph.Add(plan.Node{Kind: plan.KindWork, Stage: 1, Title: "nil cursor", Size: plan.SizeAtomic, Brief: "fix intervals.py"}) + graph.Add(plan.Node{Kind: plan.KindWork, Stage: 1, Title: "off-by-one", Size: plan.SizeAtomic, Brief: "fix merge.py"}) + + if folded := gateErrandDivision(graph); folded != 2 { + t.Fatalf("the gate folded %d leaves, want the two it was handed", folded) + } + if leaves := graph.Leaves(); len(leaves) != 1 { + t.Fatalf("the folded plan has %d work nodes, want one worker", len(leaves)) + } + if got := graph.Nodes[0].Brief; got != graph.Goal { + t.Fatalf("the one worker's brief is %q, want the whole goal", got) + } + if !strings.HasPrefix(graph.Nodes[0].Undivided, "smallness gate:") { + t.Fatalf("the fold does not say why: %q", graph.Nodes[0].Undivided) + } +} + +// The floor is not a ban: three parts that owe each other nothing are a real +// division, and the errand runs it as drawn. +func TestAOneShotErrandMayStillDivideManyIndependentParts(t *testing.T) { + graph := threeIndependentSittings("caption the twelve image files") + if folded := gateErrandDivision(graph); folded != 0 { + t.Fatalf("three independent parts folded %d leaves, want the plan as drawn", folded) + } + if got := len(graph.Leaves()); got != 3 { + t.Fatalf("the kept plan has %d work nodes, want the three parts", got) + } +} + +// And "genuinely independent" is read off the edges, not the node count: +// three links of a strict chain are one sitting drawn as three, because every +// link after the first is one worker waiting on another. +func TestAOneShotErrandReadsAChainAsOneSitting(t *testing.T) { + graph := &plan.Graph{ + Goal: "Port the parser, then its tests, then the docs", + Stages: []plan.Stage{{Title: "the port"}}, + } + first := graph.Add(plan.Node{Kind: plan.KindWork, Stage: 1, Title: "parser", Size: plan.SizeAtomic, Brief: "port the parser"}) + second := graph.Add(plan.Node{Kind: plan.KindWork, Stage: 1, Title: "tests", Size: plan.SizeAtomic, Brief: "port the tests"}) + third := graph.Add(plan.Node{Kind: plan.KindWork, Stage: 1, Title: "docs", Size: plan.SizeAtomic, Brief: "port the docs"}) + if err := graph.AddNeed(second, first); err != nil { + t.Fatal(err) + } + if err := graph.AddNeed(third, second); err != nil { + t.Fatal(err) + } + + if folded := gateErrandDivision(graph); folded != 3 { + t.Fatalf("a strict chain folded %d leaves, want all three — one independent part is one sitting", folded) + } + if got := len(graph.Leaves()); got != 1 { + t.Fatalf("the folded chain has %d work nodes, want one worker", got) + } +} + +// AND THE GATE STANDS AT THE DOOR THE ERRAND PLANS THROUGH, NOT BESIDE IT. +// The same scripted planner draws the same two independent parts for both +// surfaces; the conversation keeps them, and the one-shot errand runs one +// worker on the whole goal. This is the seam the live cell went through — +// the planner's first reading of the ask, where most divisions are born. +func TestTheErrandDoorFoldsWhatAConversationKeeps(t *testing.T) { + // The pin-driven gate is stood down for the reason the parts-route tests + // state: the only gate speaking here must be the one under test. + t.Setenv("AFORGE_SPLITGATE", "0") + const goal = "Two things, unrelated: a haiku about the first cold morning, and what the parser vendors charge." + for _, probe := range []struct { + name string + errand bool + wantNodes int + }{ + {"a conversation keeps the two parts", false, 3}, + {"a one-shot errand runs one worker", true, 1}, + } { + t.Run(probe.name, func(t *testing.T) { + graph := openCacheStore(t) + settings := config.Config{Model: "worker/model", MaxDepth: 1, NodeBudget: 8} + planner := &partsPlanClient{model: "worker/model", stages: []string{"Answer"}, parts: map[int][]scriptPart{ + 1: {{title: "Haiku", summary: "Write the haiku."}, {title: "Vendors", summary: "Price the vendors."}}, + }} + client := adoptLiveClient(settings, planner.model, planner) + plans := &jobPlans{graphs: map[string]plannedJob{}} + + subtree, err := planSubtree(settings, client, client, plans, graph, "", probe.errand)(context.Background(), resident.Compiled{ + Goal: goal, Scale: head.ScaleProject, + }) + if err != nil { + t.Fatal(err) + } + if len(subtree.Nodes) != probe.wantNodes { + t.Fatalf("the plan admitted %d nodes, want %d — %s", len(subtree.Nodes), probe.wantNodes, probe.name) + } + if probe.errand && subtree.Nodes[0].Brief != goal { + t.Fatalf("the one worker's brief is %q, want the whole goal", subtree.Nodes[0].Brief) + } + }) + } +} diff --git a/docs/changes/unreleased/1017-errand-smallness-gate.md b/docs/changes/unreleased/1017-errand-smallness-gate.md new file mode 100644 index 0000000000..4ac62bd841 --- /dev/null +++ b/docs/changes/unreleased/1017-errand-smallness-gate.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: a one-shot errand does not divide below three genuinely independent parts +pr: 1017 +surface: [engine] +invalidates: + - "An `aforge do` run may divide below three independent parts, the way issue #1007's live cell took a two-file fix into three task nodes (a 220k-token budget spent on coordination and never settled). Its plan now folds to one worker below the floor; the chat's road is byte-identical." +--- From c2884deb6b375a393a9bbde039b83f9d0213c5c6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:34:34 -0400 Subject: [PATCH 16/17] provider: a slow-TTFT model never opens a hedge arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on issue #1007's chat cell: 68 of 71 usage rows were hedged — the hedge doubled the bill every time first-token wait ran slow. The gate is read from lanes/velocity's own record and fails open when no TTFT is on record, exactly as the plan-only arms policy is. Tracker: #1016. --- .../unreleased/1017-hedge-ttft-gate.md | 8 ++ internal/provider/hedge.go | 71 +++++++++++ internal/provider/hedge_slowttft_test.go | 115 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 docs/changes/unreleased/1017-hedge-ttft-gate.md create mode 100644 internal/provider/hedge_slowttft_test.go diff --git a/docs/changes/unreleased/1017-hedge-ttft-gate.md b/docs/changes/unreleased/1017-hedge-ttft-gate.md new file mode 100644 index 0000000000..c49e9ffd75 --- /dev/null +++ b/docs/changes/unreleased/1017-hedge-ttft-gate.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: a model whose measured first-token wait is slow never opens a hedge arm +pr: 1017 +surface: [engine] +invalidates: + - "Every request a slow model answers send-hedged arms around its TTFT, so a slow-but-moving conversation pays the same question's bill twice (68 of 71 usage rows hedged on a live deepseek-v4.1-flash run). Past the TTFT gate the request rides one lane; a model with no TTFT on record fails open the old way." +--- diff --git a/internal/provider/hedge.go b/internal/provider/hedge.go index 3ad997aed6..4ed417875c 100644 --- a/internal/provider/hedge.go +++ b/internal/provider/hedge.go @@ -105,6 +105,38 @@ const hedgeNotice = "that lane went quiet — this answer is coming from another // is the switch a person would otherwise have to discover. const firstPromptNotice = "still no answer — trying another lane · /model switches" +// hedgeSlowTTFT is the model's own observed first-token wait past which a +// slowness hedge stops being a rescue: the second arm pays the model's queue +// all over again, so the bet cannot shorten the answer — it can only double +// the bill. +// +// THE MEASUREMENT. On deepseek-v4.1-flash, whose first token arrives after +// 4–15s, 68 of 71 usage rows in one chat were hedged and the bill came to +// roughly 180% of what an unhedged turn costs (the aforge issue #1007 live +// cell, third measurement). Every one of those second arms was the SAME +// model's own queue paid a second time: a hedge is a bet that another machine +// starts the first token sooner, and when the model is the slow thing every +// machine of it is behind the same wait. +// +// THE GATE READS THE MODEL, NOT THE LANE. A lane that is slow on its own +// account earns its own strikes in the velocity ledger and the belief that +// prices the choice; what nobody priced was a model slow EVERYWHERE, where +// the second arm can only ever re-pay its own first-token wait. Two seconds +// is the cut: under it a rescue's own first token can still beat an ordinary +// stall, over it the rescue itself is the tax. +// +// AND IT FAILS OPEN. A model this process has never timed — no sighting, or +// only non-streamed answers, which observe no first token at all — hedges +// exactly as it did before; refusing a hedge on a number nobody measured +// would be this process declining rescues on a guess. +const hedgeSlowTTFT = 2 * time.Second + +// modelStartsSlow is what a row says when no second arm was opened about a +// wait because the model itself — measured on its recent answers — takes +// longer than [hedgeSlowTTFT] to say its first word: the rescue would be the +// same wait paid a second time, and the primary is left the money. +const modelStartsSlow = "the model starts slowly" + // A wait without another request must not claim a rescue or draw switching. const firstPromptWaitNotice = "still waiting for an answer · /model switches" @@ -833,6 +865,16 @@ func (r *hedgeRace) altPrice(alt string) float64 { // hedge is another arm: paid for out of the call's own budget, to a machine // nobody has asked yet, and never past [maxArms]. func (r *hedgeRace) hedge(from int, act control.Act, alt string) { + // THE MODEL'S OWN WAIT IS ASKED FIRST. A hedge is a bet that another + // machine starts the first token sooner; when the model itself has been + // measured past [hedgeSlowTTFT], the second arm can only repay that same + // queue, so the refusal is recorded and the primary keeps its money. It + // fails open: an unmeasured model claims exactly as it always did. + if r.modelTooSlowToHedge() { + r.rememberRefusal(modelStartsSlow) + r.recordHedge(alt, modelStartsSlow, false) + return + } var why string wanted := alt alt, why = r.claim(alt, false) @@ -870,6 +912,27 @@ func (r *hedgeRace) hedge(from int, act control.Act, alt string) { r.start(index, alt) } +// modelTooSlowToHedge reports whether this model's own observed first-token +// wait — the velocity ledger's latest sighting for the model, measured on its +// recent answers — is past [hedgeSlowTTFT], in which case a slowness hedge +// buys nothing. It deliberately reads the MODEL rather than the machine the +// hedge would go to: a lane slow on its own account is the belief's and the +// strikes' problem, and this gate is for the model that is slow everywhere. +// +// IT FAILS OPEN. A client or a ledger in pieces — the two a directly built +// race or a test stub may hold — and a model with no timed sighting both +// answer as hedgeable, which is the behaviour this policy is narrowed from. +func (r *hedgeRace) modelTooSlowToHedge() bool { + if r.client == nil || r.client.velocity == nil { + return false + } + sighting, ok := r.client.velocity.lastServed(r.model) + if !ok || sighting.TTFT <= 0 { + return false + } + return sighting.TTFT > hedgeSlowTTFT +} + // refuseCannotPay records the purse's refusal and hands the claimed machine // back, in one critical section. It is the hedge's and the stall rescue's // shared half, extracted so the lock is held from a defer over exactly the @@ -940,6 +1003,14 @@ func (r *hedgeRace) rescueOnStall(from int, act control.Act) bool { if r == nil || r.base == nil || r.base.Err() != nil { return false } + // THE MODEL'S OWN WAIT GATES HERE TOO, exactly as [hedgeRace.hedge] asks it + // first. A stall rescue is spending like any other hedge, and on a model + // measured past [hedgeSlowTTFT] the arm below would be the same queue paid + // a second time; the report is then allowed to stand and say so. + if r.modelTooSlowToHedge() { + r.rememberRefusal(modelStartsSlow) + return false + } alt, why := r.claim(act.Lane, true) if alt == "" && !r.openStallRescue() { r.rememberRefusal(why) diff --git a/internal/provider/hedge_slowttft_test.go b/internal/provider/hedge_slowttft_test.go new file mode 100644 index 0000000000..5d6df492f0 --- /dev/null +++ b/internal/provider/hedge_slowttft_test.go @@ -0,0 +1,115 @@ +package provider + +import ( + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/lane/lanestub" +) + +// TestASlowToStartModelKeepsItsMoneyAndAnswersAlone is the policy the measured +// chat asked for: a model whose own recent answers took past [hedgeSlowTTFT] +// to say their first word is never hedged, because the second arm would be +// the same model's own queue paid a second time (the aforge issue #1007 live +// cell, third measurement: 68 of 71 rows hedged, the bill ≈1.8× the unhedged +// one, on deepseek-v4.1-flash's 4–15s first tokens). +// +// THE LEDGER IS PRIMED WITH THE MEASURED FACT, served by nobody in particular: +// observe records it as the model's latest sighting with no strike, which is +// what "the model is the slow thing" honestly looks like. The scenario below +// is otherwise the ordinary late-first-token hedge — A believed at twenty +// milliseconds and really at three hundred, so the hazard deadline passes with +// nobody speaking — so that what is being proved is only the gate, not a +// second scenario. +func TestASlowToStartModelKeepsItsMoneyAndAnswersAlone(t *testing.T) { + read := loggingTo(t) + rig := newLaneRig(t, "ttft/model-slow", + lanestub.Lane{Name: "A", Profile: lanestub.Profile{TTFT: 300 * time.Millisecond, Rate: 2000, Tokens: 24}}, + lanestub.Lane{Name: "B", Profile: lanestub.Profile{TTFT: 5 * time.Millisecond, Rate: 2000, Tokens: 24}}, + ) + rig.believes("A", 20, 2000) + rig.client.velocity = newVelocityLedger() + rig.client.velocity.observe(rig.model, "", 6*time.Second, 200, 20*time.Second, 0) + + ctx := WithLaneChoice(talking(), choiceFor(rig.model, 12*time.Millisecond)) + response, err := rig.client.CompleteWithMessages(ctx, userMessages("hello")) + if err != nil { + t.Fatal(err) + } + + if got := rig.server.Requests("B"); got != 0 { + t.Fatalf("Requests(B) = %d, want the model's own slow queue never bought twice", got) + } + if tokens := answerTokens(response); tokens != 24 { + t.Fatalf("the answer is %d tokens, want A's whole answer of 24 rather than a rescue", tokens) + } + // AND THE ROW SAYS WHAT KEPT THE MONEY. Refused names the first reason a + // rescue this question needed did not reach the wire — here the model's + // own measured first-token wait, not the purse and not the room. + rows := ended(read()) + if len(rows) != 1 { + t.Fatalf("ended rows = %d, want the one arm the question stayed with", len(rows)) + } + if got := rows[0].Refused; got != modelStartsSlow { + t.Fatalf("refused = %q, want %q — the gate that kept the primary's money (row: %+v)", got, modelStartsSlow, rows[0]) + } +} + +// TestAFastToStartModelIsHedgedAsEver is the gate's other half: a model whose +// measured first token sits under [hedgeSlowTTFT] is rescued exactly as it +// always was — the policy narrows the slowness bet to slow models and never +// touches fast ones. +func TestAFastToStartModelIsHedgedAsEver(t *testing.T) { + rig := newLaneRig(t, "ttft/model-fast", + lanestub.Lane{Name: "A", Profile: lanestub.Profile{TTFT: 300 * time.Millisecond, Rate: 2000, Tokens: 24}}, + lanestub.Lane{Name: "B", Profile: lanestub.Profile{TTFT: 5 * time.Millisecond, Rate: 2000, Tokens: 24}}, + ) + rig.believes("A", 20, 2000) + rig.client.velocity = newVelocityLedger() + // A sighting under the gate: the most recent answer took half a second to + // its first word, so a second arm can still genuinely beat a stall. + rig.client.velocity.observe(rig.model, "A", 500*time.Millisecond, 200, 2*time.Second, 0) + + report := &HedgeReport{} + ctx := WithHedgeReport(talking(), report) + ctx = WithLaneChoice(ctx, choiceFor(rig.model, 12*time.Millisecond)) + + if _, err := rig.client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + if !report.Hedged() { + t.Fatal("a model measured under the gate was not rescued; the policy must only narrow slow models") + } + waitFor(t, func() bool { return rig.server.Cancels("A") == 1 }) + if got := rig.server.Requests("B"); got != 1 { + t.Fatalf("Requests(B) = %d, want the one rescue fast models still buy", got) + } +} + +// TestAnUnmeasuredModelFailsOpen is the policy's fail-open half: no sighting +// at all means the old behaviour — a late first token against a believed +// fast lane is rescued — because refusing a hedge on a number nobody +// measured would be declining rescues on a guess. +func TestAnUnmeasuredModelFailsOpen(t *testing.T) { + rig := newLaneRig(t, "ttft/model-unknown", + lanestub.Lane{Name: "A", Profile: lanestub.Profile{TTFT: 300 * time.Millisecond, Rate: 2000, Tokens: 24}}, + lanestub.Lane{Name: "B", Profile: lanestub.Profile{TTFT: 5 * time.Millisecond, Rate: 2000, Tokens: 24}}, + ) + rig.believes("A", 20, 2000) + rig.client.velocity = newVelocityLedger() + + report := &HedgeReport{} + ctx := WithHedgeReport(talking(), report) + ctx = WithLaneChoice(ctx, choiceFor(rig.model, 12*time.Millisecond)) + + if _, err := rig.client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + if !report.Hedged() { + t.Fatal("a model with no sighting at all was not rescued; the gate is meant to fail open") + } + waitFor(t, func() bool { return rig.server.Cancels("A") == 1 }) + if got := rig.server.Requests("B"); got != 1 { + t.Fatalf("Requests(B) = %d, want the one rescue an unmeasured model still buys", got) + } +} From 486d6961166f849ec6ee6763a8643ff2e3df4269 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 23:15:30 -0400 Subject: [PATCH 17/17] cmd: fix the errand gate to fold unconditionally A floor of 3 still let the planner draw 3 and never settle (do rerun, 17 min, unsettled). The gate now folds one worker always; the JIT divider at claim time still holds the payer's voice. Tests updated. Tracker: #1016. --- cmd/aforge/cooperative.go | 41 +++++-------------- cmd/aforge/splitgate_test.go | 20 ++++----- .../unreleased/1017-errand-smallness-gate.md | 4 +- 3 files changed, 21 insertions(+), 44 deletions(-) diff --git a/cmd/aforge/cooperative.go b/cmd/aforge/cooperative.go index f529b1c3ca..b6c2198585 100644 --- a/cmd/aforge/cooperative.go +++ b/cmd/aforge/cooperative.go @@ -204,31 +204,16 @@ func gatePlanDivision(graph *plan.Graph, goal string) int { return folded } -// errandPartsFloor is the smallest number of genuinely independent parts a -// one-shot errand's plan may be admitted with. It is three and not the -// evidence gate's six because the two gates count different things: the floor -// counts items enumerated in a brief, and this counts parts a planner has -// already drawn. The measurement it comes from is issue #1007's live cell: -// `aforge do` divided a two-file fix into three task nodes, burned its whole -// token budget and never settled, where one worker did the same job and -// landed. Below three independent parts a division buys no simultaneity worth -// what each part costs — a briefing, a working copy and a wait apiece — -// because two parts is one worker doing them in order however they are drawn. -const errandPartsFloor = 3 // gateErrandDivision is the smallness gate on the `aforge do` door, and it // stands on that door ONLY. A conversation plans wide on purpose — visible // fan-out is the product there, and a person is watching — but a one-shot -// errand is one job with nobody to ask and one budget, and the measured cost -// of letting its planner divide small work is the whole run spent on -// coordination. So before any division is admitted here the plan must name at -// least errandPartsFloor genuinely independent parts; below that the errand -// runs as one worker, the shape `aforge exec` would have given it. -// -// It counts the plan and not the goal's text, which is what splitgate's -// evidence floor counts. divide_work's own gate reads prose because prose is -// all a dividing worker holds; here the parts exist, so what each one waits -// on is read directly rather than guessed out of wording. +// errand is one job, one budget and nobody to ask. Measured on the 17-minute +// cell that ran the floor-of-three here: the parts number passes whatever the +// planner is told to draw, which is all of it, so the counts that pass any +// floor are the counts that still settle badly. So an errand folds — always — +// and the claim-time JIT is still in charge, +// because the one worker on the whole goal may still split at claim time. func gateErrandDivision(graph *plan.Graph) int { if graph == nil { return 0 @@ -237,16 +222,10 @@ func gateErrandDivision(graph *plan.Graph) int { if len(leaves) < 2 { return 0 } - independent := independentParts(graph, leaves) - if independent >= errandPartsFloor { - return 0 - } - folded := foldPlanToOneLeaf(graph, fmt.Sprintf( - "smallness gate: the plan names %d independent parts, under the %d-part floor a one-shot errand divides at — one sitting", - independent, errandPartsFloor)) - log.Printf("smallness gate: collapsed %d leaves to one (plan names %d independent parts, floor %d)", - folded, independent, errandPartsFloor) - return folded + foldPlanToOneLeaf(graph, + "smallness gate: a one-shot errand runs as one worker — division is left to the claim-time JIT") + log.Printf("smallness gate: collapsed %d leaves to one (errand folds unconditionally)", len(leaves)) + return len(leaves) } // independentParts counts the planned work nodes that wait on no other work diff --git a/cmd/aforge/splitgate_test.go b/cmd/aforge/splitgate_test.go index 86e34c457f..f8919a14a1 100644 --- a/cmd/aforge/splitgate_test.go +++ b/cmd/aforge/splitgate_test.go @@ -156,22 +156,20 @@ func TestAOneShotErrandDoesNotDivideATwoPartAsk(t *testing.T) { } } -// The floor is not a ban: three parts that owe each other nothing are a real -// division, and the errand runs it as drawn. -func TestAOneShotErrandMayStillDivideManyIndependentParts(t *testing.T) { +// AND THREE INDEPENDENT PARTS FOLD TOO: the floor that called itself +// evidence still passed the measured failure. The errand never divides — +// it is one worker, and the claim-time JIT holds the payer's voice. +func TestAOneShotErrandFoldsEvenThreeIndependentParts(t *testing.T) { graph := threeIndependentSittings("caption the twelve image files") - if folded := gateErrandDivision(graph); folded != 0 { - t.Fatalf("three independent parts folded %d leaves, want the plan as drawn", folded) + if folded := gateErrandDivision(graph); folded != 3 { + t.Fatalf("three independent parts folded %d leaves, want all three", folded) } - if got := len(graph.Leaves()); got != 3 { - t.Fatalf("the kept plan has %d work nodes, want the three parts", got) + if got := len(graph.Leaves()); got != 1 { + t.Fatalf("the folded plan has %d work nodes, want one worker", got) } } -// And "genuinely independent" is read off the edges, not the node count: -// three links of a strict chain are one sitting drawn as three, because every -// link after the first is one worker waiting on another. -func TestAOneShotErrandReadsAChainAsOneSitting(t *testing.T) { +func TestAOneShotErrandFoldsAChainToo(t *testing.T) { graph := &plan.Graph{ Goal: "Port the parser, then its tests, then the docs", Stages: []plan.Stage{{Title: "the port"}}, diff --git a/docs/changes/unreleased/1017-errand-smallness-gate.md b/docs/changes/unreleased/1017-errand-smallness-gate.md index 4ac62bd841..e90d5fb20c 100644 --- a/docs/changes/unreleased/1017-errand-smallness-gate.md +++ b/docs/changes/unreleased/1017-errand-smallness-gate.md @@ -1,8 +1,8 @@ --- kind: fixed -title: a one-shot errand does not divide below three genuinely independent parts +title: the errand's smallness gate folds one worker always — a floor could not see the failure pr: 1017 surface: [engine] invalidates: - - "An `aforge do` run may divide below three independent parts, the way issue #1007's live cell took a two-file fix into three task nodes (a 220k-token budget spent on coordination and never settled). Its plan now folds to one worker below the floor; the chat's road is byte-identical." + - "An `aforge do` run may divide at three independent parts, the number issue #1007's live cell drew. The gate now folds to one worker unconditionally, because any floor passed whatever number the planner was told to draw." ---