diff --git a/docs/changes/unreleased/923-turn-decides-promotion-inherits.md b/docs/changes/unreleased/923-turn-decides-promotion-inherits.md new file mode 100644 index 0000000000..d264723f57 --- /dev/null +++ b/docs/changes/unreleased/923-turn-decides-promotion-inherits.md @@ -0,0 +1,129 @@ +--- +kind: changed +title: The turn decides whether to become a task, and a task it starts inherits what it already read +pr: 923 +surface: [chat, engine] +invalidates: + - "A REPLY WAS MOVED TO A TASK AT ROUND TEN. The checkpoint's first two marks + each woke a separate model to read the transcript, and a `split` from that + reader ENDED the turn and handed the work to a worker. Neither mark reads + anybody now and neither moves anything: they put one note into the running + turn at its next step — `[taking stock] 10 rounds so far, 9 files opened, + 121 KB of results in front of you.` plus the three roads on — and the model that is holding + the context decides. `checkpointSplitNote`, the `split` decision and the + seam mark are gone; `checkpointNotes` is the count of rungs that tell, and + `checkpointMarks` is still where the last one stands." + - "THE CEILING WAS A COUNT OF ROUNDS. It is a runaway net, and `turnHasRunAway` + reads ONE fact: the turn's context has passed the room it can still work in + (`compactThresholdOf` of its own window, the build's own line for how far a + conversation may grow). The other roads to the same ceiling were already + written and are not repeated in it — the loop watch past its nudge ceiling + (`handOverLoopingTurn`), an unattended session's wall (`turnwall.go`, asked + ahead of this ladder) and a believed completion claim disproved by + `checkpointPrice` more rounds of real work (`checkpointMeter.askAgainAt`). + No new number anywhere. A turn that reads ten distinct files in forty-eight + seconds is told twice and moved never, which is the measured failure this + closes." + - "A TASK STARTED OUT OF A RUNNING TURN OPENED ON A BRIEF ABOUT THAT TURN. + It was handed a written brief plus a `CALLS THAT HAVE ALREADY RUN` list of + pointers at the caller's own results, and it re-read them: measured at 551k + input tokens, 24% cached, 393 seconds, no item ticked. A promoted worker is + now a read-only FORK of the caller's transcript — the caller's messages and + tool results verbatim, with the brief as its opening message — admitted + through the same door as the tool. The stub-pointer section is not written + for an inherited worker at all (`admissionContext` still serves every other + kind), and `quickFromDrawing`'s bare-spec path is gone." + - "`quick_task` had no way to be handed the conversation that started it. It + takes `inherit` (a boolean, off by default), described in the four words the + prefix budget can afford: `Opens on this conversation`. The refusal is a + result the model reads, it names the window it did not fit, and it says to + hand the work out with a brief instead. There is one constructor, not two — + `newQuickSpec` gained a field." + - "`quick_task`'s DESCRIPTION SAID THE ROAD LAW THAT THE PAGE ALSO SAYS. `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.` is + `beltfacts.go`'s `What must be checked and landed on its own, or must survive + you, is a task; what you will read and carry on with is quick`, word for word + in two places and paid for on every request of every turn. The description's + copy is deleted: the page renders wherever this verb does + (`Config.mayQuickTask` puts both verbs on a belt or neither), and what is left + on the tool is what only the tool says — the two roads in one sentence, the + step that is no hand-off at all, and the grain." + - "AND WHAT `inherit` DOES IS A VERB LAW, NOT A PAGE LAW. It rode + `beltfacts.go`'s handoff paragraph for a wave — one sentence naming the + field — and a page sentence is paid for by every request of every turn + whether or not the belt carries the tool it is about. It is registered at + `quick_task` now (`lawregistry_test.go`, class `verb`), which is the class + the diet's own taxonomy already had for a fact about one field of one tool." + - "THE `inherit` BOUND IS READ AGAINST THE WORKER'S WINDOW, NOT THE + CONVERSATION'S. The two are routinely different — a `task.model` pin or the + crew's worker seat hands tasks to a cheaper model than the person is talking + to — so the model is settled by the resolver BEFORE the spec is built, and + `newQuickSpec` takes it and writes it onto the node. The quick door used to + resolve the model after building the spec and assign it afterwards; that + order is reversed and the assignment happens once." + - "A NET THAT FIRES ONCE HAS TO SAY SO. The net measures a condition rather + than a boundary and a full context stays full, and three of + `handOverRunningTurn`'s endings LEAVE THE TURN RUNNING — no brief came back, + the conversation is holding work of its own, the reader says nothing is left. + Without a latch each of those was followed by a fresh mastermind call and an + unconditional cut of the next step, every round until the turn ended. + `checkpointMeter.netFiredAt` records the firing and `netRested` holds the net + off for `checkpointPrice` rounds of real work — one predicate for all four + endings, in the unit the meter already used for a disproved claim." + - "THE BRIEF ROAD SURVIVES ONLY WHERE INHERITANCE CANNOT GO — the rung that + fired BECAUSE the context is too big to carry. There the brief now carries + the compiled results themselves, under `WHAT THIS WORK ALREADY FOUND OUT`, + rather than pointers at calls the worker would have to run again." + - "`forkSeed` was deleted with #811's 1,368 lines when the fork left the belt. + It is back in `fork.go`, restored rather than rewritten, and it is what + `inherit` is built on: there is one read-only fork of a transcript in this + package and promotion uses it." + - "THE LINE A MARK USED TO WRITE IS NOT PRINTED ANY MORE. `this has parts · + handing it to a task that can take them side by side` was the mark's own + notice; a mark writes nothing at all now, so the two pages quoting it as a + live line were describing an event that cannot happen. The two lines a move + can still write are the ceiling's `this is running long · moving it to a task + that is watched and can split` and the carry-on's `this is running long · + carrying on here, in this folder, with everything already read` — the second + of which replaces `this has parts · a quick task is taking them here`, + because what the line has to say now is what the worker took with it." + - "The manual said an answer that runs long is read and moved at round ten, + that `markreader` is asked three times, and that the reading decides whether + the step stops. `chat/tasks.md` now has `An answer that runs long is told, and + decides for itself` and `When a reply is taken out of your hands`, plus a + section on keeping what was already read; `how-tasks-run.md` says which tasks + inherit a transcript and which get a brief; `models-and-cost.md` says + `markreader` is asked at most once during an answer, on a reply that can no + longer work where it is; and `screen.md`'s `taking stock` row says the same, + beside a new section separating that phase word from the `[taking stock]` + line the model is handed." +--- + +The decision was being made by the wrong party, on the wrong signal, and the move +threw away the thing that was worth keeping. A summary reader on a small account +cannot know whether a turn is nearly done; a count of rounds says nothing about +whether the work is going anywhere; and a worker that opens on a brief about your +reading starts by doing your reading again. + +So the count buys a sentence instead of a decision. The model holding the context +is the only party that knows whether it is two files from an answer, and the note +gives it the three facts it cannot see about itself and the three roads on, with +the cost stated once where the choice is. Nothing about it goes into the prefix: +it arrives through the one ambient door a loop nudge already uses, at the next +step boundary, and it never starts a turn. + +What is left of the old ceiling is a net rather than a schedule. It asks whether +this turn has run away — no room left to work in, a loop already nudged, a wall +already spent — and every bound is derived from something the build had already +measured, so there is no new number to tune and no case where a productive turn +meets one. + +The prefix comes DOWN. Measured against `dev` at 84016ad09, on a machine with ripgrep: +the lean prefix goes 31,341 → 31,267 bytes and the fixed 39,073 → 39,000, against +budgets of 31,500 and 48,000. The `inherit` property costs 72 bytes and the road law +deleted from `quick_task`'s description gives back 145. On a machine WITHOUT ripgrep — +which is what CI is, and `grep`'s description is longer there — the same pair reads +31,448 → 31,375 and 39,180 → 39,107: the same 73 bytes back, in the environment the +gate measures. The note itself is in neither figure: it rides the event, at the +boundary where it matters, and costs a turn that never reaches a rung nothing at all. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index f6356532c0..96a0800c6f 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -1130,7 +1130,7 @@ the run goes on to deliver, be reviewed, and land like any other — **with the checklist it already read off your request**, so a job that fell back here is still held to the behaviours you stated rather than only to how its answer reads. -## When the run says a brief could not be written — a plan already drawn is kept +## When the run says a brief could not be written — the run said the brief could not be written, what happened, a plan already drawn is kept You only see that line when **no plan was drawn at all**. Planning is seven or eight model calls, some of them one per node, and a fault in a late one is not a reason to throw away diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index c002c92b53..c298d2532d 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -614,9 +614,12 @@ A task is the same agent you talk to, with the same tools, in a quieter place. It inherits the conversation's provider client, context window, image support, roles source, search provider and fetcher, **connected accounts**, image-generation model and -document engine. It inherits **not the transcript** — one assembled brief is its whole -world, and your own message is the first part of it (below, under *What the task actually -reads*). The +document engine. **The transcript it inherits only when it was promoted** — a quick task +started with `inherit`, or a reply the ceiling carried on — and that one opens holding +this conversation exactly as it stands, every result in it, reading none of it again +(*tasks*, under *Can it keep what it read*). Every other task inherits **not the +transcript**: one assembled brief is its whole world, and your own message is the first +part of it (below, under *What the task actually reads*). The one thing it is given of what aforge remembers about you is the handful of lines its own brief needs: the conversation asks the router once, against that brief, and puts the answer at the top of the task's instructions (what-i-remember). The task itself never @@ -1110,9 +1113,9 @@ request, the line shows its identity mark and name alone rather than an empty qu If several tasks arrive before one answer, their lines are stacked in arrival order above that answer. -**That reply is priced exactly like one you typed.** It climbs the same three points, has the -same ceiling, and is handed to a task the same way — see *An answer that runs long is read and -moved* in *Tasks*. It used to be exempt, and a measured run had one such reply grind for 46 +**That reply is priced exactly like one you typed.** It climbs the same ladder, has the +same ceiling, and is handed to a task the same way — see *When a reply is taken out of your +hands* in *Tasks*. It used to be exempt, and a measured run had one such reply grind for 46 minutes with nobody watching and then leave the session idle for seven and a half hours. ## Which task is this answer about @@ -1811,9 +1814,9 @@ working task from a hung one. **asked to hand parts of it out**, and is waiting while a model on the thinking tier reads the parts and decides whether, and how. -**A new task never waits on it.** When work is moved out of a reply that had parts in it -(`this has parts · handing it to a task that can take them side by side`), the parts -somebody drew are read **beside** the task's worker, which starts at once on the whole +**A new task never waits on it.** When a reply is moved onto a task because it could no +longer work where it was (`this is running long · moving it to a task that is watched and +can split`), the parts drawn on the way out are read **beside** the task's worker, which starts at once on the whole brief — so a task that has just appeared shows its worker's own calls, not this word. When that reading answers: parts are handed out as workers of their own, each from the task's copy of the folder as it stood at that moment, and the task gathers their reports (the diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 4617b6f18f..790b3472a0 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -377,7 +377,7 @@ under the class answering it, saying which model comes out. As shipped: | `planner` | mastermind | the plan that steers an adaptive run | | `designer` | mastermind | writes and reviews a harness page | | `routerconfirm` | mastermind | a second look before work starts itself | -| `markreader` | mastermind | what is left of a long answer, and whether it has parts | +| `markreader` | mastermind | what is left of an answer that is being taken out of your hands, drawn as parts | | `handoff` | mastermind | the instruction a handed-over turn gives whoever finishes it | | `division` | mastermind | the parts a worker hands its own work out in | @@ -396,14 +396,17 @@ about to be spent; `division` reads a task's parts before any of them exists, an every part ever takes runs on the brief it leaves behind. `markreader` and `handoff` are the two calls a long answer makes (*Tasks*). `markreader` is -asked at most three times, and only on an answer that has already spent ten rounds of tool -calls, plus once at the end of any answer that touched a tool at all — it reads the account of -the work and says what is left of your question. **It runs beside the work rather than -stopping it**: the next step of the answer goes out immediately and the reading happens -alongside it, so a reading that says "carry on" — which is nearly all of them — costs you -nothing at all. A reading that says the work has independent parts in it stops the step where -it stands and hands the answer over. It used to be awaited, and a measured one held the work -for 8.1 seconds to decide nothing. `handoff` writes the instruction the task +asked **at most once during an answer** — only when that answer can no longer work where it +is: its context full, the loop watch already spent, or the wall run out — plus once at the end +of any answer that touched a tool at all. It reads the account of the work and says what is +left of your question. **It runs beside the work rather than stopping it**: the next step of +the answer goes out immediately and the reading happens alongside it. What it draws is the +list the work carries on with; the step it lands beside is stopped either way, because the +decision to stop was taken before it was asked. It used to be awaited, and a measured one held +the work for 8.1 seconds to decide nothing. **A long answer no longer buys one of these +every ten rounds**: the two earlier moments cost no call at all now — aforge tells the model +what its answer has run up and the model decides for itself (*Tasks*, under *An answer that +runs long is told*). `handoff` writes the instruction the task opens on when an answer is handed over. Both sit on mastermind for the same measured reason: a cheap model asked "is this finished" answered `(done)` about half-finished work 15 times out of 18, and that is the one answer that quietly drops a handover you were owed. There is no diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index a927462b7c..ca91793ffe 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -3316,12 +3316,38 @@ same clock: | `running ` | one call on the belt is executing | | `checking` | a reader is deciding whether the answer finished the ask, or whether it should have been work. The two are asked **at the same time** rather than one after the other, so this stage lasts as long as the slower of them and not both | | `checking whether this is safe to run` | the safety stand-in is being asked whether one command is plainly safe, before you are asked about it. This is **the one reading that really does come first** — it decides whether the command runs at all — and it answers in ten seconds or not at all | -| `taking stock` | a second model is being shown an account of the work so far and asked what is left of what you asked for. **The work does not stop for it**: the next step goes out straight away and the reading happens alongside it. If the reading says what is left has independent parts in it, the step is stopped where it stands and the answer is handed over | +| `taking stock` | a second model is being shown an account of the work so far and asked what is left of what you asked for. **The work does not stop for it**: the next step goes out straight away and the reading happens alongside it. It happens **once**, and only on a reply that can no longer work where it is — its context full, the loop watch already spent, the wall run out, or a reply that said it had finished and then worked on for another ten rounds. The step it lands beside is stopped whatever the reading says; what the reading supplies is the list the work carries on with (*Tasks*, under *When a reply is taken out of your hands*) | | `tidying` | the conversation is being compacted | | `briefing a worker` | your turn is being handed to a task, and the instruction it opens on is being written — fifteen to thirty seconds is normal (see *How tasks run*) | Each of them is taken off the screen the moment the wait ends. +## Two things are called taking stock — the word on the status line and the `[taking stock]` line the model gets + +**They are different moments, and only one of them asks a model anything.** + +**`taking stock` in the phase words** is a wait, in the table above. A second model is being +shown an account of the work and asked to draw what is left of your question. It happens +**once in an answer**, at the moment that answer is being taken out of your hands — its +context full, the loop watch spent, the wall run out, or a claim that it had finished +disproved by ten more rounds of real work (*Tasks*, under *When a reply is taken out of your +hands*). + +**`[taking stock]` at the head of a line is aforge talking to the model writing your +answer**, and nobody is asked anything at all. After ten finished rounds of tool calls, and +again after twenty, a note rides into the next request the answer was going to make anyway, +carrying what this answer has run up and the roads on: + +``` +[taking stock] 10 rounds so far, 9 files opened, 116 KB of results in front of you. Three roads on, and the choice is yours: answer now from what you have; carry the rest on in a room, with `quick_task` and `inherit` set, which opens on this transcript as you are holding it; or hand out the parts you have not read yet. A worker that does not inherit opens on a brief about your work instead of on your work, and reads it all again. Nothing has been decided for you and nothing has been moved. +``` + +The brackets are the harness's own voice — the family `[stuck]` and `[silent]` belong to +(*Keys*) — so the model can tell it from something you typed. **It is not drawn on your +screen**: no phase word, no line in your transcript, no task started, nothing moved. What +the answer does about it is the answer's own choice (*Tasks*, under *An answer that runs long +is told, and decides for itself*). + ## What happens when I send a message — does anything run before the model is asked, what runs before my answer, and why the wait is only the model **Nothing runs before the model is asked.** From the instant you press enter, the diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 0817338bea..5c68236bd0 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -9,7 +9,9 @@ never inherits the conversation: what it reads is one written brief — your own for word, then the work, what to produce and what done means, and a pointer at the journal path and line of your original turn so it can read those words in full when the restatement was cut. How that is assembled is on the *how tasks run* page, under *What the -task actually reads* and *Can the task see the original request*. +task actually reads* and *Can the task see the original request*. The one exception is a +**quick task started with `inherit`**, which opens holding the conversation itself — see +*Can it keep what it read* below. You can ask for the work in words, and the model grooms it and calls its `propose_task` tool. You then get a card asking whether the work should go. The @@ -662,17 +664,17 @@ somewhere else while you watch it. So a no costs you nothing at all — which is messages, and it is why the read can afford to take its time. **If both models say yes, you see nothing.** No line, no task, no interruption. The one -thing that changes is *when aforge looks at the work*: instead of waiting until the answer -has run ten rounds of tool calls, it looks at the very next break between rounds — and then -at the ordinary points after that. What that look is, and what it can do, is *An answer that -runs long is read and can be handed over* below. +thing that changes is *when aforge says something about the work*: instead of waiting until +the answer has run ten rounds of tool calls, the first note lands at the very next break +between rounds — and then at the ordinary point after that. What that note says, and what it +can and cannot do, is *An answer that runs long is told, and decides for itself* below. **It used to hand the reply over on the spot, and it does not any more.** That was measured against real transcripts and it took work out of the conversation that the conversation would have finished faster: a message that *sounds* like four jobs is not the same fact as -four jobs, and reading the request cannot tell them apart. Reading the work can. So this -moment now only decides **how soon to look**, and looking at the work decides everything -else. +four jobs, and reading the request cannot tell them apart. The answer doing the work can. So +this moment now only decides **how soon the answer is told what it has run up**, and the +answer decides for itself from there. **What the judge is looking for is a request whose fastest correct answer is not a conversation:** several independent deliverables in one message, a sweep over many files or @@ -720,71 +722,62 @@ offered the way every other proposal is offered — `1 start it`, `2 no`, and a whose silence starts it. You are told, and it opens; what the card gives you on top of that is the window to redirect it before it spends anything. -## An answer that runs long is read and moved — a reply that stops halfway to become a task, my answer was moved, this has parts, this is running long, carrying the ask only, no second model is set - -**When one answer keeps going, aforge prices it.** A long answer costs a little more with -every round of tool calls and never stops costing; handing the same work to a task costs one -fixed price — a clean copy of your folder opened and closed, a brief written, somebody -independent reading the result — and after that the work runs watched. So the count of -finished tool rounds is compared against that fixed price, and at three points along the way -aforge stops and **looks at what is left**. **Nothing about what you asked for is read to -decide when to look.** It is the cost and only the cost. (If the message you typed already -read like work, the first of those points comes at the very next break instead — see the -section above.) - -**At each point, a second model reads the answer so far.** Not the model writing your reply -— a different one, asked for one line: a sketch of what is left, as parts and arrows. -`A | B | C` means three pieces that do not wait on each other. `A > B > C` means one job in -three steps. Then one sentence saying what the letters are. If nothing is left, that line is -`(done)`, and if the only thing left is waiting on work already handed out that line is -`(waiting)` — *Watching the pieces you handed out does not move your answer*, below. -**The model answering you is never asked and never sees the question**, which is -the whole point: a model in the middle of tool calls answers a question like that with -another tool call about half the time, so it is asked of somebody who is not busy. - -**That second reader is shown a short account of the work, not the whole conversation** — -your message word for word, one line per tool call naming the tool and what it touched, **the -end of what came back from the most recent of those calls**, what has been written or -changed, and the last thing the answer said. Reading the whole conversation instead was -measured costing more than the work it was judging, so what is carried is bounded: each -result is cut to its last 400 bytes, newest first, and the account says how many older ones -it left out. The tail rather than the top, because what a command concluded — `Passed: 0`, -`97 errors`, `no such file` — is in its last lines. Results used to be left out entirely, and -that made the one reader deciding whether to hand your work over the only participant who -could not see the evidence. - -**If the sketch has independent parts in it, the reply is handed over.** It stops halfway, -your answer is moved to one task, and two dim lines go into the transcript: - -``` -this has parts · handing it to a task that can take them side by side -this looked like work, so task 4 started: finish the four pieces -``` - -The first line is the reason; the second is the ordinary line every task started this way -carries, with its own number and name. Both are in the transcript, so the next thing you say -is not answered on top of a message nobody replied to. - -**If the sketch says one job, nothing happens at all.** No line, no note, nothing added to -your reply, and the model writing it is not told it was looked at. The answer carries on and -the next point is twice as far along. - -**And if nobody can be reached, nothing happens either.** No second model configured, a -reader that faults, a reader that takes too long: each of those is a look that produced -nothing, and a look that produced nothing is the answer carrying on. The third point below -is what makes that safe. - -**The third point is not a question.** Past it aforge stops looking. The answer ends where it -is, what is left of the work moves onto one task whatever the last sketch said — unless the -answer itself says nothing is left, which is the one thing that stops it (below) — and two -dim lines go into the transcript: - -``` -this is running long · moving it to a task that is watched and can split -this looked like work, so task 4 started: finish the four pieces -``` - -## A quick task took over my answer · this has parts, a quick task is taking them here · why was there no copy of the folder · the moved work carried on in my own folder +## An answer that runs long is told, and decides for itself — a reply that stops halfway to become a task, my answer was moved, this is running long, why did it not become a task + +**When one answer keeps going, aforge says so — and the model writing it decides what to do +about that.** After ten finished rounds of tool calls, and again after twenty, a short note +arrives inside the reply. It carries three facts aforge already has — how many rounds have +gone, how many files have been opened, how many kilobytes of results are being held — and the +three roads on: answer now from what is already there; carry the rest on in a room, with +`quick_task` and `inherit` set, which opens on this conversation exactly as it stands; or +hand out the parts that have not been opened yet. + +**Nothing is asked of a second model at those two points, and nothing is moved.** No task +starts, no line is added to your transcript, and the note costs no extra call — it rides into +the next request the reply was going to make anyway. The count of finished rounds is the +whole of the trigger, and **nothing about what you asked for is read** to decide when to say +it. Rounds spent only watching work already handed out do not count towards it. + +**Why the model decides and aforge does not.** The question is not "are there parts" — it is +"is this worth carrying somewhere else", and only the model holding what has been read knows +what re-reading it would cost. A second model used to be shown a summary of the work and +decide for it: on a one-line design question it read ten files in 48 seconds, a reader said +`split`, and the worker that started re-read every one of them — 551,000 tokens and six and a +half minutes, with not one item ticked. The model that had done the reading answered the +question out of what it already held, the moment somebody asked it. + +**So a long answer is no longer moved for being long.** Ten rounds, twenty rounds, forty — +a reply that is working is left to work. What takes a reply out of your hands is the section +below, and it is not a count. + +## When a reply is taken out of your hands — this is running long, my answer was moved, the ceiling, why a reply stopped halfway + +**A reply is moved only when it can no longer work where it is.** aforge reads that three +ways, and none of them is a number of rounds: + +- **Its own context is full.** The conversation has grown until this model's window no longer + has room for one more tool result. A reply that cannot hold another result cannot take + another step. +- **It is going in circles.** The loop watch has said so twice, and a third note would be + aforge talking to itself — see *Why does it say carry on*. +- **It said it was finished and then carried on.** A reply claiming nothing is left is + believed once per request, and ten more rounds of real tool work disprove it. + +A session running unattended with a wall has a fourth: one turn may spend only a share of the +wall inline, so that what it hands over can still be checked before the wall comes down. + +**At that point, one second model is asked one thing: draw what is left.** Not whether to +move the work — that is already decided — but the shape of it, as parts and arrows: +`A | B | C` means three pieces that do not wait on each other, `A > B > C` one job in three +steps, and a sentence under it saying what the letters are. That drawing becomes the +**checklist** the work carries on with. If nobody can be reached — no second model +configured, a reader that faults or takes too long — the reply still moves, with no checklist. + +**Then the reply stops where it is, and what takes the work opens on the work.** See *A quick +task took over my answer* for the one-line case and *It made a task out of work that was +already done* for the reply that was finishing anyway. + +## A quick task took over my answer · this is running long, carrying on here in this folder · this has parts, a quick task is taking them here · why was there no copy of the folder · the moved work carried on in my own folder **When the answer being moved has changed nothing on disk, a quick task takes it instead of a full one.** That is the whole of what aforge reads here — not what the work was about, but @@ -795,19 +788,43 @@ merge, so it is not given a copy of the folder. **You read one line instead of two:** ``` -this has parts · a quick task is taking them here, in this folder: audit the pricing code +this is running long · carrying on here, in this folder, with everything already read: audit the pricing code ``` **What that means.** The work carries on **in the folder you are standing in** — no branch, -no copy, no merge. The parts the second reader drew become the quick task's **items**, in -the order they were drawn, and it works through them in that order. Nothing checks it and -there is nothing to land: its last message is the answer, and it reaches you as the ordinary -note when the row goes `done`. It is on the rail like any other task, so it can be opened, -steered and stopped from there. +no copy, no merge. The parts the drawing named become the quick task's **items**, in the +order they were drawn, and it works through them in that order. Nothing checks it and there +is nothing to land: its last message is the answer, and it reaches you as the ordinary note +when the row goes `done`. It is on the rail like any other task, so it can be opened, steered +and stopped from there. **If the reply had written anything at all, none of this applies.** One edit is enough: the -answer takes the ordinary road above — a task in its own copy of the folder, briefed, -checked and landed — and you read the two lines that road writes. +answer takes the ordinary road above — a task in its own copy of the folder, briefed, checked +and landed — and you read the two lines that road writes. + +## Can it keep what it read — does the moved work start over, why did it stop reading and start a task, inherit + +**A moved reply is PROMOTED, not restarted.** The quick task that takes it opens holding +**this conversation's whole transcript** — every file that was read, every result that came +back, word for word — with the drawing's parts as its checklist on the end. It re-reads +nothing, and the provider bills most of it as already cached. + +**That is what the `with everything already read` in the line means.** Before this, the work +was handed a brief plus a list of **pointers** to the calls the reply had already made, and a +pointer is an instruction to go and read it again: measured once at 551,000 tokens and six and +a half minutes, with nothing ticked. + +**The model can do the same thing itself.** `quick_task` has an `inherit` field, off by +default. With it set, the quick task it starts opens on this conversation as it stands +instead of on a sentence about it. + +**And what cannot be inherited is still not re-read.** `inherit` is refused when the +conversation will not fit the worker's window with room left to work in — the model is told +roughly how big the conversation is and how much room the worker would have, and can start +the work without it. A reply that could not be promoted for that reason, or because it had +written files, goes the briefed road, and **the brief carries the account of the turn**: +what was called and the end of what came back, newest first, cut, under the heading +`WHAT THIS WORK ALREADY FOUND OUT`. Not pointers. ## It made a task out of work that was already done · why did it hand over when everything was written · the task redid what the answer had already written · it started again from my first message @@ -931,8 +948,7 @@ what still remains, and if the model answers that everything you asked for is al **and the second reader's sketch at that same point said `(done)` too**, the move is **dropped**. No task, no lines. The answer carries on to its own end and stands, which is the right outcome for a turn that was finishing anyway: what this whole mechanism is for is an -answer that is grinding, and one that is about to stop is not. This holds at all three -points. +answer that is grinding, and one that is about to stop is not. **One reader saying so is not enough**, and that is deliberate: a model in the middle of a long answer saying "everything is done" is that model marking its own work at the moment it @@ -958,22 +974,24 @@ the fork is behind a step nobody has taken yet. `(A | B | C) > D` is three, with step waiting on all of them. `A > B | C > D` is two chains that wait on nothing but themselves. -**At the third point the task is allowed to split but nothing is handed out.** There the +**At the ceiling the task is allowed to split but nothing is handed out.** There the answer outran one pair of hands by measurement and no parts were drawn, so the worker is merely allowed to hand parts out once it has opened the material. Whether it does is its own decision, and it still has to justify them inside the task; the roster says so if it happens — see *When a task turns out to be too wide for one worker*. -**What it costs.** At most three calls to that second model, and only on an answer that has -already spent ten rounds of tool calls, which most answers never do. Handing over adds two -more: the draft, and the model that writes the brief out of it. One further call goes at the +**What it costs.** Nothing at all until a reply is actually taken out of your hands: the two +notes buy no model call, and the drawing is one call at the ceiling, which most answers never +reach. A reply that is promoted stops there — no brief is written, because the worker is +handed the conversation itself. A reply that has to be briefed instead adds two: the draft, +and the model that writes the brief out of it. One further call goes at the **end** of any answer that touched a tool at all, asking whether your question is finished — see the section below. An answer that called no tools costs none of this. The read at the front of your turn is one cheap call and now starts nothing by itself. **This applies to replies aforge started by itself, too.** When a task lands, the chat answers it without you typing anything (see *Why did the chat reply on its own* in *how tasks -run*). That reply is priced exactly like one you asked for: same three points, same ceiling, +run*). That reply is priced exactly like one you asked for: same two notes, same ceiling, same handover. It used to be exempt, on the grounds that a reply about a task already had a budget somewhere — it does not, and a measured run had one such reply make 127 tool calls over 46 minutes with nobody watching, and then the session sat idle for seven and a half @@ -1086,7 +1104,7 @@ something, and the limit on carrying on*. ## A reply that starts changing files becomes a task — why did my edit become a task, it started a task instead of just editing, how many files can a reply change, how many edits can a reply make, small edits inline -**Reading is free. Writing is not.** The three points above count tool ROUNDS, which is the +**Reading is free. Writing is not.** The two notes above count tool ROUNDS, which is the right unit for a reply that is looking things up and the wrong one for a reply that is changing your files: forty rounds of reading cost you a wait, and forty rounds of editing are unreviewed changes in the folder you are sitting in. So there is a second, much shorter @@ -1118,7 +1136,7 @@ outside this folder — a scratch file in `/tmp` is not your work. **It can still decide not to move.** The move goes through the same road as the third point, which means it can be dropped when the model writing your answer says nothing is left AND the second reader agrees — which is exactly the reply that made its one edit and was finishing. -And it happens **once** in a reply: past it, the three points above are the governor again. +And it happens **once** in a reply: past it, the notes and the ceiling govern it again. **Why it is there.** A message reading "implement this issue" was answered as an ordinary reply for seven minutes and forty-six seconds — forty-eight tool calls, `sed -i` edits in @@ -1187,7 +1205,7 @@ as a finished job does. Until this, nothing checked which of the two it was, and ten-hour request ended with hours of it never touched. **So at the end of a reply that has already run long enough to be looked at once — the first -of the three points above — the same second reader is asked one question**: is what you asked +of the moments above — the same second reader is asked one question**: is what you asked for finished? It is shown the same short account of the work — your message, the steps, what came back — and it answers either the single line `NOTHING LEFT TO DO`, or one line saying what of your request is still not done. @@ -1209,7 +1227,7 @@ answering a question that was addressed to you. That is the whole of the test itself, so it works whatever language you are talking in. **A short reply that only LOOKED at things is not read at all.** If the reply ended before it -reached the first of those three points — a couple of reads and an answer, or no tools at all +reached the first of those notes — a couple of reads and an answer, or no tools at all — it is never read for what remains. There was not enough work in it to leave half done, and reading every small reply cost a thinking-tier call on every message you sent: measured, that was a third to a half of a small question's whole bill, and it almost never found anything @@ -1257,7 +1275,7 @@ itself, and it is then priced like any other short reply. aforge does not try to from a test from a read; it only asks whether the reply stopped on the change or looked at it. **What bounds it is the same meter as everything else on this page, and two rules of its own.** -Carrying on counts as a round, so it climbs the same three points, and a carried-on reply that +Carrying on counts as a round, so it climbs the same ladder, and a carried-on reply that reaches the third one is handed to a task in the ordinary way. On top of that: a reading that says what the last one said stops the reply on the spot, and a reader that keeps finding new things is believed at most three times — see *Waiting on something, and the limit on carrying @@ -3956,8 +3974,7 @@ the parts somebody already named are handed out without the worker having to fin again. Everything below applies to it without exception: the same tests, the same reading by the mastermind, the same refusals. The receipt reads the same too, and reaches the worker while it works, telling it the parts are now somebody else's so it does not do them again; -an answer that arrives after the worker has finished is dropped. *An answer that runs long is -read and moved* is where that happens. +an answer that arrives after the worker has finished is dropped. *When a reply is taken out of your hands* is where that happens. ## Why it refused to split the work — it would not break the job into pieces, and the tests a division has to pass diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9c576e730b..35757ac4c7 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -1765,6 +1765,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // judge — and they say the line back in its own words. {"this has parts handing it to a task", "tasks"}, {"a quick task took over my answer", "tasks"}, + // The promotion wave of 2026-09-11: the turn is told and decides, the + // ceiling is a runaway net, and what takes the work opens on the work. + {"why did it stop reading and start a task", "tasks"}, + {"can it keep what it read", "tasks"}, + {"why did it not become a task", "tasks"}, {"why was there no copy of the folder", "tasks"}, {"why did it hand my answer to a team", "tasks"}, {"it started answering and then handed the work over", "tasks"}, diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index 389955affc..d2411b51b9 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -32,30 +32,32 @@ package session // buying costs, and then buy. That is exactly the shape here, with one softening // and one hardening: // -// - THE TRIGGER IS DETERMINISTIC COST AND NEVER CONTENT. Nothing decides to -// LOOK on account of what the turn is about. A counter of finished tool -// rounds is the whole of the input, so there is no phrasing that defeats it -// and no kind of work it is tuned for. -// - THE JUDGEMENT IS READ FROM THE WORK, up to a point. At each mark the -// harness has somebody sketch what is left, and a turn that is genuinely one -// long job carries on with nothing said and nothing spent but that one call. -// - AND PAST THE LAST MARK THE HARNESS STOPS READING. Two sketches saying "one -// job" are a judgement; a third would be momentum. At the ceiling the turn -// ends and the REMAINING work moves onto the one road, where it is watched — -// and remaining is the whole of what is still asked there -// ([checkpointNothingLeft]): a turn that answers the handoff by saying it is -// finished is left to finish, which is not the harness asking again but the -// harness having nothing to move. +// - NOTHING HERE IS DECIDED ON WHAT THE TURN IS ABOUT. Neither rung reads a +// word of the subject: the notes are counted in finished tool rounds and the +// ceiling is a property of the machine the turn is running on. So there is no +// phrasing that defeats this and no kind of work it is tuned for. +// - AND THE RENT IS TOLD TO THE ONE READER THAT CAN PRICE IT. At each note rung +// the turn is handed what it has run up and the roads on, and NOBODY IS ASKED +// ANYTHING ([checkpointChoiceNote], inherit.go): the model holding the work is +// the only party that knows what carrying it elsewhere would cost, because +// only it knows what re-reading everything it has read would buy. +// - AND THE LAST RUNG IS NOT RENT AT ALL. Spending is not a reason to end a +// turn; being unable to carry on is. The ceiling fires on a turn that can no +// longer work where it is ([Agent.turnHasRunAway]) and moves the REMAINING +// work onto the one road, where it is watched — and remaining is the whole of +// what is still asked there ([checkpointNothingLeft]): a turn that answers +// the handoff by saying it is finished is left to finish, which is not the +// harness asking again but the harness having nothing to move. // // ── WHY THE MARKS ARE GEOMETRIC AND NOT ONE LINE ── // -// A single threshold is a single chance to be wrong. Doubling the interval each -// time means a turn that is honestly one long job is looked at a bounded number -// of times — three, over four times the handoff price — while a turn that is four -// jobs in a trench coat is read early, when the findings are still worth handing -// over. Each look costs a model call, so looking gets rarer as the evidence that -// the answer is "carry on" accumulates. [checkpointRatio] is the whole of that -// policy. +// A single threshold is a single chance to be wrong. Doubling the interval means +// a turn that is honestly one long job is told a bounded number of times — twice, +// the second at [checkpointRatio] times the handoff price — while a turn that is four jobs in +// a trench coat hears it early, when the findings are still worth handing over. A +// note costs no call, but it costs the turn's attention, so saying it gets rarer +// as the evidence that the answer is carrying on accumulates. [checkpointRatio] +// is the whole of that policy. // // ── WHY THE MARK IS READ BY SOMEBODY ELSE ── // @@ -72,10 +74,17 @@ package session // interrupt. // // The mastermind tier answered the same questions on the same transcripts with -// 0% to 7% unanswered. So the mark is READ BY A SIDECAR ([Agent.readMark]): one -// call, on the tier that thinks, over the transcript the turn has built — and the -// running model never sees a word of it. Nothing is injected into the turn at any -// mark, which is why there is no note const in this file any more. +// 0% to 7% unanswered. So the READING is done BY A SIDECAR ([Agent.readMark]): +// one call, on the tier that thinks, over the transcript the turn has built — +// and the running model is never asked to answer anything. +// +// WHICH IS WHY THE NOTE RUNGS ASK NOTHING RATHER THAN ASKING CHEAPLY. A line does +// ride into the turn at those rungs (inherit.go's [checkpointChoiceNote]), and +// the difference between it and the mechanism the measurement killed is the whole +// of what was learned: it is told and not asked, it needs no answer to be worth +// sending, and a turn that replies to it with a tool call has lost nothing. The +// one reading that must come back is the one at the ceiling, and that is the one +// the sidecar takes. // // ── AND WHAT IT IS ASKED FOR IS A SHAPE AND NOT A DECISION ── // @@ -190,17 +199,30 @@ const ( // however long it runs. checkpointRatio = 2 - // checkpointMarks is how many marks a turn has, and the LAST of them is the - // ceiling rather than a fourth reading ([Agent.checkpointRound]). + // checkpointMarks is how many rungs a turn's ladder has, and the LAST of them + // is the ceiling rather than a third note ([Agent.checkpointRound]). // - // Three, because two readings are the most that are worth paying for. The loop + // Three, because two tellings are the most that are worth paying for. The loop // detector reached the same number from the other side — past two nudges the // notes have stopped working and a third is the harness talking to itself // (looped.go's loopNudgeCeiling) — and the answer here is the same answer: - // stop asking and do something. What this does instead of asking again is move - // the work somewhere it is watched. + // stop saying it and do something. + // + // AND THE LAST RUNG NO LONGER STANDS ON A ROUND COUNT AT ALL, which is the + // 2026-09-11 ruling and the one change that matters in this file. A count of + // finished rounds prices THE PERSON'S WAITING, which is exactly what the two + // notes below are for and exactly the wrong question to end a turn on: a turn + // that read ten files in 48 seconds was moved by it, and its worker then spent + // 551,000 tokens reading them again. What ends a turn is a reading of RUNAWAY + // — a turn that can no longer work where it is (inherit.go's + // [Agent.turnHasRunAway]) — and the ladder's business stops at the notes. checkpointMarks = 3 + // checkpointNotes is how many of those rungs are NOTES: every rung but the + // last. It is derived rather than written so the two cannot disagree about + // where the telling stops and the ceiling begins. + checkpointNotes = checkpointMarks - 1 + // THE HANDOFF BRIEF IS SENT WITH NO CEILING, like everything else on this // road. It used to carry 2000 — more room than the shaper's, because this // document is written by a model with a turn's worth of findings in front of @@ -492,30 +514,6 @@ const checkpointSketchAsk = "[checkpoint] Above is what was asked and what has b "Work that is already done is not a part either: what the account above shows finished is not " + "what remains, and drawing it sends somebody to do it a second time." -// checkpointSplitNote is the ONE line a person reads when a mark's sketch says -// the work in front of it has parts. -// -// It is the third line in this register and the last of the family: the -// mid-answer handoff's ([taskEscalationNote]), the ceiling's -// ([checkpointCeilingNote]) and this one. All three are an observation, a middle -// dot and a promise, all three are lowercase with no full stop, and nothing in -// any of them is machinery. -// -// WHAT DIFFERS IS THE OBSERVATION, because the three moments have honestly seen -// different things. The ceiling has watched an answer outrun its own price and -// says so. This one has had somebody read the work and draw what is left of it, -// and the true thing it has to say is that the drawing came back with more than -// one part in it — so it says that, and nothing about how long the turn has been -// running, which at the first mark may be no time at all. -// -// AND THE PROMISE IS THE ONE THING THIS PATH CAN PROMISE THAT THE OTHERS CANNOT. -// The ceiling says the work "can split", because arming is all it did. Here the -// parts are already named and already at the head of the brief, so the honest -// promise is about the shape of the hands rather than about a possibility — and -// it still stops short of saying it WILL split, because the evidence gate and the -// reviewer stand in front of every actual division (task_divide.go). -const checkpointSplitNote = "this has parts · handing it to a task that can take them side by side" - // checkpointCeilingNote is the ONE line a person reads when the harness stops // reading and moves the work itself. // @@ -681,7 +679,7 @@ const checkpointRemainsAsk = "[still asked] Above is what the person asked for a // checkpointCarryOnNote is the ONE line a person reads when a turn stopped and // the ask had not. // -// It is the fourth in the register ([checkpointSplitNote], [checkpointCeilingNote], +// It is the fourth in the register ([checkpointCeilingNote], // task.go's [taskEscalationNote]): an observation, a middle dot, a promise, all // lowercase, no full stop, no machinery. WHAT IT OBSERVES IS THE ONE HONEST THING // AT THIS MOMENT — somebody who is not the running model read the ask against the @@ -958,6 +956,62 @@ type checkpointMeter struct { // said this message reads like work, and zero on every ordinary turn — see // [checkpointMeter.tighten]. firstAt int + // outOfRoom says THE NET FIRED BECAUSE THIS TURN'S CONTEXT COULD NO LONGER + // HOLD ANOTHER STEP, and it is the one fact about a runaway that decides what + // happens next (inherit.go): a worker handed a context its own window cannot + // work in is a worker in the same trouble one step later, so that rung's move + // is the brief road with the results compiled into it and never a promotion. + // + // IT IS LATCHED WHERE THE NET FIRES RATHER THAN ASKED AGAIN LATER, which is + // the reading-decides law read in this file's own currency: two readings of a + // growing context taken a model call apart can disagree, and the one that + // decided to end the turn is the one that must decide what takes it. + // + // AND IT IS NEVER CLEARED, WHICH IS THE POINT RATHER THAN AN OVERSIGHT. The + // one thing that can put a turn's context back under the line after this + // fired is a compaction (loop.go's [Agent.maybeCompact]), and a compacted + // transcript is precisely what a promotion must not carry: the whole argument + // for inheriting is that the worker holds the RESULTS, verbatim, and a folded + // transcript is a summary of them. So a turn that ran out of room once takes + // the brief road for the rest of its life, with what it found out compiled + // into the brief, whatever the meter would read a moment later. + outOfRoom bool + // opened is where in the transcript this turn's own messages start — the + // index of the message the person opened it with — and zero until the first + // boundary has taken it ([turnOpenedAt]). + // + // IT IS ON THE METER FOR THE REASON EVERYTHING HERE IS: it is a fact about + // ONE answer, and the note it feeds says "9 files opened" about this answer + // rather than about every conversation this session has ever had. + opened int + // netFired is that the runaway net has fired on this turn at all, and + // netFiredAt is the round it last fired on. + // + // THEY ARE TWO FIELDS BECAUSE THEY ARE TWO FACTS AND ROUND ZERO IS A REAL + // ROUND. A batch that only watched work already out does not advance + // [checkpointMeter.rounds], so a turn whose opening boundaries are all + // watching sits at round zero with the net able to fire; a single field + // would read that firing as "never fired" and hand the turn the very + // re-arming this latch exists to stop. + // + // THE NET IS A CONDITION AND NOT A BOUNDARY, WHICH IS WHY IT NEEDS THIS. The + // ladder's rungs are rounds and a round happens once; "this turn cannot work + // where it is" is true of a quantity that only grows, so it is true at every + // boundary after the first one it is true at. Three of the endings below + // [Agent.handOverRunningTurn] LEAVE THE TURN RUNNING — no brief to give, work + // this conversation is holding, a reader saying nothing is left — and without + // a latch each of those would be followed by a fresh reading and an + // unconditional cut on every remaining step, which is the person's answer + // being chopped once a round for the rest of the turn. + // + // SO A FIRING BUYS ITS OWN QUIET, AND THE QUIET IS THE PRICE. The bound is + // [checkpointPrice] rounds of real work — the same unit the meter already + // uses for "this was asked and answered; do not ask it again for free" + // ([checkpointMeter.round]'s disproved-claim rung) — so a turn that really is + // running away is met again after a turn's worth of evidence rather than + // after a step, and no new number is invented for it. + netFired bool + netFiredAt int // raced is what that race's both-yes actually wrote: the breadth two readers // agreed on and the done-condition the screen composed ([routeVerdict]). // @@ -1117,7 +1171,10 @@ func (m *checkpointMeter) round(worked bool) int { m.askAgainAt = 0 return checkpointMarks } - if m.marks >= checkpointMarks { + // AND THE COUNT CLIMBS NO FURTHER THAN THE NOTES. The rung above them is the + // ceiling, and nothing about how many rounds a turn has finished says whether + // it has run away ([checkpointNotes], [Agent.turnHasRunAway]). + if m.marks >= checkpointNotes { return 0 } if m.rounds < m.markAt(m.marks+1) { @@ -1127,6 +1184,20 @@ func (m *checkpointMeter) round(worked bool) int { return m.marks } +// netRested reports that the runaway net may fire again: either it has not +// fired on this turn at all, or a turn's worth of real work has been done since +// it last did ([checkpointMeter.netFiredAt] says why the quiet is owed). +// +// IT IS ONE PREDICATE FOR ALL FOUR ENDINGS. A believed completion claim, a +// reader with no brief to give, work this conversation is holding and a reading +// that says nothing is left all leave the turn running, and all four used to be +// followed by the same question being asked again at the very next boundary. A +// predicate per ending is four chances to forget one; this is the shape asked +// once, of the fact that the net fired at all. +func (m *checkpointMeter) netRested() bool { + return m == nil || !m.netFired || m.rounds-m.netFiredAt >= checkpointPrice +} + // mayBelieveDone reports that no completion claim about THIS request has been // believed yet, and so one may be believed now. // @@ -1868,7 +1939,12 @@ func (a *Agent) journalMarkRead(read checkpointRead, mark, rounds int, decision // constants because the bench reads them and a decision spelled two ways is two // decisions to whatever is counting. const ( - checkpointDecisionSplit = "split" + // checkpointDecisionTold is what a NOTE rung did: the turn was handed the + // facts and the three roads on, and nothing else happened. It carries no + // model, no cost and no duration — the emptiness law doing the rest of the + // work, because there was no call and so there are no figures about one. + checkpointDecisionTold = "told" + checkpointDecisionContinue = "continue" checkpointDecisionFailed = "failed" // checkpointDecisionNoReader is the ONE row a session with no second model @@ -1978,7 +2054,6 @@ const ( // therefore never says `moved` — nothing was handed anywhere — it says which // ending the road stood down for. const ( - checkpointSeamMark = "mark" checkpointSeamWrite = "write" checkpointSeamCeiling = "ceiling" checkpointSeamWall = "wall" @@ -2719,51 +2794,70 @@ func (a *Agent) checkpointRound(ctx context.Context, hub *eventHub, user userMes if wallShareIsAsked(mark, calls) && a.pastTurnWallShare(meter, started) { return a.checkpointOverWallShare(ctx, hub, turn, started, model, meter.rounds, meter, taken) } - if mark == 0 { - return false - } - // THE SKETCH IS READ AT EVERY MARK, THE CEILING'S INCLUDED. At the first two - // it is the decision; at the ceiling the decision is already made and the - // drawing is still worth its call for TWO reasons now — it is what names the - // parts at the head of the brief the worker opens on, and a drawing with parts - // in it is the one reading that REFUSES a completion claim - // ([Agent.handOverRunningTurn]). rounds := meter.rounds - if mark < checkpointMarks { - // AND AT THE FIRST TWO IT IS STARTED RATHER THAN WAITED FOR, which is this - // file's whole share of loop.go's law. A mark's reading was eight seconds - // of a person's evening on the 2026-09-11 census — measured at 09:29:12, - // with the next step going out two milliseconds after it returned — and - // what it decided on that turn, and on the great majority of turns it is - // ever asked on, was NOTHING: [checkpointSketch.split] is false and the - // only thing that happens is a journal line (the branch below). A reading - // whose usual answer is "carry on" has no business being the reason the - // work stopped to wait. - // - // SO IT RIDES BESIDE THE NEXT STEP AND INTERRUPTS IT WHERE IT MUST. A - // carry-on costs the person nothing at all now; a drawing with independent - // parts in it cuts the step in flight through the same door a person's own - // steer uses (steer.go's [Agent.cutGeneration]) and is spent at the - // boundary that cut opens ([Agent.checkpointSettle]). - aside.start(ctx, a, mark, rounds) + // AND WHERE THIS TURN BEGAN IS TAKEN ONCE, AT ITS FIRST BOUNDARY, because the + // note below reports three figures about ONE answer and two of them are read + // off the transcript ([turnOpenedAt], inherit.go). It is a walk back over one + // round's traffic, done on the first boundary and never again, rather than a + // walk of the session at every mark. + if meter.opened == 0 { + meter.opened = turnOpenedAt(a.snapshot()) + } + // ── THE NOTE RUNGS: THE TURN IS TOLD, AND NOTHING ELSE HAPPENS ── + // + // NOBODY IS ASKED ANYTHING HERE ANY MORE, and that is the 2026-09-11 ruling. + // A mark used to buy a mastermind call — eight seconds of a person's evening + // on the census, measured at 09:29:12 — and hand the decision to a reader that + // had been shown an ACCOUNT of the work rather than the work. On the measured + // failure that reader said `split` about a turn that had read ten files in 48 + // seconds, and the worker it started spent 551,000 tokens reading them again. + // + // THE ONE READER THAT CAN WEIGH THIS IS THE ONE HOLDING THE CONTEXT, because + // the question is not "are there parts" — it is "is it worth this work being + // re-read somewhere else", and only the model holding what was read knows what + // that is worth. So the harness states the facts and the three roads on, ONCE, + // through the same ambient door a loop nudge uses, and the model decides + // (inherit.go's [checkpointChoiceNote]). + // + // IT COSTS NO CALL, NO TIMER AND NO WAIT. The note lands in the next request + // at the boundary the turn was going to reach anyway. + if mark > 0 && mark <= checkpointNotes { + a.enqueueAmbientNote(checkpointChoiceNote(readTurnFacts(a.snapshot(), meter.opened, rounds))) + a.file.appendMark(journalMark{N: mark, Rounds: rounds, Decision: checkpointDecisionTold}) return false } - // THE CEILING IS AN ENDING AND NOT A READING, so it is the one mark still read - // in line. There is no next step to run beside it: the decision to move this - // turn is already taken by the time this branch is reached, and starting one - // more request only to cut it a moment later would be a prefill bought to be - // thrown away. + // ── THE RUNAWAY NET ── // - // AND ITS OWN READ IS JOURNALED AS A CARRY-ON, because that is what it did: it - // decided nothing, and the ceiling line written a moment later is where what - // happened to the turn is recorded. + // The last rung, and it is not a count. A turn reaches it when it can no + // longer work where it is ([Agent.turnHasRunAway]) or when a completion claim + // it made has been disproved by [checkpointPrice] more rounds of real work + // (the meter's own rung, which is what `mark` carries here). The other two + // absolute bounds on a turn are written where they belong and are not repeated + // here: an unattended session's clock is turnwall.go's share, asked above, and + // a turn that has added nothing twice is looped.go's ceiling, which reaches + // [Agent.checkpointCeiling] by its own road. // - // A READING STILL IN FLIGHT FROM THE MARK BELOW IS LET GO OF HERE. Its answer - // could only ever say what this ceiling is already doing. - aside.end() - read := a.readMark(ctx) - a.journalMarkRead(read, mark, rounds, read.sketch.carryOnDecision()) - return a.checkpointCeiling(ctx, hub, turn, started, model, rounds, meter, meter.raced, read, taken) + // AND A FIRING BUYS ITS OWN QUIET, because three of the endings this leads to + // leave the turn running and the condition it fires on stays true forever + // after ([checkpointMeter.netRested]). + if mark < checkpointMarks { + if !meter.netRested() || !a.turnHasRunAway() { + return false + } + meter.outOfRoom = true + } + // THE LATCH IS SET ON EVERY FIRING AND NOT ONLY ON THE NET'S, because what it + // bounds is the reading, and the disproved-claim rung buys exactly the same + // reading. It is set BEFORE the ask so that a reading in flight cannot be + // asked for twice by the boundary it is riding beside. + meter.netFired, meter.netFiredAt = true, rounds + // AND THE DRAWING IS STILL WORTH ITS CALL, because the decision is already + // taken and what the drawing supplies is the CHECKLIST the promoted worker + // opens on. It rides beside the work like every other reading in this build + // (sidecar.go) and cuts the step in flight the instant it lands, so the + // boundary where it can be spent arrives at once ([Agent.checkpointSettle]). + aside.start(ctx, a, checkpointMarks, rounds) + return false } // ── A MARK'S READING, BESIDE THE WORK ─────────────────────────────────────── @@ -2796,14 +2890,16 @@ type markAside struct { // decomposition row loop.go writes. func (m *markAside) everAsked() bool { return m != nil && m.asked } -// start launches one mark's reading beside the work. +// start launches the net's reading beside the work. // -// A DRAWING WITH INDEPENDENT PARTS IN IT CUTS THE STEP IN FLIGHT. That is the -// only thing this reading is allowed to do to the work, and it is the +// IT CUTS THE STEP IN FLIGHT WHATEVER IT DRAWS, which is the one thing that +// changed when the marks stopped deciding. The drawing is no longer the decision +// — the net took that before this was started — so there is nothing for the +// drawing to decline and nothing to wait for the step to finish for. What it +// supplies is the CHECKLIST the promoted worker opens on, and the cut is the // interruption road a person's own steer already uses: the request stops, // whatever was written stays in the transcript, and the loop comes back to a -// boundary where the drawing can be spent ([Agent.checkpointSettle]). A carry-on -// cuts nothing and says nothing. +// boundary where the drawing can be spent ([Agent.checkpointSettle]). // // AND A DRAWING THAT LANDS BETWEEN TWO STEPS IS NOT A DRAWING THAT CUT NOTHING. // The cut is OWED to the next request this turn makes and spent the instant it @@ -2818,11 +2914,7 @@ func (m *markAside) start(ctx context.Context, a *Agent, mark, rounds int) { func(readCtx context.Context) markReading { return markReading{read: a.readMark(readCtx), mark: mark, rounds: rounds} }, - func(landed markReading) { - if landed.read.sketch.split() { - a.cutGeneration(errMarkCut) - } - }) + func(markReading) { a.cutGeneration(errMarkCut) }) } // take answers the reading IF ONE HAS ALREADY LANDED, and never waits. @@ -2874,10 +2966,6 @@ func (a *Agent) checkpointSettle(ctx context.Context, hub *eventHub, turn *Usage // reading that has already been read (steer.go's [Agent.dropOwedCut]). a.dropOwedCut(errMarkCut) read, mark, rounds := landed.read, landed.mark, landed.rounds - if !read.sketch.split() { - a.journalMarkRead(read, mark, rounds, read.sketch.carryOnDecision()) - return false - } // AND A TURN THE PERSON HAS SINCE LET GO OF STARTS NOTHING. The reading was // taken against a turn that was alive; between then and here the person may // have pressed stop, and a handover on a dead turn would put work on the rail @@ -2886,9 +2974,12 @@ func (a *Agent) checkpointSettle(ctx context.Context, hub *eventHub, turn *Usage if ctx.Err() != nil { return false } - a.journalMarkRead(read, mark, rounds, checkpointDecisionSplit) - return a.handOverRunningTurn(ctx, hub, turn, started, model, - checkpointSplitNote, checkpointSeamMark, rounds, meter, meter.raced, read, taken).moved + // AND WHAT LANDS HERE IS ALWAYS THE NET'S, because the net is the only thing + // that starts a reading now. Its own read is journaled as a carry-on because + // that is what it did — it decided nothing — and the ceiling line written a + // moment later is where what happened to the turn is recorded. + a.journalMarkRead(read, mark, rounds, read.sketch.carryOnDecision()) + return a.checkpointCeiling(ctx, hub, turn, started, model, rounds, meter, meter.raced, read, taken) } // checkpoints reports whether this turn may be checkpointed at all. @@ -3988,8 +4079,8 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us // writes no brief because the drawing already is one (checkpoint_quick.go). // // IT IS DECIDED HERE, AFTER EVERY ENDING AND BEFORE EVERY MODEL CALL. After, - // because a turn that is finishing, awaiting or overruled must not be moved - // at all and the kind of node it would have moved to changes none of that. + // because a turn that is finishing, awaiting or overruled must not be moved at + // all and the kind of node it would have moved to changes none of that. // Before, because the two calls below are the whole of what this road exists // to skip: a worktree nobody opens and a ninety-second writer producing a // paragraph the items say better — AND THE NAME, which is asked for under @@ -3999,7 +4090,7 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us // quick carry-on sent a real request to a real model and cancelled it // microseconds later — a call paid for, and an answer nobody was ever going to // read. - if quick := a.quickFromDrawing(read, asked); quick != nil { + if quick := a.promotedFromTurn(read, asked, meter); quick != nil { return a.handOverAsQuick(ctx, hub, turn, started, model, *quick) } // THE NAME IS ASKED FOR HERE, once NO ending above this line can still take @@ -4182,6 +4273,24 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us // for the reason in a ladder that may not even have one. return checkpointHandover{decision: checkpointCeilingHeldWork, reason: carryHeldWork} } + // AND A WORKER THAT COULD NOT BE HANDED THE TURN IS HANDED WHAT THE TURN READ. + // + // THIS IS THE FALLBACK UNDER THE PROMOTION AND IT IS NOT POINTERS. A node that + // cannot inherit the transcript ([Agent.inheritFits] refused it) used to open + // on a brief plus a list of POINTERS to the calls this turn had already made + // ("CALLS THAT HAVE ALREADY RUN", admission.go) — and a pointer is an + // instruction to read it again, which is the 116 KB the measured worker spent + // its first six calls on. The account this file has already compiled for its + // own reader is the same evidence, bounded the same way ([checkpointDigest]: + // each result cut to its tail, newest first, with the count of what was left + // out), and it costs nothing to carry because it exists. + // + // IT RIDES THE GOAL rather than a section of its own, because the goal is the + // one document every door into the graph already carries whole (task_brief.go). + // AND IT RIDES AFTER THE CUSTODY READINGS ABOVE, so a digest full of a + // sibling's name cannot make a handover look like one that assigns work this + // conversation is holding. + verdict.Goal = carriedResults(verdict.Goal, read.digest) verdict = a.handoffChecks(verdict, asked, request, read) // AND THE DRAWING TRAVELS WITH THE WORK, which is the whole of what changed // after the parts stopped being only a paragraph. diff --git a/internal/session/checkpoint_checks_context_test.go b/internal/session/checkpoint_checks_context_test.go index b62e84135b..2b887c3170 100644 --- a/internal/session/checkpoint_checks_context_test.go +++ b/internal/session/checkpoint_checks_context_test.go @@ -49,7 +49,7 @@ func TestHandoffCarriesOnlyCurrentWholeRequestChecks(t *testing.T) { } taken := Decision{Verb: DecideCarryOn, Brief: "finish the repository changes"} over := a.handOverRunningTurn(context.Background(), newEventHub(), &Usage{}, time.Now(), a.model, - checkpointSplitNote, checkpointSeamMark, 0, nil, verdict, read, &taken) + checkpointCeilingNote, checkpointSeamCeiling, 0, nil, verdict, read, &taken) if !over.moved || over.taskID == 0 { t.Fatalf("actual handoff did not create work: %+v", over) } diff --git a/internal/session/checkpoint_quick.go b/internal/session/checkpoint_quick.go index bf318af1d6..58677f7ecc 100644 --- a/internal/session/checkpoint_quick.go +++ b/internal/session/checkpoint_quick.go @@ -58,7 +58,7 @@ import ( // checkpointQuickNote is the ONE line a person reads when a write-free turn's // parts are taken by a quick node, and it stands in for BOTH lines the other -// road writes — the split's own ([checkpointSplitNote]) and the started-task +// road writes — the ceiling's own ([checkpointCeilingNote]) and the started-task // line under it (route_judge.go's [Agent.launchRouteTask]). // // IT IS ONE LINE BECAUSE THERE IS ONE EVENT. The two lines exist on the full @@ -70,13 +70,14 @@ import ( // // IT KEEPS THE REGISTER of the family it joins ([inTheHouseRegister]): an // observation, a middle dot, a promise, lowercase, no full stop, no machinery. -// The observation is the same one the split makes, because the same reader saw -// the same thing. The promise is the honest one for this node and is deliberately -// narrower than the other two: not "watched", not "can split", but WHERE the -// work is happening — here, in this folder — because that is the whole of what -// distinguishes a quick node from the task the person might otherwise assume -// started. -const checkpointQuickNote = "this has parts · a quick task is taking them here, in this folder: " +// The observation is the ceiling's own, because the ceiling is what moved this. +// The promise is the honest one for this node and is deliberately narrower than +// the other two: not "watched", not "can split", but WHERE the work is happening +// and WHAT IT TOOK WITH IT — here, in this folder, holding the transcript — which +// is the whole of what distinguishes a promotion from the task the person might +// otherwise assume started, and the half a person could not otherwise see +// (inherit.go). +const checkpointQuickNote = "this is running long · carrying on here, in this folder, with everything already read: " // checkpointCeilingFanFull is the ending this road takes when a carry-on INSIDE a // task would be one child too many for that task ([TaskGraph.claimChild]). It is @@ -92,41 +93,62 @@ func checkpointQuickLine(title string) string { return checkpointQuickNote + title } -// quickFromDrawing answers the quick node this handover should ask for, or nil -// where this is not that road. It answers an ASK and never a spec: what the -// node is beyond its line and its items is decided by the one door every quick -// node comes through ([Agent.admitQuick]), and this road only says what it -// knows. +// promotedFromTurn answers the quick node this ending should PROMOTE the turn +// into, or nil where this turn cannot be promoted and the brief road below must +// carry it. It answers an ASK and never a spec: what the node is beyond its +// line, its items and `inherit` is decided by the one door every quick node +// comes through ([Agent.admitQuick]), and this road only says what it knows. // -// EVERY CLAUSE IS A FACT THE HARNESS ALREADY HOLDS, and none of them is a reading -// of anybody's words: +// EVERY CLAUSE IS A FACT THE HARNESS ALREADY HOLDS, and none of them is a +// reading of anybody's words: // -// - THE DRAWING HAS PARTS. The same reading that decided to hand the turn over -// at all ([checkpointSketch.split]), asked once so that the two cannot -// disagree. // - THE TURN WROTE NOTHING. [writeMeter.untouched], the write seam's own -// counter, read for zero. A turn with no counter at all answers NO: a -// session that never ran an episode cannot prove it left the disk alone, and -// a doubt is not a proof (writeseam.go says the same of a delivery it cannot -// establish). +// counter, read for zero. It is the one question that decides between a +// worker in a copy of the folder and a worker where the person stands, and +// it is asked in exactly one place in this build — writeseam.go owns the +// isolation question and fires FIRST at every boundary, so a turn that has +// been changing things has already been taken down that road. A turn with no +// counter at all answers NO: a session that never ran an episode cannot prove +// it left the disk alone, and a doubt is not a proof. // - NOTHING OF THIS CONVERSATION'S OWN IS MIXED INTO IT. Where the custody // reduction took parts out of the drawing ([checkpointRead.ownRemainder]) or // had a ledger to take them against ([checkpointRead.held]), the full road's -// two gates stand in front of the handover — the reduced drawing, and the -// bare ask that cannot tell the halves apart (checkpoint_custody.go). This -// road has no ladder for those gates to read, so it declines the shape -// outright and leaves the turn to the road that can weigh it. +// two gates stand in front of the handover (checkpoint_custody.go). This road +// has no ladder for those gates to read, so it declines the shape outright +// and leaves the turn to the road that can weigh it. // - AND THERE IS A SENTENCE TO GIVE IT. The line is the person's own words and // nobody here writes them; a node started on an empty line is a worker // started on a blank page, which is the ending the full road spells // [checkpointCeilingNoBrief]. -func (a *Agent) quickFromDrawing(read checkpointRead, asked string) *quickAsk { - if !read.sketch.split() { +func (a *Agent) promotedFromTurn(read checkpointRead, asked string, meter *checkpointMeter) *quickAsk { + // AND THE ONE RUNG WHOSE MOVE CANNOT BE A PROMOTION IS ASKED FIRST. A turn + // the net took because its context could no longer hold another step would + // hand that same context to a worker on the same model with the same window, + // which is the identical trouble one step later. That reading was taken when + // the net fired and is carried rather than re-taken + // ([checkpointMeter.outOfRoom]). + if meter != nil && meter.outOfRoom { return nil } if !a.turnWroteNothing() { return nil } + // AND WHAT A CONVERSATION DOES TO WORK IT HAS ALREADY HANDED OUT IS NOT WORK + // TO HAND OUT AGAIN. A drawing of "wait for the second | wait for the third" + // is three parts by the separator and no pairs of hands at all: every one of + // them is a verb this conversation owns and a worker cannot do + // ([checkpointSketch.handsBack], [checkpointHandBack]). The gate used to ride + // on [checkpointSketch.split], which asked the same question on the way to a + // different one; the count went with the split and this did not go with it. + // + // IT IS NOT COVERED BY THE WATCHING RULE ABOVE IT. A turn that only looked at + // work already out never climbs a rung at all ([checkpointMeter.round]), but + // a turn that WORKED and then spent its last rounds watching is a turn the + // net can take with a hand-back drawing in its hand, and that is the turn + // this refuses. + if read.sketch.handsBack { + return nil + } if len(read.held) > 0 || strings.TrimSpace(read.ownRemainder) != "" { return nil } @@ -137,20 +159,32 @@ func (a *Agent) quickFromDrawing(read checkpointRead, asked string) *quickAsk { if line == "" { return nil } - items := sketchItems(read.sketch) - // THE PARTS ARE COUNTED AGAIN AFTER THEY ARE READ OUT, because the split was - // decided on the shape and the items are the shape AND the legend together. A - // drawing whose letters nothing could be made of leaves a node with a line - // and no list, which is a quick node that has lost the very thing that made - // this road better than the other one. - if len(items) < checkpointSketchParts { + // AND THE BOUND IS ASKED HERE TOO, though the door asks it again and would + // refuse for itself. The door's refusal is a sentence for a model to read; + // this road has no model to read it, and what it needs to know is whether to + // take the brief road INSTEAD — so it asks the same question, of the same + // model the door will settle on, before it decides which road the turn goes + // down ([Agent.workerModelFor], inherit.go). + if _, fits := a.inheritFits(a.workerModelFor("")); !fits { return nil } // NO FILES ARE CLAIMED, because a turn that wrote nothing has named nothing // it is going to write, and no title is given, because the line is the // person's own sentence and its first line is the row's name on this road as - // on the tool's. - return &quickAsk{line: line, items: items} + // on the tool's. AND `inherit` IS THE WHOLE OF WHAT THIS ROAD ADDS: the node + // that carries a turn on is the node the model could have asked for itself. + // + // AND AN EMPTY CHECKLIST NO LONGER STOPS THE PROMOTION, WHICH IS THE POINT + // OF INHERITING. This road used to refuse a drawing with fewer than + // [checkpointSketchParts] parts in it, and the objection was real while the + // worker opened on a brief: a node with no items and a paragraph about + // somebody else's reading had lost the one thing that made this road better + // than the other one. A promoted node has not. It opens holding the whole + // transcript — every result, verbatim — so the checklist is a convenience on + // top of the work rather than the only description of it, and a reader that + // drew one part has said the remaining work is one job, which is a node with + // one thing to do and not a node with nothing. + return &quickAsk{line: line, items: sketchItems(read.sketch), inherit: true} } // turnWroteNothing reports that this turn has not landed a single write-shaped @@ -233,7 +267,7 @@ func (a *Agent) handOverAsQuick(ctx context.Context, hub *eventHub, turn *Usage, // THE DOOR DECLINED, AND WHICH REFUSAL IT WAS IS WRITTEN DOWN AS ITSELF. // // Two of the door's refusals can reach this road and no more: - // [Agent.quickFromDrawing] hands over neither files nor dependencies nor a + // [Agent.promotedFromTurn] hands over neither files nor dependencies nor a // model, so what is left is a line with nothing in it — which is the full // road's no-brief ending and means the same thing here — and the fan cap. // diff --git a/internal/session/checkpoint_quick_test.go b/internal/session/checkpoint_quick_test.go index 0900f9096a..aecb59e9e0 100644 --- a/internal/session/checkpoint_quick_test.go +++ b/internal/session/checkpoint_quick_test.go @@ -38,7 +38,7 @@ func TestAWriteFreeTurnWithPartsIsTakenByAQuickTask(t *testing.T) { const dowry = "Finish the four pieces\nwhat is left, and everything this turn already found out" path := filepath.Join(t.TempDir(), "session.jsonl") - completer := &scriptedCompleter{steps: grindingSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)} + completer := &scriptedCompleter{steps: loopingGrindSteps(checkpointMarkAt(checkpointMarks), checkpointSplitSketch, dowry)} agent := checkpointAgent(t, completer, func(config *Config) { config.Divide = true config.SessionFile = path @@ -93,7 +93,7 @@ func TestAWriteFreeTurnWithPartsIsTakenByAQuickTask(t *testing.T) { if !saidSomething(said, checkpointQuickNote) { t.Fatalf("the quick road never said its line; notices were %q", said) } - if saidSomething(said, checkpointSplitNote) || saidSomething(said, checkpointCeilingNote) { + if saidSomething(said, checkpointCeilingNote) { t.Errorf("a quick start drew the watched road's line as well; notices were %q", said) } if saidSomething(said, "this looked like work, so task ") { @@ -139,7 +139,7 @@ func TestATurnThatWroteAFileStillTakesTheWatchedRoad(t *testing.T) { const asked = "work through the four things I listed and report back" const dowry = "Finish the four pieces\nwhat is left, and everything this turn already found out" - completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)} + completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, checkpointSplitSketch, dowry)} agent := checkpointWritingAgent(t, completer, func(config *Config) { config.Divide = true }) ran := make(ranNodes, 2) stubbedGraph(agent, func(node *TaskNode) { ran <- node }) @@ -157,7 +157,7 @@ func TestATurnThatWroteAFileStillTakesTheWatchedRoad(t *testing.T) { t.Error("a writing turn's split armed nothing") } said := noticeTexts(collected) - if !saidSomething(said, checkpointSplitNote) { + if !saidSomething(said, checkpointCeilingNote) { t.Fatalf("the watched road never said its line; notices were %q", said) } if saidSomething(said, checkpointQuickNote) { @@ -214,16 +214,19 @@ func TestTheItemsAreEveryLetterOfTheDrawingInOrder(t *testing.T) { // the whole of what tells a quick node from the task a person would otherwise // assume had started somewhere else. func TestTheQuickLineIsTheLineAndCarriesNoMachinery(t *testing.T) { - const want = "this has parts · a quick task is taking them here, in this folder: " + const want = "this is running long · carrying on here, in this folder, with everything already read: " if checkpointQuickNote != want { t.Fatalf("the quick line reads %q, want %q", checkpointQuickNote, want) } inTheHouseRegister(t, checkpointQuickLine("the four pieces")) - if checkpointQuickNote == checkpointSplitNote || checkpointQuickNote == checkpointCeilingNote { + if checkpointQuickNote == checkpointCeilingNote { t.Error("the quick line says what another moment says, and the three have seen different things") } - if strings.Contains(checkpointQuickNote, "running long") { - t.Errorf("the quick line claims the turn has run long: %q", checkpointQuickNote) + // AND IT SAYS WHAT THE OTHER TWO CANNOT: the work went WITH what was already + // read. That half is the whole difference between a promotion and a restart, + // and a person cannot see it anywhere else (inherit.go). + if !strings.Contains(checkpointQuickNote, "already read") { + t.Errorf("the quick line does not say the work took what was read with it: %q", checkpointQuickNote) } } @@ -269,7 +272,7 @@ func TestAQuickNodeFromADrawingTicksItsListRatherThanFaulting(t *testing.T) { const asked = "work through the four things I listed and report back" const dowry = "Finish the four pieces\nwhat is left, and everything this turn already found out" - completer := &scriptedCompleter{steps: grindingSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)} + completer := &scriptedCompleter{steps: loopingGrindSteps(checkpointMarkAt(checkpointMarks), checkpointSplitSketch, dowry)} agent := checkpointAgent(t, completer, func(config *Config) { config.Divide = true }) ran := make(ranNodes, 2) graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node }) @@ -365,8 +368,10 @@ func TestAQuickCarryOnAsksForNoNameAndTheFullRoadStillDoes(t *testing.T) { return completer, ran.await(t), &names } - // THE QUICK ROAD: a turn that wrote nothing, whose drawing has parts. - _, quick, quickNames := namesAsked(t, grindingSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)) + // THE QUICK ROAD: a turn that wrote nothing and is going in circles, which is + // the shape the net takes a turn away from now — a count of rounds moves + // nothing (inherit.go, [loopingGrindSteps]). + _, quick, quickNames := namesAsked(t, loopingGrindSteps(checkpointMarkAt(checkpointMarks), checkpointSplitSketch, dowry)) if quick.spec.quick == nil { t.Fatal("the write-free turn was not handed to a quick node") } @@ -381,7 +386,8 @@ func TestAQuickCarryOnAsksForNoNameAndTheFullRoadStillDoes(t *testing.T) { // THE FULL ROAD: the same drawing out of a turn that wrote one file, which is // the one turn the watched road still takes ([writingGrindSteps]). - _, full, fullNames := namesAsked(t, writingGrindSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)) + _, full, fullNames := namesAsked(t, writingGrindSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, + checkpointSplitSketch, dowry)) if full.spec.quick != nil { t.Fatal("a turn that wrote was handed to a quick node") } @@ -430,3 +436,38 @@ func TestAFullFanIsWrittenDownAsAFullFanAndNotAsNoBrief(t *testing.T) { empty.moved, empty.decision, checkpointCeilingNoBrief) } } + +// loopingGrindSteps is a turn that GOES IN CIRCLES: the same failing command, +// round after round, until looped.go's watch has spent its two notes and the +// third signal ends the turn through the ceiling ([Agent.handOverLoopingTurn]). +// +// IT IS THE RUNAWAY THE PROMOTION ROAD IS FOR, and the fixture says so by being +// the shape it is. The net has three rungs and only two of them can promote: a +// turn whose CONTEXT has run out cannot hand that context to a worker on the +// same model with the same window, so its move is the brief road with the +// results compiled into it ([checkpointMeter.outOfRoom]). A turn that is looping +// has a context that is perfectly fine and work that is not moving, which is +// exactly the turn worth carrying on somewhere else with everything it read. +func loopingGrindSteps(count int, sketch, brief string) []step { + steps := make([]step, count) + for index := range steps { + round := index + steps[index] = func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + if askedForSketch(messages) { + return textResponse(sketch), nil + } + if askedForHandoff(messages) { + return textResponse(brief), nil + } + if askedToWriteHandoff(messages) { + return toolResponse("no-writer", "ls", `{"path":"."}`), nil + } + if askedForRemains(messages) { + return textResponse(checkpointNothingLeft), nil + } + return toolResponseWithText(fmt.Sprintf("spin-%d", round), "bash", + `{"command":"exit 7"}`, "trying the build again."), nil + } + } + return steps +} diff --git a/internal/session/checkpoint_test.go b/internal/session/checkpoint_test.go index 0766943d26..da2dbe3e04 100644 --- a/internal/session/checkpoint_test.go +++ b/internal/session/checkpoint_test.go @@ -48,11 +48,14 @@ import ( func TestTheMeterFiresOnlyAtTheGeometricMarks(t *testing.T) { want := map[int]int{} at := checkpointPrice - for mark := 1; mark <= checkpointMarks; mark++ { + // THE LADDER CLIMBS AS FAR AS THE NOTES AND NO FURTHER. The rung above them is + // the ceiling, and a count of finished rounds says nothing about whether a + // turn has run away ([checkpointNotes], inherit.go's [Agent.turnHasRunAway]). + for mark := 1; mark <= checkpointNotes; mark++ { want[at] = mark at *= checkpointRatio } - if len(want) != checkpointMarks { + if len(want) != checkpointNotes { t.Fatalf("two marks share a round, so the ladder is not geometric: %v", want) } @@ -75,8 +78,8 @@ func TestTheMeterFiresOnlyAtTheGeometricMarks(t *testing.T) { if meter.rounds != last { t.Errorf("the meter counted %d rounds of %d", meter.rounds, last) } - if meter.marks != checkpointMarks { - t.Errorf("the meter climbed to %d of %d marks", meter.marks, checkpointMarks) + if meter.marks != checkpointNotes { + t.Errorf("the meter climbed to %d of %d note rungs", meter.marks, checkpointNotes) } } @@ -117,16 +120,15 @@ func TestATightenedMeterMovesOnlyTheFirstMark(t *testing.T) { t.Fatalf("round %d gave mark %d, want the second mark at the ordinary rung", checkpointMarkAt(2), mark) } - for round := checkpointMarkAt(2) + 1; round < checkpointMarkAt(checkpointMarks); round++ { + // AND NOTHING STANDS ABOVE THE SECOND NOTE. The rung where the ceiling used to + // be is gone: a turn is taken out of a person's hands when it can no longer + // work where it is, never for having finished a number of rounds. + for round := checkpointMarkAt(2) + 1; round <= checkpointMarkAt(checkpointMarks)*checkpointRatio; round++ { if mark := meter.round(true); mark != 0 { - t.Fatalf("round %d fired mark %d; the ceiling stands at %d", - round, mark, checkpointMarkAt(checkpointMarks)) + t.Fatalf("round %d fired mark %d; the ladder stops at the %d notes", + round, mark, checkpointNotes) } } - if mark := meter.round(true); mark != checkpointMarks { - t.Fatalf("the ceiling gave mark %d at round %d, want %d", - mark, checkpointMarkAt(checkpointMarks), checkpointMarks) - } } // AND A YES THAT LANDS AFTER THE FIRST MARK HAS NOTHING LEFT TO TIGHTEN — but its @@ -384,27 +386,6 @@ func TestTheCeilingLineIsTheLineAndCarriesNoMachinery(t *testing.T) { inTheHouseRegister(t, checkpointCeilingNote) } -// AND THE SPLIT'S LINE, WHICH IS THE THIRD IN THE FAMILY AND SAYS SOMETHING THE -// OTHER TWO CANNOT. -// -// The ceiling has watched an answer outrun its own price. This has had somebody -// read the work and draw independent parts out of it, and at the first mark the -// turn may be no time at all — so a line claiming it had run long would be a lie, -// exactly as the race's old line claiming it four seconds in was. -func TestTheSplitLineIsTheLineAndCarriesNoMachinery(t *testing.T) { - const want = "this has parts · handing it to a task that can take them side by side" - if checkpointSplitNote != want { - t.Fatalf("the split line reads %q, want %q", checkpointSplitNote, want) - } - inTheHouseRegister(t, checkpointSplitNote) - if checkpointSplitNote == checkpointCeilingNote || checkpointSplitNote == taskEscalationNote { - t.Error("the split says what another moment says, and the three have seen different things") - } - if strings.Contains(checkpointSplitNote, "running long") { - t.Errorf("the split claims the turn has run long: %q", checkpointSplitNote) - } -} - // inTheHouseRegister is the shape every dim one-liner the harness writes over // somebody's turn is held to: an observation, a middle dot, a promise — one line, // lowercase, no full stop, no machinery vocabulary (internal/tui3's taskWideNote, @@ -452,11 +433,35 @@ const ( ) // checkpointSlack is how many of a script's steps a grinding turn spends on -// things that are not tool rounds: one read per mark, the dowry DRAFT at the end, -// and the mastermind that writes the brief out of it. A script cut to the round -// count alone runs out under the sidecar, and the scripted completer's -// past-the-end answer would then be read as a sketch and as a brief. -const checkpointSlack = checkpointMarks + 3 +// things that are not tool rounds: the ceiling's one read, the dowry DRAFT at the +// end, the mastermind that writes the brief out of it, the namer — and THE STEPS +// THAT RIDE BESIDE THE READING. That last one is the sidecar's own arithmetic +// (sidecar.go): the drawing is started at the boundary that crosses the net and +// spent at the boundary after it LANDS, so a turn whose rounds cost nothing — +// which is every scripted turn — takes a few more of them in the gap. A script +// cut to the round count alone runs out under the sidecar, and the scripted +// completer's past-the-end answer would then be read as a sketch and as a brief. +// +// AND IT IS A PRICE'S WORTH BECAUSE THE GAP IS SCHEDULER-DEPENDENT. How many +// rounds pass between the net firing and its drawing landing is not fixed — +// nothing about the road promises a number — so a slack tight enough to fail +// once in four runs is a test asserting the scheduler. [checkpointPrice] is the +// unit this package already uses for "a turn's worth of work", and it is more +// than the gap has ever been measured at. +const checkpointSlack = checkpointPrice + checkpointMarks + +// checkpointClaimSlack is the slack for the fixtures whose turn ANSWERED THE +// HANDOVER BY SAYING IT WAS FINISHED and then carried on working. +// +// A believed claim is charged once and disproved by [checkpointPrice] more +// rounds of real work ([checkpointMeter.believeDone]), so a script that grinds +// longer than that price stops testing the ending it is about — the ceiling +// dropped over a turn nothing was left of — and starts testing the claim rung, +// which is a different law with its own test +// ([TestTheRunningModelsSayS0IsBelievedOnceAndThenMet]). A round short of +// the price is the whole of the reason for this number, and it is still longer +// than the sidecar gap [checkpointSlack] exists for has ever been measured at. +const checkpointClaimSlack = checkpointPrice - 1 // answerTheReadingsOffTheQueue installs the aside every handover fixture needs // ([scriptedCompleter.aside]). @@ -646,8 +651,17 @@ func grindingSteps(count int, sketch, brief string) []step { // Visible progress keeps this fixture about checkpoint pricing rather // than the independent silent-turn ladder, which now ends a turn at its // third warning before the ordinary forty-round ceiling. - return toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", string(arguments), - "Working through the next path."), nil + // + // AND THE FIXTURE FILLS THE WINDOW AS IT GOES, because that is what the + // runaway net actually reads (inherit.go's [Agent.turnHasRunAway]): a + // turn is no longer taken away from a person for having finished forty + // rounds, it is taken away when its context can no longer hold another + // step. Scripting the provider's own prompt count is how a fixture says + // that without writing a hundred kilobytes of tool results — see + // [checkpointFillAt], which crosses the line on exactly the round the + // ceiling used to stand on. + return filling(toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", string(arguments), + "Working through the next path."), round), nil } } return steps @@ -813,181 +827,165 @@ func putTestCommandOnPath(t *testing.T, name string) { t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) } -// ── the running model never sees any of it ────────────────────────────────── +// ── the note rungs: the turn is told, and nobody is asked ─────────────────── -// THE MODEL ANSWERING THE TURN IS NEVER SENT THE CHECKPOINT. +// THE FIRST TWO RUNGS TELL THE RUNNING TURN AND BUY NO READING AT ALL. // -// This is the whole shape of the wave in one assertion. The question used to ride -// the ambient note lane INTO the running turn, and it was ignored — answered with -// a tool call instead of an answer — on between 17% and 53% of measured turns, -// depending on the phrasing. So it is asked beside the turn now, of a different -// model, and nothing about it reaches the conversation: no note in the transcript, -// no line in any request the session model was sent. -func TestTheRunningModelIsNeverSentTheCheckpoint(t *testing.T) { - rounds := checkpointMarkAt(checkpointMarks) - completer := &scriptedCompleter{steps: grindingSteps(rounds+checkpointSlack, checkpointChainSketch, "Finish it\nwhat is left over")} +// This is the whole shape of the 2026-09-11 wave in one assertion. The question +// used to be put to a SECOND MODEL shown an account of the work, which then +// decided whether the model holding the work should stop doing it — and on the +// measured failure it said `split` about a turn that had read ten files in 48 +// seconds. Now the harness states the facts and the roads, once, into the turn +// itself, and the model that is holding what was read decides (inherit.go). +func TestTheNoteRungsTellTheTurnAndAskNobodyAnything(t *testing.T) { + // Every round up to one short of the second rung, then an answer: the first + // note fires, the second does not, and the turn finishes on its own. + rounds := checkpointMarkAt(2) - 1 + steps := append(grindingSteps(rounds, checkpointSplitSketch, ""), finalAnswer("here is what I found")) + completer := &scriptedCompleter{steps: steps} agent := checkpointAgent(t, completer) - ran := make(ranNodes, 2) - stubbedGraph(agent, func(node *TaskNode) { ran <- node }) + graph := stubbedGraph(agent, func(node *TaskNode) {}) events, err := agent.Submit(context.Background(), "work through the four things I listed and report back") if err != nil { t.Fatalf("Submit: %v", err) } collect(t, events) - ran.await(t) - // NOT IN THE TRANSCRIPT. A note here would ride into the next request by - // itself, which is exactly how it used to reach the model. + // NOBODY WAS ASKED ANYTHING. Not once, over four times the price of a + // handover, with a drawing waiting in the script that would have said `split`. + if read := marksRead(completer); read != 0 { + t.Fatalf("the sidecar was asked %d times over %d rounds; the note rungs read nothing", + read, rounds) + } + // AND NOTHING WAS MOVED. + if count := admitted(graph); count != 0 { + t.Fatalf("%d tasks were admitted by a note rung; a note moves nothing", count) + } + + // THE NOTE IS IN THE TRANSCRIPT, ONCE, IN THE HARNESS'S OWN VOICE. agent.mu.Lock() messages := append([]ai.Message(nil), agent.messages...) agent.mu.Unlock() + notes := 0 for _, message := range messages { - if strings.Contains(messageText(message), "[checkpoint]") { - t.Fatalf("the checkpoint is in the transcript as a %s: %q", message.Role, messageText(message)) + if strings.Contains(messageText(message), checkpointChoiceLead) { + notes++ + if message.Role != "user" { + t.Errorf("the note rode in as a %s; harness notes are user-role text", message.Role) + } + if !strings.Contains(messageText(message), checkpointChoiceRule) { + t.Errorf("the note carried no roads: %q", messageText(message)) + } + if !strings.Contains(messageText(message), "rounds so far") { + t.Errorf("the note carried none of the turn's own figures: %q", messageText(message)) + } } } - // AND NOT IN ANY REQUEST THE CONVERSATION'S OWN MODEL WAS SENT. The sidecar's - // own request carries it, and the sidecar rides the mastermind — which is the - // only honest way to tell the two apart, because both go down one client. + if notes != 1 { + t.Fatalf("%d notes reached the turn over %d rounds; one rung stands at %d and the next at %d", + notes, rounds, checkpointMarkAt(1), checkpointMarkAt(2)) + } + // AND IT REACHED THE MODEL, which is the only thing that makes it a note + // rather than a line in a file: it is in a request the conversation's own + // model was actually sent. + carried := false for index := range completer.requests() { if completer.model(index) == checkpointMarkModel { continue } for _, message := range completer.request(index) { - if strings.Contains(messageText(message), "[checkpoint]") { - t.Fatalf("request %d, on model %q, carried the checkpoint: %q", - index, completer.model(index), messageText(message)) + if strings.Contains(messageText(message), checkpointChoiceRule) { + carried = true } } } - // AND THE SIDECAR WAS ASKED, so this is a test about where the question went - // rather than about a question nobody asked. - if read := marksRead(completer); read != checkpointMarks { - t.Fatalf("the sidecar was asked %d times over %d rounds, want one per mark (%d)", - read, rounds, checkpointMarks) + if !carried { + t.Error("the note never reached a request the conversation's own model was sent") } - for index := range completer.requests() { - if askedForSketch(completer.request(index)) && completer.model(index) != checkpointMarkModel { - t.Errorf("a mark was read by %q, want the mastermind %q", - completer.model(index), checkpointMarkModel) - } + // AND THE TURN ANSWERED, which is the point: it was told, not interrupted. + if last := lastMessage(agent); last.Role != "assistant" || + !strings.Contains(messageText(last), "here is what I found") { + t.Errorf("the turn did not finish its own answer; it ends with a %s saying %q", + last.Role, messageText(last)) } } -// AND THE SIDECAR IS ASKED AT THE MARKS AND NOWHERE ELSE. +// AND A PRODUCTIVE READ-ONLY TURN IS NEVER MOVED — the regression for the +// measured failure of 2026-09-11. // -// The cost of this mechanism on an ordinary turn is zero calls, and on a grinding -// one it is bounded at three — one per mark, on a turn that has already spent ten -// rounds of tool calls. -func TestTheSidecarIsAskedOncePerMarkAndNotBetween(t *testing.T) { - // Every round up to one short of the second mark: the first mark fires, the - // second does not. - rounds := checkpointMarkAt(2) - 1 - // The drawing is answered off the queue and spends no step - // ([answerTheReadingsOffTheQueue]), so the script is exactly the rounds. - steps := append(grindingSteps(rounds, checkpointChainSketch, ""), finalAnswer("done")) +// Ten rounds, ten files, nothing written, a drawing in the script that says the +// work has four independent parts. Under the old ladder that was a handover at +// round ten and a worker that re-read 116 KB of results. Under this one it is a +// note, and the answer finishes where it is. +func TestATenRoundReadingTurnIsToldAndNotMoved(t *testing.T) { + rounds := checkpointMarkAt(1) + steps := append(grindingSteps(rounds, checkpointSplitSketch, ""), finalAnswer("the four panels draw from home.go")) completer := &scriptedCompleter{steps: steps} - agent := checkpointAgent(t, completer) - stubbedGraph(agent, func(node *TaskNode) {}) + agent := checkpointAgent(t, completer, func(config *Config) { config.Divide = true }) + graph := stubbedGraph(agent, func(node *TaskNode) {}) - events, err := agent.Submit(context.Background(), "work through the four things I listed and report back") + events, err := agent.Submit(context.Background(), "how do the home panels get their data") if err != nil { t.Fatalf("Submit: %v", err) } - collect(t, events) + collected := collect(t, events) - if read := marksRead(completer); read != 1 { - t.Fatalf("the sidecar was asked %d times over %d rounds; one mark stands at %d and the next "+ - "at %d, so exactly one belongs here", read, rounds, checkpointMarkAt(1), checkpointMarkAt(2)) + if count := admitted(graph); count != 0 { + t.Fatalf("%d tasks were started out of a ten-round reading turn", count) + } + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || + saidSomething(said, checkpointQuickNote) { + t.Fatalf("a reading turn was moved; notices were %q", said) + } + if read := marksRead(completer); read != 0 { + t.Errorf("a reading turn paid for %d readings", read) + } + if last := lastMessage(agent); last.Role != "assistant" || + !strings.Contains(messageText(last), "the four panels draw from home.go") { + t.Errorf("the turn did not answer; it ends with a %s saying %q", last.Role, messageText(last)) } } -// ── a mark that says the work has parts ───────────────────────────────────── +// ── a drawing with parts in it no longer moves a turn ─────────────────────── -// A SKETCH WITH PARTS IN IT HANDS THE TURN OVER, AT THE FIRST MARK. +// A SKETCH WITH PARTS IN IT MOVES NOTHING AT A MARK. // -// The one assertion the whole wave turns on. What the person reads is the split's -// own line; what the worker opens on is the parts, named, above everything the -// turn found out; and the road is armed BY A JUDGEMENT — a mastermind read this -// transcript and drew independent parts out of it, which is what lets the division -// reviewer adjudicate a floor refusal later (task_divide.go). -func TestASplitSketchAtTheFirstMarkHandsTheTurnOver(t *testing.T) { +// This was the assertion the previous wave turned on and it is exactly the thing +// that was wrong: a second model shown an ACCOUNT of the work decided that the +// model holding the work should stop. It is deleted rather than weakened. What a +// drawing is FOR now is the checklist the promoted work carries, and it is read +// once, at the ceiling, after the decision to move has already been taken +// (inherit.go). +func TestADrawingWithPartsNoLongerMovesATurnAtAMark(t *testing.T) { const asked = "work through the four things I listed and report back" const dowry = "Finish the four pieces\nwhat is left, and everything this turn already found out" - // Well past the first mark and one short of the second, so what fires here can - // only be the first and the script cannot run out from under a drawing that - // lands a round or two late on a loaded machine. - completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(2)-1, checkpointSplitSketch, dowry)} + // Well past the first rung and one short of the second, with a drawing in the + // script that says the work has three independent parts. + rounds := checkpointMarkAt(2) - 1 + steps := append(writingGrindSteps(rounds, checkpointSplitSketch, dowry), finalAnswer("all four are done")) + completer := &scriptedCompleter{steps: steps} agent := checkpointWritingAgent(t, completer, func(config *Config) { config.Divide = true }) - ran := make(ranNodes, 2) - graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node }) + graph := stubbedGraph(agent, func(node *TaskNode) {}) events, err := agent.Submit(context.Background(), asked) if err != nil { t.Fatalf("Submit: %v", err) } collected := collect(t, events) - node := ran.await(t) - // EXACTLY ONE TASK, on the one road. - if count := admitted(graph); count != 1 { - t.Fatalf("%d tasks were admitted at the first mark, want exactly one", count) - } - // AND IT WAS THE FIRST MARK THAT MOVED IT, which is what the request count is - // here to say: the turn never reached the SECOND rung, so the handover above - // belongs to the first one and to nothing else. - // - // THE BOUND IS THE NEXT RUNG AND NOT A HANDFUL OF CALLS, and that is a - // consequence of this wave rather than slack for its own sake. The drawing is - // no longer a wait in front of the work (checkpoint.go's [markAside]): it is - // started at the boundary that crosses the mark and spent at the next boundary - // it has landed by, so how many steps a turn rides between the two is a fact - // about the machine the test is running on. Counting them would be asserting - // the scheduler. What does not vary is the rung. - if completer.requests() >= checkpointMarkAt(2) { - t.Errorf("the turn made %d requests and reached the second mark at %d; the handover "+ - "was supposed to be the first one, standing at %d", - completer.requests(), checkpointMarkAt(2), checkpointMarkAt(1)) - } - // THE LINE, EXACTLY. - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { - t.Fatalf("the split never said its line; notices were %q", noticeTexts(collected)) - } - if saidSomething(noticeTexts(collected), checkpointCeilingNote) { - t.Error("a split at the first mark drew the ceiling's line") - } - // THE PERSON'S ASK RIDES IT VERBATIM. - if node.spec.request != asked { - t.Errorf("the task's request is %q, want the person's own words %q", node.spec.request, asked) - } - // THE SKETCH IS AT THE HEAD OF THE BRIEF, so the worker's divide_work has the - // parts already named — and the dowry is still under it. - if !strings.HasPrefix(node.spec.brief, "WHAT IS LEFT, AS PARTS: A | B | C") { - t.Errorf("the brief does not open on the parts the sidecar drew:\n%s", node.spec.brief) - } - if !strings.Contains(node.spec.brief, "the validation workflow") { - t.Errorf("the legend did not reach the worker:\n%s", node.spec.brief) - } - if !strings.Contains(node.spec.brief, "everything this turn already found out") { - t.Errorf("the task lost what the turn learned:\n%s", node.spec.brief) - } - // ARMED, AND ARMED BY THE READER THAT READ THE WORK. The word matters: only a - // model's own reading lets the reviewer reconsider a below-floor division - // ([TaskNode.armedByJudgement]). - if got := node.armedBy(); got != armedJudged { - t.Errorf("the task was armed by %q, want the mark reader's judgement (%q)", got, armedJudged) + if count := admitted(graph); count != 0 { + t.Fatalf("%d tasks were admitted on a drawing at a mark; a mark moves nothing", count) } - if !node.dividing() { - t.Error("a sketch that named three parts armed nothing") + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || + saidSomething(said, checkpointQuickNote) { + t.Fatalf("the turn was moved; notices were %q", said) } - // THE TURN IS OVER, and the transcript is not left with a question nobody - // answered — the next turn would open on it and answer it. - if last := lastMessage(agent); last.Role != "assistant" || - !strings.Contains(messageText(last), checkpointSplitNote) { - t.Errorf("the turn did not end on its own line; the transcript ends with a %s saying %q", - last.Role, messageText(last)) + // AND NOBODY WAS ASKED TO DRAW ANYTHING, which is the saving: the drawing in + // the script was never bought. + if read := marksRead(completer); read != 0 { + t.Errorf("the sidecar was asked %d times under the note rungs", read) } } @@ -1014,8 +1012,8 @@ func TestASketchSayingOneJobLetsTheTurnRunOn(t *testing.T) { if count := admitted(graph); count != 0 { t.Fatalf("%d tasks were started off a sketch that said the work was one job", count) } - if said := noticeTexts(collected); saidSomething(said, checkpointSplitNote) || - saidSomething(said, checkpointCeilingNote) { + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || + saidSomething(said, checkpointQuickNote) { t.Fatalf("a carry-on said something to the person: %q", said) } // AND THE DOWRY WAS NEVER ASKED FOR, which is what makes this free: a carry-on @@ -1023,10 +1021,10 @@ func TestASketchSayingOneJobLetsTheTurnRunOn(t *testing.T) { if asks := handoffAsks(completer); asks != 0 { t.Errorf("a carry-on asked for the dowry %d times", asks) } - // BOTH MARKS WERE READ, so this is two carry-ons rather than a mechanism that - // never fired. - if read := marksRead(completer); read != 2 { - t.Errorf("the sidecar was asked %d times over %d rounds, want both marks", read, rounds) + // AND NEITHER RUNG READ ANYBODY. Both notes fired over these rounds and both + // cost the turn one sentence and no model call (inherit.go). + if read := marksRead(completer); read != 0 { + t.Errorf("the sidecar was asked %d times over %d rounds of note rungs", read, rounds) } // AND THE TURN'S OWN ANSWER STANDS. if last := lastMessage(agent); last.Role != "assistant" || !strings.Contains(messageText(last), answered) { @@ -1065,9 +1063,10 @@ func TestASidecarThatCannotBeReachedCarriesOnAndTheCeilingStillFires(t *testing. collected := collect(t, events) node := ran.await(t) - // NOTHING MOVED AT THE FIRST TWO MARKS. - if saidSomething(noticeTexts(collected), checkpointSplitNote) { - t.Error("a reader that faulted still split the turn") + // NOTHING MOVED AT THE TWO NOTE RUNGS — they read nobody, so a reader that + // faulted could not have moved anything there in any case. + if saidSomething(noticeTexts(collected), checkpointQuickNote) { + t.Error("a reader that faulted still moved the turn before the net") } // AND THE CEILING MOVED IT ANYWAY, which is the whole of why the fail-open is // safe. @@ -1270,16 +1269,23 @@ func TestAtTheCeilingTheTurnEndsAndTheWorkMovesToOneWatchedTask(t *testing.T) { t.Errorf("the turn did not end on its own line; the transcript ends with a %s saying %q", last.Role, messageText(last)) } - // AND IT STOPPED WHERE IT SAID IT WOULD. What stands past the ceiling is the - // last mark's read, the handoff brief and the errand that names the session - // (title.go), and none of those is another tool round. - if completer.requests() > rounds+checkpointSlack { - t.Errorf("the turn made %d requests past a ceiling standing at %d rounds", completer.requests(), rounds) - } - // THE WORK WAS READ TWICE BEFORE THE HARNESS DECIDED, AND ONCE MORE TO BRIEF - // THE WORKER. - if read := marksRead(completer); read != checkpointMarks { - t.Errorf("the sidecar was asked %d times, want one read per mark (%d)", read, checkpointMarks) + // AND IT STOPPED WHERE IT SAID IT WOULD — bounded by the next doubling rather + // than by a handful of calls, for the reason every assertion about this road + // now is: the drawing rides BESIDE the work (sidecar.go), so how many steps a + // turn takes between the net firing and the drawing landing is a fact about + // the machine the test runs on. Counting them would be asserting the + // scheduler. What does not vary is that the turn ended nowhere near twice the + // round the net fired on. + if completer.requests() >= rounds*checkpointRatio { + t.Errorf("the turn made %d requests past a net that fired at %d rounds", completer.requests(), rounds) + } + // AND THE WORK WAS READ EXACTLY ONCE, AT THE CEILING. The two rungs under it + // buy no reading at all now: they tell the turn what it has spent and leave + // the deciding to the model holding the work (inherit.go), so the whole + // mechanism costs one mastermind call on the turns that reach the net and + // nothing whatever on the turns that do not. + if read := marksRead(completer); read != 1 { + t.Errorf("the sidecar was asked %d times, want the ceiling's one reading", read) } } @@ -1307,10 +1313,10 @@ func TestTheCeilingCarriesTheLastSketchIntoTheBrief(t *testing.T) { collected := collect(t, events) node := ran.await(t) - // A split fired at the FIRST mark here, so this is the same road arriving - // early — which is the honest thing to assert about a sidecar that saw parts. - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { - t.Fatalf("a sketch with parts in it never split the turn; notices were %q", noticeTexts(collected)) + // THE CEILING IS WHAT MOVED IT — a drawing with parts in it no longer moves a + // turn on its own, and the parts are still what head the brief. + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { + t.Fatalf("the ceiling never moved the turn; notices were %q", noticeTexts(collected)) } if !strings.HasPrefix(node.spec.brief, "WHAT IS LEFT, AS PARTS: A | B | C") { t.Errorf("the brief does not open on the parts:\n%s", node.spec.brief) @@ -1341,8 +1347,8 @@ func TestTheCeilingHandsOverEvenWhenNobodyCanWriteTheBrief(t *testing.T) { if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were admitted, want exactly one", count) } - if node.spec.brief != asked { - t.Errorf("with no brief written the task should run on the person's own words; it has %q", + if !strings.HasPrefix(node.spec.brief, asked) { + t.Errorf("with no brief written the task should open on the person's own words; it has %q", node.spec.brief) } } @@ -1429,7 +1435,7 @@ func TestADowryOfMachineMarkupIsRefusedAndNeverBecomesTheName(t *testing.T) { if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were admitted, want exactly one", count) } - if node.spec.brief != asked { + if !strings.HasPrefix(node.spec.brief, asked) { t.Errorf("the task runs on %q; a brief that is not prose falls back to the person's words %q", node.spec.brief, asked) } @@ -1479,7 +1485,7 @@ func TestAContinuationSayingNothingIsLeftDropsTheCeilingHandover(t *testing.T) { const answered = "all eight files are written and the smoke check passed" rounds := checkpointMarkAt(checkpointMarks) - steps := append(grindingSteps(rounds+checkpointSlack, checkpointDoneSketch, checkpointNothingLeft), finalAnswer(answered)) + steps := append(grindingSteps(rounds+checkpointClaimSlack, checkpointDoneSketch, checkpointNothingLeft), finalAnswer(answered)) completer := &scriptedCompleter{steps: steps} agent := checkpointAgent(t, completer) graph := stubbedGraph(agent, func(node *TaskNode) { @@ -1634,7 +1640,7 @@ func TestTheTurnsThatMustNeverBeCheckpointedAreNot(t *testing.T) { t.Errorf("%s paid for %d mark readings", shape.name, read) } if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || - saidSomething(said, checkpointSplitNote) { + saidSomething(said, checkpointQuickNote) { t.Errorf("%s was moved off its own turn: %q", shape.name, said) } if count := admitted(graph); count != 0 { @@ -1692,7 +1698,7 @@ func TestAnInterruptedTurnIsNotCheckpointed(t *testing.T) { collected := collect(t, events) if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || - saidSomething(said, checkpointSplitNote) { + saidSomething(said, checkpointQuickNote) { t.Error("an interrupted turn was moved onto the rail") } if count := admitted(graph); count != 0 { @@ -1975,24 +1981,40 @@ func TestEveryMarkReadIsJournaledWithItsDecisionAndItsCost(t *testing.T) { if len(marks) != checkpointMarks { t.Fatalf("%d marks were journaled over a turn that crossed %d", len(marks), checkpointMarks) } - for index, mark := range marks { + // THE NOTE RUNGS COST NOTHING AND SAY SO. `told` is what they did — the turn + // was handed its own figures and the three roads on — and the emptiness law + // does the rest: no model, no cost, no sketch, because there was no call. + for index, mark := range marks[:checkpointNotes] { if mark.N != index+1 { t.Errorf("mark %d is journaled as rung %d", index+1, mark.N) } if mark.Rounds != checkpointMarkAt(index+1) { t.Errorf("rung %d fired at round %d, want %d", mark.N, mark.Rounds, checkpointMarkAt(index+1)) } - if mark.Decision != checkpointDecisionContinue { - t.Errorf("rung %d decided %q, want %q", mark.N, mark.Decision, checkpointDecisionContinue) - } - if mark.Sketch != "A > B > C" { - t.Errorf("rung %d journaled the sketch as %q", mark.N, mark.Sketch) + if mark.Decision != checkpointDecisionTold { + t.Errorf("rung %d decided %q, want %q", mark.N, mark.Decision, checkpointDecisionTold) } - if mark.Model != checkpointMarkModel { - t.Errorf("rung %d was journaled against %q, want the mastermind %q", - mark.N, mark.Model, checkpointMarkModel) + if mark.Sketch != "" || mark.Model != "" || mark.CostUSD != 0 { + t.Errorf("rung %d journaled a reading (%q, %q, %v) for a rung that asks nobody anything", + mark.N, mark.Sketch, mark.Model, mark.CostUSD) } } + // AND THE CEILING'S OWN READING IS THE ONE THAT IS PAID FOR. + last := marks[checkpointMarks-1] + if last.N != checkpointMarks || last.Rounds != rounds { + t.Errorf("the ceiling's reading is journaled as rung %d at round %d, want %d at %d", + last.N, last.Rounds, checkpointMarks, rounds) + } + if last.Decision != checkpointDecisionContinue { + t.Errorf("the ceiling's reading decided %q, want %q", last.Decision, checkpointDecisionContinue) + } + if last.Sketch != "A > B > C" { + t.Errorf("the ceiling journaled the sketch as %q", last.Sketch) + } + if last.Model != checkpointMarkModel { + t.Errorf("the ceiling's reading was journaled against %q, want the mastermind %q", + last.Model, checkpointMarkModel) + } // AND THE CEILING SAYS WHAT IT DID WITH THE TURN, with the node that took it. ceilings := journaledCeilings(t, path) if len(ceilings) != 1 { @@ -2015,8 +2037,8 @@ func TestEveryMarkReadIsJournaledWithItsDecisionAndItsCost(t *testing.T) { reads++ } } - if reads != checkpointMarks { - t.Errorf("%d call lines name the mark reader, want one per mark (%d)", reads, checkpointMarks) + if reads != 1 { + t.Errorf("%d call lines name the mark reader, want the ceiling's one reading", reads) } } @@ -2054,10 +2076,15 @@ func TestAMarkNobodyCouldReadIsJournaledAsAFailure(t *testing.T) { if len(marks) != checkpointMarks { t.Fatalf("%d marks were journaled, want one per rung", len(marks)) } - for _, mark := range marks { - if mark.Decision != checkpointDecisionFailed { - t.Errorf("rung %d journaled %q for a reader nobody could reach, want %q", - mark.N, mark.Decision, checkpointDecisionFailed) + // ONLY THE CEILING READS ANYTHING, so only the ceiling's rung can record a + // reader that was not there. The two under it told the turn and asked nobody. + if got := marks[checkpointMarks-1].Decision; got != checkpointDecisionFailed { + t.Errorf("the ceiling journaled %q for a reader nobody could reach, want %q", + got, checkpointDecisionFailed) + } + for _, mark := range marks[:checkpointNotes] { + if mark.Decision != checkpointDecisionTold { + t.Errorf("rung %d journaled %q, want %q", mark.N, mark.Decision, checkpointDecisionTold) } } // AND THE CEILING STILL MOVED THE WORK, which is the recovery bound the whole @@ -2087,6 +2114,73 @@ func TestAMarkNobodyCouldReadIsJournaledAsAFailure(t *testing.T) { // back, the claim is spent, and the work moves — on the person's own sentence, // because a continuation that spent its answer on the token wrote no instruction // to hand anybody. +// AND THE BOUNDARY BETWEEN THOSE TWO OUTCOMES IS PINNED ON THE METER ITSELF, +// WITH NO SCHEDULER IN IT. +// +// The two roads above are one fixture apart: a turn whose claim was believed and +// which then worked fewer than [checkpointPrice] rounds is left alone, and the +// same turn a round later is met again. Which side of that line a SCRIPTED turn +// lands on depends on how many of its steps the sidecar's gap eats, so a fixture +// is the wrong instrument for the boundary — it was measured failing 13 times in +// 30 on one machine and never on another, for exactly that reason, and +// [checkpointClaimSlack] is the answer to it. This asks the meter directly, both +// sides of the line and in one place, so the law is pinned by arithmetic rather +// than by a script's luck. +func TestABelievedClaimIsMetAgainOnlyAfterAPriceOfRealRounds(t *testing.T) { + const asked = "write the eight files I listed and smoke-check them" + + meter := &checkpointMeter{} + for meter.rounds < checkpointMarkAt(checkpointMarks) { + meter.round(true) + } + // THE NET FIRES AND THE CLAIM IS BELIEVED AT THE SAME BOUNDARY, which is the + // shape of the ending this is about: the reader drew `(done)`, the + // continuation said nothing was left, and the turn was left to finish. + meter.netFired, meter.netFiredAt = true, meter.rounds + if !meter.mayBelieveDone(asked) { + t.Fatal("a claim about a request nobody has claimed anything about was refused") + } + meter.believeDone(asked) + believedAt := meter.rounds + + // A PRICE SHORT OF THE PRICE, NOTHING RINGS. Not the ladder, which is spent, + // and not the net, which is latched. + for round := 1; round < checkpointPrice; round++ { + if mark := meter.round(true); mark != 0 { + t.Fatalf("round %d of the claim's quiet rang rung %d; the quiet is %d rounds long", + round, mark, checkpointPrice) + } + if meter.netRested() { + t.Fatalf("the net was rested %d rounds after it fired, %d short of its price", + round, checkpointPrice-round) + } + } + // AND ON THE PRICE'S OWN ROUND IT IS MET, THROUGH THE METER AND NOT THE NET. + // The claim rung answers with the last rung whatever the latch says, because + // what it bounds is a claim the harness believed rather than a reading it + // bought. + if mark := meter.round(true); mark != checkpointMarks { + t.Fatalf("the round that disproved the claim rang rung %d, want %d", mark, checkpointMarks) + } + if worked := meter.rounds - believedAt; worked != checkpointPrice { + t.Errorf("the claim was met after %d rounds of work, want %d", worked, checkpointPrice) + } + // AND THE SECOND TIME THE CLAIM IS NOT BELIEVED AGAIN, which is what makes + // this rung an ending rather than a loop. + if meter.mayBelieveDone(asked) { + t.Error("the same claim about the same request was offered a second belief") + } + // AND A ROUND SPENT WATCHING WORK ALREADY OUT IS NOT ONE OF THE PRICE'S, + // which is the meter's own law and the reason this counts rounds of WORK. + watching := &checkpointMeter{} + watching.believeDone(asked) + for round := 0; round < 2*checkpointPrice; round++ { + if mark := watching.round(false); mark != 0 { + t.Fatalf("a batch that only watched rang rung %d", mark) + } + } +} + func TestTheRunningModelsSayS0IsBelievedOnceAndThenMet(t *testing.T) { const asked = "write the eight files I listed and smoke-check them" @@ -2094,7 +2188,13 @@ func TestTheRunningModelsSayS0IsBelievedOnceAndThenMet(t *testing.T) { // draws independent parts either, which is the live cells' own shape and the // one corroboration could say nothing useful about. The continuation says // nothing is left, at the ceiling and at the rung that comes back after it. - rounds := checkpointMarkAt(checkpointMarks) + checkpointPrice + checkpointSlack + // THE SCRIPT IS LONG ENOUGH FOR THE CLAIM TO BE MET AND SPENT. A reading rides + // BESIDE the work (sidecar.go) and is spent at the head of a later loop, so + // the drop is not settled on the round the ceiling fired and the rung that + // comes back after it stands [checkpointPrice] rounds past THAT. Two prices + // and the ordinary tail is the room both of those need; a script that ended + // between them would be a test asserting the scheduler. + rounds := checkpointMarkAt(checkpointMarks) + 2*checkpointPrice + checkpointSlack steps := append(grindingSteps(rounds, checkpointChainSketch, checkpointNothingLeft), finalAnswer("done")) path := filepath.Join(t.TempDir(), "session.jsonl") @@ -2117,7 +2217,7 @@ func TestTheRunningModelsSayS0IsBelievedOnceAndThenMet(t *testing.T) { } // AND IT MOVED ON THE PERSON'S OWN WORDS, because the continuation wrote no // brief — the same fallback a dowry of machine markup gets. - if node.spec.brief != asked { + if !strings.HasPrefix(node.spec.brief, asked) { t.Errorf("the task runs on %q, want the person's own sentence %q", node.spec.brief, asked) } // AND THE FILE SAYS BOTH THINGS THAT HAPPENED, in order: the claim believed, @@ -2151,7 +2251,7 @@ func TestTheCeilingIsDroppedWhenTheReaderAgreesNothingRemains(t *testing.T) { const answered = "all eight files are written and the smoke check passed" rounds := checkpointMarkAt(checkpointMarks) - steps := append(grindingSteps(rounds+checkpointSlack, checkpointDoneSketch, checkpointNothingLeft), + steps := append(grindingSteps(rounds+checkpointClaimSlack, checkpointDoneSketch, checkpointNothingLeft), finalAnswer(answered)) path := filepath.Join(t.TempDir(), "session.jsonl") agent := checkpointAgent(t, &scriptedCompleter{steps: steps}, func(config *Config) { config.SessionFile = path }) @@ -2192,7 +2292,7 @@ func TestTheCeilingIsDroppedWhenTheReaderAgreesNothingRemains(t *testing.T) { // is a reader stating that work remains — it cannot corroborate a claim that none // does. func TestASplitIsNeverDroppedByTheRunningModelsDeclaration(t *testing.T) { - completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(1)+6, + completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, checkpointSplitSketch, checkpointNothingLeft)} agent := checkpointWritingAgent(t, completer) ran := make(ranNodes, 2) @@ -2208,7 +2308,7 @@ func TestASplitIsNeverDroppedByTheRunningModelsDeclaration(t *testing.T) { if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were admitted at a split the running model declared finished", count) } - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Errorf("the split never said its line; notices were %q", noticeTexts(collected)) } } @@ -3076,6 +3176,23 @@ func TestExposureIsTheLastThingTheTurnDid(t *testing.T) { // handoffSteps is [grindingSteps] with the mastermind that WRITES the brief // scripted too: the sketch it draws at a mark, the draft the running model // writes, and the document the writer makes out of it. +// checkpointFillAt is how full a scripted grinding turn's context is after +// `round` finished rounds: a straight ramp that crosses [compactThresholdOf] at +// the round the old ceiling stood on ([checkpointMarkAt] of the last rung), so +// every fixture written against that round still ends where it always did — and +// now ends for the reason the code actually gives. +func checkpointFillAt(round int) int { + ceiling := checkpointMarkAt(checkpointMarks) + return compactThresholdOf(defaultContextWindow) * (round + 2) / ceiling +} + +// filling makes one scripted response report that prompt count. +func filling(response *ai.Response, round int) *ai.Response { + tokens := checkpointFillAt(round) + response.Usage = &ai.Usage{PromptTokens: tokens, CompletionTokens: 7, TotalTokens: tokens} + return response +} + func handoffSteps(count int, sketch, draft, written string) []step { steps := grindingSteps(count, sketch, draft) for index := range steps { @@ -3408,13 +3525,16 @@ func TestACoordinationSketchStartsNothingAndRealPartsStillConvert(t *testing.T) if count := admitted(graph); count != 0 { t.Fatalf("%d tasks were started off a drawing of the conversation's own waiting", count) } - if said := noticeTexts(collected); saidSomething(said, checkpointSplitNote) { + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) { t.Fatalf("the split line was said over a turn that was only waiting: %q", said) } - // AND THE READER WAS ASKED, so this is a refusal rather than a mechanism - // that never fired. - if read := marksRead(completer); read != 2 { - t.Errorf("the sidecar was asked %d times over %d rounds, want both marks", read, rounds) + // AND NOBODY WAS ASKED TO READ ANYTHING, which is what makes the refusal + // free. The drawing that used to be bought at a mark and then thrown away + // over a turn like this one is not bought at all: the rungs below the net + // tell the turn and call nobody (inherit.go), and a turn spent watching + // work already out does not even move the ladder that far. + if read := marksRead(completer); read != 0 { + t.Errorf("the sidecar was asked %d times over %d rounds of watching", read, rounds) } // AND THE TURN'S OWN ANSWER STANDS. if last := lastMessage(agent); last.Role != "assistant" || !strings.Contains(messageText(last), answered) { @@ -3424,7 +3544,7 @@ func TestACoordinationSketchStartsNothingAndRealPartsStillConvert(t *testing.T) }) t.Run("real parts", func(t *testing.T) { - completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, "Finish the four pieces\nwhat is left")} + completer := &scriptedCompleter{steps: writingGrindSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, checkpointSplitSketch, "Finish the four pieces\nwhat is left")} agent := checkpointWritingAgent(t, completer, func(config *Config) { config.Divide = true }) ran := make(ranNodes, 2) graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node }) @@ -3439,8 +3559,8 @@ func TestACoordinationSketchStartsNothingAndRealPartsStillConvert(t *testing.T) if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were admitted off three real parts, want exactly one", count) } - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { - t.Fatalf("the split never said its line; notices were %q", noticeTexts(collected)) + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { + t.Fatalf("the ceiling never said its line; notices were %q", noticeTexts(collected)) } if !strings.HasPrefix(node.spec.brief, "WHAT IS LEFT, AS PARTS: A | B | C") { t.Errorf("the brief does not open on the parts the sidecar drew:\n%s", node.spec.brief) @@ -3567,8 +3687,8 @@ func TestATurnSpentWatchingItsOwnWorkIsNeverCheckpointed(t *testing.T) { if count := admitted(graph); count != 0 { t.Fatalf("%d tasks were started off a turn that only watched", count) } - if said := noticeTexts(collected); saidSomething(said, checkpointSplitNote) || - saidSomething(said, checkpointCeilingNote) { + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || + saidSomething(said, checkpointQuickNote) { t.Fatalf("a watching turn was told something happened to it: %q", said) } if last := lastMessage(agent); last.Role != "assistant" || !strings.Contains(messageText(last), answered) { diff --git a/internal/session/cutboundary_test.go b/internal/session/cutboundary_test.go index cfeed55edd..4d01868a4a 100644 --- a/internal/session/cutboundary_test.go +++ b/internal/session/cutboundary_test.go @@ -242,10 +242,17 @@ func TestACarryOnDrawingLandingPastTheLastStepStillSaysContinue(t *testing.T) { } } -// drawingHeldPastTheLastStep runs one grinding turn that crosses its first mark +// drawingHeldPastTheLastStep runs one grinding turn that reaches THE RUNAWAY NET // and HOLDS THE DRAWING until the turn's last step has already answered, which is -// the one window where no boundary can follow. It returns the journaled mark and -// handover rows. +// the one window where no boundary can follow. It returns the mark row the +// reading was bought for and the journaled handover rows. +// +// IT IS THE NET AND NOT THE FIRST MARK BECAUSE THE FIRST MARK BUYS NOTHING NOW +// (#923): the rungs below the last one tell the turn what it has run up and ask +// nobody anything, so the only reading a turn buys is the one at the net. The +// context is what takes it there — every scripted round reports a fuller window +// ([checkpointFillAt]) — and the note rungs' own rows are dropped below, because +// a row that cost no call is not the row this fixture is about. // // THE DRAWING IS RELEASED BY THE QUESTION A TURN'S END ASKS, and that is what // makes this deterministic rather than a race: the remains reading is made after @@ -256,7 +263,7 @@ func drawingHeldPastTheLastStep(t *testing.T, sketch string) ([]journalMark, []j release := make(chan struct{}) var loosen sync.Once var rounds atomic.Int64 - toolRounds := int64(checkpointMarkAt(1)) + toolRounds := int64(checkpointMarkAt(checkpointMarks)) // EVERY STEP ANSWERS BY SHAPE, so it does not matter which request rides which // slot — the drawing's own call takes one of them while it waits. @@ -289,10 +296,10 @@ func drawingHeldPastTheLastStep(t *testing.T, sketch string) ([]journalMark, []j if round > toolRounds { return textResponse("done"), nil } - return toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", - fmt.Sprintf(`{"path":"./%d"}`, round), "Working through the next path."), nil + return filling(toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", + fmt.Sprintf(`{"path":"./%d"}`, round), "Working through the next path."), int(round)), nil } - steps := make([]step, checkpointMarkAt(1)+checkpointSlack+4) + steps := make([]step, checkpointMarkAt(checkpointMarks)+checkpointSlack+4) for index := range steps { steps[index] = answer } @@ -306,5 +313,18 @@ func drawingHeldPastTheLastStep(t *testing.T, sketch string) ([]journalMark, []j t.Fatalf("Submit: %v", err) } collect(t, events) - return journaledMarks(t, path), journaledCeilings(t, path) + return marksThatBoughtAReading(journaledMarks(t, path)), journaledCeilings(t, path) +} + +// marksThatBoughtAReading drops the note rungs' rows. They are journaled with +// [checkpointDecisionTold] and cost no call at all, so they are not rows about a +// drawing and cannot be rows about one landing late. +func marksThatBoughtAReading(marks []journalMark) []journalMark { + kept := make([]journalMark, 0, len(marks)) + for _, mark := range marks { + if mark.Decision != checkpointDecisionTold { + kept = append(kept, mark) + } + } + return kept } diff --git a/internal/session/fork.go b/internal/session/fork.go index 5780589e2c..08a9c0112c 100644 --- a/internal/session/fork.go +++ b/internal/session/fork.go @@ -29,6 +29,7 @@ import ( "strings" "github.com/Agent-Field/aforge-v2/internal/orchestrate" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // normalizeScopePath is THE ONE READING OF A DECLARED WRITE SCOPE, and both @@ -168,3 +169,47 @@ func forkScopesCollide(left, right []string) (string, bool) { } return "", false } + +// ── AND ONE READING OF A TRANSCRIPT SOMEBODY ELSE IS ABOUT TO OPEN ON ──────── + +// forkSeed is the copy a promoted worker opens with: the caller's transcript up +// to this call, and the system message that stands in front of it. +// +// THE ASSISTANT MESSAGE CARRYING THIS VERY CALL LOSES ITS TOOL CALLS AND KEEPS +// ITS WORDS. It has to lose them: a request whose last assistant message asks +// for a tool nothing has answered is a shape providers refuse, and the answer +// does not exist yet — this function is running inside it. It keeps the words +// because those are the caller's own sentence about what it is about to do, +// which is the most recent thing a worker could be told and the one thing it +// would otherwise have to be told twice. +// +// THE SYSTEM MESSAGE IS HANDED OVER AS TEXT AS WELL AS RIDING AT seed[0], so the +// worker's own [Agent.refreshSystemLocked] rewrites message[0] to what is +// already there. Without it the worker's first request would carry a freshly +// rendered prompt in front of a transcript built against the caller's, and the +// shared prefix — the whole economic argument for inheriting anything — would +// break on its first byte. +// +// IT IS THE FUNCTION #811 DELETED, RESTORED RATHER THAN REWRITTEN. `fork` left +// the belt in that wave and took its only caller with it; the reading itself was +// never wrong, and writing a second one beside the scope reader above would have +// been two answers to one question (promote.go states the law it serves now). +func (a *Agent) forkSeed() ([]ai.Message, string) { + seed := a.snapshot() + if len(seed) == 0 { + return nil, "" + } + if last := len(seed) - 1; len(seed[last].ToolCalls) > 0 { + stripped := seed[last] + stripped.ToolCalls = nil + if strings.TrimSpace(messageContentText(stripped)) == "" { + seed = seed[:last] + } else { + seed[last] = stripped + } + } + if len(seed) == 0 { + return nil, "" + } + return seed, messageContentText(seed[0]) +} diff --git a/internal/session/headlessdoor_test.go b/internal/session/headlessdoor_test.go index 4db1fac9b2..ba20a72599 100644 --- a/internal/session/headlessdoor_test.go +++ b/internal/session/headlessdoor_test.go @@ -30,7 +30,7 @@ func TestAHeadlessRunLeftGoingCheckpointsAtTheSameRoundsAsAChatOne(t *testing.T) wantRounds := []int{ checkpointMarkAt(1), checkpointMarkAt(2), - checkpointMarkAt(3), + checkpointMarkAt(checkpointMarks), } journalRounds := make(map[string][]int, 2) @@ -69,8 +69,10 @@ func TestAHeadlessRunLeftGoingCheckpointsAtTheSameRoundsAsAChatOne(t *testing.T) "work through the four things I listed and report back")) ran.await(t) - if read := marksRead(completer); read != checkpointMarks { - t.Fatalf("the mark reader was asked %d times, want %d", read, checkpointMarks) + // ONE READING, AT THE NET. The two rungs under it tell the turn and + // ask nobody anything (inherit.go), which is the same on both postures. + if read := marksRead(completer); read != 1 { + t.Fatalf("the mark reader was asked %d times, want the net's one reading", read) } if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were started, want the one the ceiling moved", count) @@ -90,9 +92,13 @@ func TestAHeadlessRunLeftGoingCheckpointsAtTheSameRoundsAsAChatOne(t *testing.T) if mark.N != index+1 { t.Errorf("mark %d was journaled as rung %d", index+1, mark.N) } - if mark.Decision != checkpointDecisionContinue { - t.Errorf("rung %d decided %q, want %q", - mark.N, mark.Decision, checkpointDecisionContinue) + // THE NOTE RUNGS TELL AND THE NET READS, on both postures alike. + want := checkpointDecisionTold + if mark.N == checkpointMarks { + want = checkpointDecisionContinue + } + if mark.Decision != want { + t.Errorf("rung %d decided %q, want %q", mark.N, mark.Decision, want) } } if !slices.Equal(gotRounds, wantRounds) { @@ -213,7 +219,7 @@ func TestAScreenlessRunWithNobodyInChargeStillNeverCheckpoints(t *testing.T) { t.Errorf("the screenless run had %d tasks started over it", count) } if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) || - saidSomething(said, checkpointSplitNote) { + saidSomething(said, checkpointQuickNote) { t.Errorf("the screenless run was moved off its own turn: %q", said) } if completer.requests() <= rounds { diff --git a/internal/session/inherit.go b/internal/session/inherit.go new file mode 100644 index 0000000000..8a922fe473 --- /dev/null +++ b/internal/session/inherit.go @@ -0,0 +1,481 @@ +package session + +// PROMOTION: THIS TURN, CARRIED ON BY SOMEBODY WHO ALREADY HOLDS WHAT IT READ. +// +// ── THE MEASURED FAILURE (owner's laptop, 2026-09-11, conversation 7598fe73) ── +// +// A one-line read-only design question. The chat read home-panel files for ten +// rounds in 48 seconds. The round-ten mark fired: a second model was shown an +// account of the work, drew `A | B | C | D`, and the turn was moved. A worker +// was started on the person's sentence, four drawn items and a list of POINTERS +// to the calls the chat had already run. Its first six calls re-read the chat's +// own results — 116 KB of them — and then it read fourteen more files: 551,000 +// input tokens, 24% of them cached, 393 seconds, and not one item ticked. Asked +// why, the chat stopped it and answered out of its own earlier reading. +// +// THREE THINGS WERE WRONG AND THEY ARE THREE DIFFERENT MISTAKES: +// +// - THE DECISION WAS MADE BY THE WRONG PARTY. A summary reader shown an +// account of the work decided whether the model holding the work should stop +// doing it. The one reader in the building that knows whether re-reading +// everything is worth it is the one that already holds it. +// - AT THE WRONG SIGNAL. Ten finished rounds is a count of the person's +// waiting and says nothing whatever about whether the turn is in trouble. A +// turn that reads ten files in 48 seconds is a turn that is working. +// - AND THE HAND-OFF THREW THE CONTEXT AWAY. Everything the turn had learned +// was compiled down to a brief and a list of pointers, and pointers are an +// instruction to read it all again. +// +// ── SO THE TURN DECIDES, INFORMED; AND WHEN THE HARNESS DOES MOVE IT, IT +// PROMOTES RATHER THAN RESTARTS ── +// +// This file is those two sentences. It holds: +// +// THE NOTE ([checkpointChoiceNote]) that the early marks inject instead of +// drawing: the facts this turn has run up, and the three roads on. It decides +// nothing and moves nothing. +// +// THE BOUND ([Agent.inheritFits]) that says whether a worker could hold this +// turn's context at all, in the window's own arithmetic and not in a number +// anybody picked. +// +// THE NET ([Agent.turnHasRunAway]) that is what remains of the ceiling: an +// ABSOLUTE reading of runaway rather than a count of rounds. +// +// AND THE PROMOTION ITSELF ([Agent.promotedQuick]), which is the one place a +// quick node is built to open on its caller's transcript, whether the model +// asked for it with `inherit` or the net did it on the model's behalf. +// +// THERE IS ONE PROMOTION AND IT HAS ONE DOOR. The model reaching for +// `quick_task inherit:true` (task_quick.go) and the net moving a turn +// (checkpoint_quick.go) build the same spec through the same constructor with +// the same bound in front of it. A second road would be a second set of rules +// about what the worker opens on, which is exactly the divergence that let a +// brief-and-pointers worker exist beside a chat that already had the answer. + +import ( + "fmt" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// ── THE NOTE ──────────────────────────────────────────────────────────────── + +// checkpointChoiceRule is the whole of what the harness says at a mark, and it +// is the law this file registers (lawregistry_test.go, class `event`). +// +// IT RIDES THE EVENT AND NOT THE PAGE, which is the prompt diet's own taxonomy +// applied rather than quoted: a rule that matters at one moment of a few turns +// in a hundred is a rule that costs nothing until that moment arrives, and the +// message announcing the moment is where it is delivered. Nothing of this +// sentence is in prompts/system.md and nothing of it is in a tool description; +// the page's own line names the two roads and this names what choosing between +// them COSTS. +// +// AND IT DECIDES NOTHING. The three roads are stated flat, in the order a model +// weighing them would: the cheapest first. There is no recommendation, no +// "consider", and no threshold quoted at the model — the facts above it are the +// argument, and the model holding the work is the only reader that can weigh +// them against what it still has to find out. +// +// THE COST IS SAID ONCE AND IT IS THE ONE FACT A MODEL CANNOT DERIVE. Everything +// else in the note it could work out for itself; whether a worker it starts +// opens on its transcript or on a paragraph about it is a fact about this +// harness, and it was measured costing 551,000 tokens and six and a half minutes +// to leave unsaid. +const checkpointChoiceRule = "Three roads on, and the choice is yours: answer now from what you have; " + + "carry the rest on in a room, with `quick_task` and `inherit` set, which opens on this transcript " + + "as you are holding it; or hand out the parts you have not read yet. A worker that does not inherit " + + "opens on a brief about your work instead of on your work, and reads it all again. " + + "Nothing has been decided for you and nothing has been moved." + +// checkpointChoiceLead is the tag every harness note to a running turn wears, +// in the family looped.go's `[stuck]` and `[silent]` belong to: a bracketed +// word saying what kind of interruption this is, so the model can tell the +// harness's voice from the person's at a glance. +const checkpointChoiceLead = "[taking stock]" + +// turnFacts is what a turn has actually run up, in the three quantities a model +// weighing "is it worth carrying all this somewhere else" has to have in front +// of it. +// +// EVERY ONE OF THEM IS ALREADY HELD AND NONE OF THEM IS A READING. The rounds +// are the meter's own count; the files are the paths this turn has named to a +// tool, counted once each; the bytes are what the tool results in the transcript +// actually weigh. No model is asked anything to produce this, which is the whole +// point — the reading is what cost eight seconds and decided nothing. +type turnFacts struct { + rounds int + files int + bytes int +} + +// turnOpenedAt is where THIS TURN begins in the transcript: the index of the +// message the person opened it with. +// +// THE THREE FIGURES HAVE TO BE ABOUT ONE ANSWER OR THEY ARE ABOUT NOTHING. The +// rounds come from the turn's own meter, and a walk of the whole session would +// have counted every file yesterday's conversation opened beside them — a note +// telling a model it is holding 400 KB after two rounds, which is exactly the +// kind of figure that makes a model do the wrong thing confidently. +// +// A STEER IS PART OF THE TURN IT LANDS IN, so the walk goes back over the tool +// traffic to the newest person's message and then keeps going while the +// messages before it are the person's too: a steer sits directly on the ask it +// steered, and stopping at the steer would start the count halfway through the +// work it was about. +func turnOpenedAt(messages []ai.Message) int { + index := len(messages) - 1 + for index >= 0 && messages[index].Role != "user" { + index-- + } + for index > 0 && messages[index-1].Role == "user" { + index-- + } + if index < 0 { + return 0 + } + return index +} + +// readTurnFacts walks THIS TURN once and answers all three, from the message it +// opened on ([turnOpenedAt]). +// +// A PATH IS WHAT THE CALL SAID IT WAS ABOUT, read through the one parser this +// package already has for a model's arguments ([checkpointArgumentNamed]). It +// is deliberately every tool that names a `path` and not a list of readers: a +// list would be a rule about which verbs count, kept by hand, and wrong the day +// somebody adds one. +func readTurnFacts(messages []ai.Message, from, rounds int) turnFacts { + facts := turnFacts{rounds: rounds} + if from < 0 || from > len(messages) { + from = 0 + } + seen := make(map[string]bool) + for _, message := range messages[from:] { + for index := range message.ToolCalls { + path := checkpointArgumentNamed(message.ToolCalls[index].Function.Arguments, "path") + if path == "" || seen[path] { + continue + } + seen[path] = true + } + if message.Role != "tool" { + continue + } + facts.bytes += messageBytes(message) + } + facts.files = len(seen) + return facts +} + +// line is the facts as a person's model reads them: one clause each, and the +// emptiness law over the two that can be zero — a turn that has read no file and +// holds no result says so by not mentioning either, rather than by claiming +// `0 files`. +func (f turnFacts) line() string { + parts := []string{fmt.Sprintf("%d round%s so far", f.rounds, plural(f.rounds))} + if f.files > 0 { + parts = append(parts, fmt.Sprintf("%d file%s opened", f.files, plural(f.files))) + } + if f.bytes > 0 { + parts = append(parts, fmt.Sprintf("%s of results in front of you", humanBytes(f.bytes))) + } + return strings.Join(parts, ", ") +} + +// checkpointChoiceNote is the note itself: the tag, this turn's facts, and the +// rule. It is composed here so that the law is one const and the figures are +// one function, and neither can be edited without the other being read. +func checkpointChoiceNote(facts turnFacts) string { + return checkpointChoiceLead + " " + facts.line() + ". " + checkpointChoiceRule +} + +// humanBytes is KB above a kilobyte and bytes below it, because a note saying +// `118,784 bytes` is a note a reader has to do arithmetic on. +func humanBytes(bytes int) string { + if bytes < 1024 { + return fmt.Sprintf("%d bytes", bytes) + } + return fmt.Sprintf("%d KB", bytes/1024) +} + +// ── THE BOUND ─────────────────────────────────────────────────────────────── + +// inheritFits reports whether a worker on `model` could hold this turn's context +// and still have room to work, and says why not when it could not. +// +// IT IS A PROPERTY AND NOT A NUMBER, and every term of it is a fact this build +// already holds: +// +// - THE WINDOW is the model's own ([Agent.childWindow], off the catalog). A +// window nobody can name is a refusal rather than a guess: handing a +// transcript to a worker whose capacity is unknown is the one direction +// where being wrong costs the whole node. +// - WHAT OF IT IS USABLE is [ctxbudget.For] — the fill share, less the +// completion reserve and the fixed floor — which is the same arithmetic +// every other reader of a window in this build uses. +// - AND ROOM TO WORK IS ONE TOOL RESULT AT THAT WINDOW ([bare.CapsFor]). It is +// the smallest unit of work this harness has: a worker that cannot take one +// more result is a worker that cannot take one more step, and a node started +// in that state would spend its whole budget failing to read anything. +// +// SO THE TEST IS: the transcript, plus one result, inside the budget. Nothing +// here is a ratio and nothing here is tuned — change the person's fill percent +// or their completion reserve and this moves with them, because it is their +// arithmetic and not a second opinion about it. +func (a *Agent) inheritFits(model string) (refusal string, ok bool) { + carried, room, known := a.contextRoomFor(model) + if !known { + return inheritNoWindow, false + } + if carried > room { + return fmt.Sprintf(inheritTooBig, carried, room), false + } + return "", true +} + +// contextRoomFor is the arithmetic both readers of a window share: what this +// conversation weighs, how far a conversation may grow in that model's window +// before this build has to start throwing part of it away, and whether anything +// knows the window at all. +// +// TWO READERS AND ONE SUM, because "could a worker hold this" and "can this turn +// still work here" are the same question asked about two models. They differ +// only in what an UNKNOWN window means, which is the one thing each caller +// decides for itself. +// +// THE LINE IS THE ONE THIS BUILD ALREADY DRAWS and not a second opinion about +// it. [compactThresholdOf] is how far a conversation may grow before compaction +// must fold it — the window less the larger of fifteen percent and sixteen +// thousand tokens, or the fill a person pinned held under the completion reserve +// (loop.go states the whole derivation, and internal/ctxbudget holds the figures +// it is built from). A conversation past that line cannot take another step +// without something it has already read being folded away, which is precisely +// what "cannot keep working in this window" means; below it, the reserve the +// line keeps back IS the room to work, so there is no second allowance to +// subtract here and no number of this file's own. +// +// WHAT THE CONVERSATION WEIGHS IS [Agent.ContextTokens] and not a walk of the +// messages: it is the provider's own count of the request it last served where +// there is one — the page, the tool schemas, every result, every argument — and +// the content estimate where the transcript has grown past it. A second reading +// taken here would be a second answer to a number a person can already see. +func (a *Agent) contextRoomFor(model string) (carried, room int, known bool) { + window := a.childWindow(model) + if window <= 0 { + return 0, 0, false + } + room = compactThresholdOf(window) + if room <= 0 { + return 0, 0, false + } + return a.ContextTokens(), room, true +} + +// The two refusals, spelled as the model reads them. Each says the fact and then +// the road that is still open, because a refusal that names no alternative is a +// refusal a model answers by trying the same call again. +const ( + inheritNoWindow = "`inherit` is refused: nothing here knows how big this model's context is, " + + "so there is no way to tell whether it could hold yours. Start it without `inherit` and " + + "write what it needs into `line` and `items`." + inheritTooBig = "`inherit` is refused: this conversation is about %d tokens and a worker on this model " + + "can hold about %d and still have room to work. Start it without `inherit` and write what it needs " + + "into `line` and `items`, naming the files it should open." +) + +// ── THE NET ───────────────────────────────────────────────────────────────── + +// turnHasRunAway reports that this turn has stopped being an answer that is +// taking a while and become one that cannot finish where it is. +// +// ── WHY IT IS NOT A COUNT OF ROUNDS ── +// +// It was, and the count is what the measured failure was made of: ten finished +// rounds moved a turn that had read ten files in 48 seconds. A round is a good +// unit for THE PERSON'S WAITING — which is what the note above is priced in — +// and a useless one for TROUBLE, because a round says nothing about whether the +// turn is getting anywhere or whether it still can. +// +// ── SO WHAT IS LEFT IS ONE ABSOLUTE FACT, AND THE OTHER TWO ARE ALREADY WRITTEN ── +// +// THE CONTEXT IS THIS FUNCTION'S OWN and it is the honest reading of runaway: a +// turn whose transcript no longer leaves room for another tool result at its own +// window is a turn that cannot take another step, whatever it is doing. It is +// [Agent.inheritFits] read against THIS agent's model rather than a worker's — +// one function, two readers — so the bound that says a worker could not hold +// this context is the same bound that says the turn can no longer work in it. +// A turn that reads ten distinct files in 48 seconds is nowhere near it. +// +// THE CLOCK IS turnwall.go AND IS NOT REPEATED HERE. An unattended session's +// wall already bounds the stretch one turn may spend inline, at a share of the +// wall, and it is asked at every boundary AHEAD of this ladder. A second clock +// in this file would be a second mechanism for one shape. +// +// THE LOOP IS looped.go AND IS NOT REPEATED HERE EITHER. Past +// [loopNudgeCeiling] the watch marks the episode and the turn is handed over +// through [Agent.handOverLoopingTurn], which reaches the very same ceiling this +// net does. "Added nothing, twice" is that file's sentence and it stays there. +// +// AND THE CLAIM RUNG IS THE METER'S ([checkpointMeter.askAgainAt]): a turn that +// said it was finished and then did [checkpointPrice] more rounds of real work +// has disproved itself, which is an absolute fact about a claim rather than a +// count of anybody's patience. +func (a *Agent) turnHasRunAway() bool { + carried, room, known := a.contextRoomFor(a.model) + // AND A WINDOW NOBODY CAN NAME IS NOT A RUNAWAY. It is the opposite fail-open + // direction from [Agent.inheritFits], and both are the conservative one for + // their own question: a worker that MIGHT not hold a transcript is not handed + // it, and a turn that MIGHT be near its window is not taken off the person who + // is watching it. A doubt stops a promotion; it does not stop an answer. + return known && carried > room +} + +// ── THE PROMOTION ─────────────────────────────────────────────────────────── + +// promotedQuick is the ONE place a quick node is built to open on its caller's +// transcript. Both parties that can promote a turn come through it: the model +// asking for `inherit` (task_quick.go) and the runaway net moving the turn on +// its behalf (checkpoint_quick.go). +// +// IT IS THE ORDINARY CONSTRUCTOR WITH THE SEED HUNG ON IT and never a second +// one. Everything else about the node is untouched — kind quick, in place, +// items, claims, landing, the room, the rail row — which is the whole reason a +// promotion is cheap to reason about: it is the node this build already has, +// started on a transcript instead of on a page about one. +// +// THE SEED IS READ ONCE, HERE. [Agent.forkSeed] takes the caller's messages at +// this instant, and the system text that stands in front of them travels with +// it so the worker's first request is byte-identical to the caller's up to the +// brief — which is the whole economy of the thing (a provider prefix cache hit +// rather than 116 KB read again). +func (a *Agent) promotedQuick(line string, items, files []string) *quickTaskSpec { + quick := newQuickTaskSpec(line, items, files) + quick.seed, quick.system = a.forkSeed() + return quick +} + +// adoptSeed puts a promoted node's inherited transcript in front of its worker, +// and does nothing at all for the ordinary quick node that has none. +// +// IT IS THE HAND'S OWN INSTALL, and the copy is the copy-on-write: what is +// SHARED is what the messages point at — their text, their images — which +// nothing rewrites and where all the weight is; what is not shared is the slice, +// because this worker appends its own work to the tail and rewrites element zero +// at the start of every turn ([Agent.refreshSystemLocked]). Two agents writing +// one backing array is a race in the one place nobody would ever look. +// +// AND ELEMENT ZERO IS THE WORKER'S OWN PAGE. `Config.System` was handed the +// caller's text plus this node's own tail at construction, so the page a worker +// reads is the page its caller read with one paragraph saying what is different +// about being this worker — and `refreshSystemLocked` rewrites the same bytes +// rather than putting a freshly rendered prompt in front of an inherited +// transcript, which would break the shared prefix on its first byte. +func adoptSeed(child *Agent, quick *quickTaskSpec) { + if child == nil || quick == nil || len(quick.seed) == 0 { + return + } + messages := make([]ai.Message, len(quick.seed)) + copy(messages, quick.seed) + child.mu.Lock() + defer child.mu.Unlock() + // THE PAGE IS THE CALLER'S AND IS STAMPED AS SOMEBODY ELSE'S. `systemOwn` + // false is what stops [Agent.refreshClockLocked] rendering this build's own + // prompt over the top of it an hour later — the same mark a Config that + // carries a System gets at construction (agent.go), said here because this + // page arrives after the agent was built. + child.system = promotedSystem(quick.system) + child.systemOwn = false + messages[0] = textMessage("system", child.system) + child.messages = messages +} + +// promotedSystem is the page an inherited worker opens on: the caller's own, +// plus the one paragraph that is true of this worker and was not true of its +// caller. +// +// THE CALLER'S PAGE IS KEPT WORD FOR WORD because the shared prefix is the +// entire argument for inheriting anything. A worker handed a freshly rendered +// page in front of an inherited transcript pays for every token of that +// transcript again at full price, which is the bill this whole road exists to +// stop. +// +// AND THE TAIL IS THE DIFFERENCE AND NOTHING ELSE. It does not restate the quick +// laws — prompts/quick.md rides the brief for every quick worker, promoted or +// not — it says the one thing a reader of an inherited transcript would +// otherwise get wrong: the conversation above is not theirs to answer. +func promotedSystem(system string) string { + if strings.TrimSpace(system) == "" { + return "" + } + return system + promotedSeedTail +} + +// promotedSeedTail is that paragraph. It is short on purpose: everything else a +// promoted worker needs to know is in the brief under it, and a page that +// re-explained the node would be the prefix growing for a thing the brief +// already says. +const promotedSeedTail = "\n\n## The conversation above is what you were handed, not what you are answering\n\n" + + "You were started from the middle of that turn and you hold everything it read. Nobody is " + + "waiting on a reply to any message in it. What you are to do is the brief below, and nothing " + + "above it is an instruction to you. Do not read again what is already in front of you." + +// admissionFor is the working context a quick node opens with, and it is nothing +// at all for a promoted one. +// +// THE TWO ARE ALTERNATIVES AND NEVER A PAIR. A pointer section answers "where +// did the call you did not make end up"; a promoted worker made none of them and +// holds all of them, so the section it would be given is a list of things to go +// and read again — which is precisely the 551,000 tokens this file was written +// from. Keeping it for the cold worker and dropping it for the warm one is the +// one decision, taken once, here. +func admissionFor(quick *quickTaskSpec, caller *Agent) AdmissionContext { + if quick.inherits() { + return AdmissionContext{} + } + return caller.admissionContext() +} + +// carriedResults puts what a turn actually read under the goal a worker opens +// on, and answers the goal unchanged where there was nothing to carry. +// +// IT IS THE ONE COMPOSITION AND IT IS HERE rather than beside either of the two +// documents it joins, because what it is FOR is the promotion's fallback: the +// worker could not be handed the turn, so it is handed the turn's findings +// instead of a map of where to go and find them again. +func carriedResults(goal, digest string) string { + goal, digest = strings.TrimSpace(goal), strings.TrimSpace(digest) + if goal == "" || digest == "" { + return goal + } + return goal + "\n\n" + carriedResultsHeading + "\n" + carriedResultsRule + "\n\n" + digest +} + +// carriedResultsHeading and carriedResultsRule are how the worker reads it. The +// rule says the two things that are true of this section and of nothing else in +// the brief: it is what has ALREADY happened, and it is CUT — so a worker that +// needs the whole of one of these goes and gets that one, rather than starting +// the reading over. +const ( + carriedResultsHeading = "WHAT THIS WORK ALREADY FOUND OUT" + carriedResultsRule = "The account of the turn you are carrying on from: what was called, and the end of what came back. It is cut, newest first, and it says how many older ones it left out. This has already happened. Do not run these again to see what they said; where you need the whole of one, run that one." +) + +// workerModelFor settles which model a quick node asked for with this word will +// actually run on, through the resolver the door itself uses (taskmodel.go). +// +// IT EXISTS SO THAT A ROAD CAN ASK THE BOUND BEFORE IT COMMITS. The door +// resolves the model and answers a refusal a MODEL reads; the carry-on road has +// no model to read one and needs the answer in order to choose between +// promoting the turn and briefing a worker instead — so it asks the same +// question of the same model, rather than of this conversation's own, which is +// routinely not the one the work leaves on ([Agent.defaultTaskModel]). +func (a *Agent) workerModelFor(word string) string { + choice := a.resolveTaskModel(strings.TrimSpace(word)) + if len(choice.options) > 0 { + return settleTaskModel(choice.options, "") + } + return choice.model +} diff --git a/internal/session/inherit_test.go b/internal/session/inherit_test.go new file mode 100644 index 0000000000..0d6bae0136 --- /dev/null +++ b/internal/session/inherit_test.go @@ -0,0 +1,349 @@ +package session + +// PROMOTION, THROUGH THE REAL RUNNER. +// +// The two ends of the one road this file's mechanism opens: a worker that IS +// the conversation that asked for it, and the refusal that stands where it +// cannot be. Both are driven the way task_quick_test.go drives every other +// quick task — a scripted model, the real belt, the real graph, the real +// worker — because the whole claim is about what arrives in the worker's FIRST +// REQUEST, and only the road can put it there. + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// The worker model of the refusal test, and the window an install says it has. +// They are named rather than written into the fixture twice because the whole +// assertion is that ONE conversation is small enough for one window and too big +// for the other. +const ( + inheritSmallModel = "test/pocket" + inheritSmallWindow = 32_000 +) + +// inheritOverSmall is a conversation that fits this build's own default window +// happily and does not fit a worker on the pocket model — the one figure the +// refusal test turns on. +var inheritOverSmall = compactThresholdOf(inheritSmallWindow) + 1 + +// holding puts a size on a scripted response: the provider's own count of what +// it was sent, which is where [Agent.ContextTokens] reads a conversation's size +// from. +// +// EVERY RESPONSE IN A SCRIPT THAT CARES ABOUT SIZE HAS TO CARRY ONE. A real +// provider's prompt count only grows; a fixture that puts a big number on one +// response and leaves the default on the next has the conversation SHRINK +// between the round that filled it and the call that reads it, which is a +// property of the script rather than of the road. +func holding(response *ai.Response, tokens int) *ai.Response { + response.Usage = &ai.Usage{PromptTokens: tokens, CompletionTokens: 7, TotalTokens: tokens} + return response +} + +// quickCallInheriting is the model asking for a quick task and asking to be +// carried into it. It is quickCall with the one argument this wave adds, kept +// beside it rather than folded into it so a reader of either test can see at a +// glance which road is under test. +func quickCallInheriting(id, title, line string, tokens int) step { + arguments, _ := json.Marshal(quickArguments{Title: title, Line: line, Inherit: true}) + return func(context.Context, []ai.Message) (*ai.Response, error) { + return holding(toolResponse(id, quickTaskToolName, string(arguments)), tokens), nil + } +} + +// A PROMOTED WORKER OPENS ON THE CONVERSATION AS IT STANDS, AND READS NOTHING +// TWICE. +// +// This is the measured failure inverted. On the owner's laptop a worker started +// out of a running turn was handed a brief and a list of POINTERS at the calls +// that turn had already made, and its first six calls ran them again: 551,000 +// input tokens at 24% cached, 393 seconds, not one item ticked. So the thing to +// assert is not that a flag survived the road — it is that the bytes the caller +// paid for are in the worker's own request, and that the pointer section that +// used to stand in for them is not. +func TestAPromotedWorkerOpensOnTheConversationAsItStands(t *testing.T) { + const evidence = "CARRIED-EVIDENCE-7" + const line = "say what that command printed, and stop" + + completer := newQuickLanes([]step{ + // THE CALLER DOES A ROUND OF REAL WORK FIRST, because a transcript with + // nothing in it proves nothing about carrying one. + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("c1", "bash", `{"command":"echo `+evidence+`"}`), nil + }, + quickCallInheriting("q1", "finish the reading", line, 4_000), + finalText("it is out"), + }) + completer.lane(line, func(context.Context, []ai.Message) (*ai.Response, error) { + return textResponse(evidence + " is what it printed."), nil + }) + + agent, graph, _ := quickAgent(t, completer) + collect(t, mustSubmit(t, agent, "run that echo and then finish the reading")) + + node := quickNodeSaying(t, graph, line) + // THE NODE KNOWS IT IS A PROMOTION, and it knows it by holding the transcript + // rather than by carrying a flag beside it ([quickTaskSpec.inherits]). + if !node.spec.quick.inherits() { + t.Fatal("the worker was admitted without the conversation it asked to be started from") + } + waitDoneNode(t, node) + + requests := completer.asksOf(line) + if len(requests) == 0 { + t.Fatal("the promoted worker never made a request") + } + first := requests[0] + + // THE CALLER'S OWN RESULT IS IN THE WORKER'S FIRST REQUEST, verbatim. This is + // the whole of the saving: what it holds, it does not fetch. + carried := false + for _, message := range first { + if message.Role == "tool" && strings.Contains(messageText(message), evidence) { + carried = true + } + } + if !carried { + t.Fatalf("the promoted worker's first request carries no result of its caller's; it holds %d messages", + len(first)) + } + // AND THE POINTERS ARE NOT ALSO THERE. A worker holding the results and + // handed a list of places to go and find them is the failure with one extra + // step in it (inherit.go's [admissionFor]). + if completer.sawIn(line, admissionEvidenceHeading) { + t.Error("a promoted worker was handed pointers at calls it is already holding") + } + // AND IT WAS TOLD WHOSE CONVERSATION IT IS READING, which is the one thing a + // reader of an inherited transcript gets wrong on its own. + if first[0].Role != "system" || !strings.Contains(messageText(first[0]), "not what you are answering") { + t.Errorf("the promoted worker opened on %q, want the caller's page with the one paragraph that is "+ + "true of a promotion", messageText(first[0])) + } + // AND IT ANSWERED OUT OF WHAT IT WAS HOLDING. + if notice := node.notice(); notice.State != TaskDone || !strings.Contains(notice.Report, evidence) { + t.Errorf("the promoted worker landed %q saying %q", notice.State, notice.Report) + } +} + +// AND WHERE THE CONVERSATION WILL NOT FIT, THE MODEL IS TOLD SO AND TOLD WHAT +// TO DO INSTEAD. +// +// The bound is a property rather than a number: a worker can be handed this +// conversation when this conversation still fits inside what that model can +// grow to and keep working ([Agent.inheritFits], `compactThresholdOf`). Past +// it there is no promotion to be had, and the interesting part is what happens +// instead — NOT a silent downgrade to a cold worker, which would be a verb +// doing something cheaper than it was asked for, but a result the model reads +// and can act on. +func TestInheritIsRefusedWhenTheConversationWillNotFitAndSaysWhatToDoInstead(t *testing.T) { + const line = "carry the rest of this on" + + completer := newQuickLanes([]step{ + // ONE ROUND THAT FILLS MORE THAN THE WORKER'S WINDOW. The figure is the + // provider's own count of what it was sent, which is where + // [Agent.ContextTokens] reads a conversation's size from, and it sits + // between the two windows on purpose: this conversation is still working + // happily in its own, and no worker on the small model could hold it. + func(context.Context, []ai.Message) (*ai.Response, error) { + return holding(toolResponse("c1", "bash", `{"command":"echo one"}`), inheritOverSmall), nil + }, + quickCallInheriting("q1", "carry it on", line, inheritOverSmall), + finalText("I will finish it here instead."), + }) + + agent, graph, _ := quickAgent(t, completer, func(config *Config) { + // AND THE WORK LEAVES ON A SMALLER MODEL THAN THE PERSON IS TALKING TO, + // which is the ordinary case rather than a contrived one: a `task.model` + // pin or the crew's worker seat hands tasks to a cheaper model every day + // ([Agent.defaultTaskModel]). The bound is the WORKER'S window, so this is + // the conversation that does not fit one. + config.TaskModel = inheritSmallModel + config.ContextWindowFor = func(model string) int { + if model == inheritSmallModel { + return inheritSmallWindow + } + return defaultContextWindow + } + }) + collect(t, mustSubmit(t, agent, "read the whole tree and then carry the rest on")) + + // NOTHING WAS STARTED. + if count := admitted(graph); count != 0 { + t.Fatalf("%d workers were started on a conversation that does not fit one", count) + } + // AND THE MODEL WAS TOLD WHY, in figures it can check, with the road that is + // still open named in the same breath. + if !completer.sawIn("", "`inherit` is refused") { + t.Fatal("the refusal never reached the model that asked") + } + if !completer.sawIn("", "Start it without `inherit`") { + t.Error("the refusal named no way on") + } +} + +// A DECLINED CEILING DOES NOT RE-ARM THE NET AT THE VERY NEXT BOUNDARY. +// +// The net is a CONDITION over a quantity that only grows, and three of the +// endings it leads to leave the turn running — no brief to give, work this +// conversation is holding, a reader saying nothing is left. So the shape to be +// afraid of is not the firing, it is the SECOND one: without a latch, every +// remaining step of the turn buys one mastermind call and one unconditional +// `cutGeneration`, and the person watches their answer be chopped once a round +// until it ends. The old ladder could not do this — the ceiling was a rung and a +// rung happens once — and nothing about the new road makes it impossible except +// [checkpointMeter.netRested]. +// +// THE FIXTURE IS THE HELD-WORK ENDING, driven by the net rather than by the +// write seam: two pieces genuinely out, a drawing that is half coordination, and +// a turn that touches no file, so the reduction blanks every rung, the ceiling +// declines, and the turn carries on with the condition still true. +func TestADeclinedCeilingDoesNotReadTheWorkAgainEveryRound(t *testing.T) { + const asked = "land everything once the other two report" + const draft = "Wait for tasks 1 and 2 to report, then land everything together." + + // LONG PAST THE FIRING, so the count below is a count of what the rest of the + // turn cost rather than of one boundary: two full prices of rounds after the + // net's own rung, which is two chances for a latch to be missing. + rounds := checkpointMarkAt(checkpointMarks) + 2*checkpointPrice + checkpointSlack + completer := &scriptedCompleter{steps: grindingSteps(rounds, custodyMixedSketch, draft)} + agent := custodyAgent(t, completer) + graph := stubbedGraph(agent, custodyRunner(t)) + handOutPiece(t, graph, 1, "the folder picker rail") + handOutPiece(t, graph, 2, "the settings pane copy") + + events, err := agent.Submit(context.Background(), asked) + if err != nil { + t.Fatalf("Submit: %v", err) + } + collect(t, events) + + // NOTHING WAS ADMITTED, which is what makes this the ending under test: the + // ceiling declined and the turn was left to carry on. + if count := admitted(graph); count != 2 { + t.Fatalf("%d nodes are in the graph, want the two pieces that were already out", count) + } + // AND THE WORK WAS READ A HANDFUL OF TIMES AT MOST, not once per round. The + // bound is the one the meter already owns: a firing buys [checkpointPrice] + // rounds of quiet, so a turn this long can afford two more readings and no + // more. + most := 1 + (rounds-checkpointMarkAt(checkpointMarks))/checkpointPrice + if read := marksRead(completer); read > most { + t.Fatalf("the work was read %d times over %d rounds past the net's rung, want at most %d — "+ + "a declined ceiling re-armed the net at the next boundary", read, rounds, most) + } + if read := marksRead(completer); read == 0 { + t.Fatal("the net never fired at all, so this test proves nothing about the latch") + } +} + +// AND THE NOTE'S THREE FIGURES ARE ABOUT THIS ANSWER, NOT ABOUT THE SESSION. +// +// The rounds come from the turn's own meter and the other two are read off the +// transcript, so a walk that started at message zero would put yesterday's files +// and yesterday's bytes beside today's round count — a note telling a model it is +// holding 400 KB after two rounds, which is the kind of figure that makes a model +// do the wrong thing confidently. +func TestTheNotesFiguresCountThisTurnAndNotTheOnesBeforeIt(t *testing.T) { + transcript := []ai.Message{ + textMessage("system", "SYSTEM"), + textMessage("user", "what is in the pricing module"), + {Role: "assistant", ToolCalls: []ai.ToolCall{ + {Function: ai.ToolCallFunction{Name: "read", Arguments: `{"path":"pricing/old.go"}`}}}}, + textMessage("tool", strings.Repeat("y", 4_096)), + textMessage("assistant", "it holds the tariff table"), + // THE SECOND TURN, and a steer inside it: a steer is part of the turn it + // lands in, so the count starts at the ask above it and not at the steer. + textMessage("user", "now check the currency module"), + textMessage("user", "and the rounding while you are in there"), + {Role: "assistant", ToolCalls: []ai.ToolCall{ + {Function: ai.ToolCallFunction{Name: "read", Arguments: `{"path":"currency/rates.go"}`}}}}, + textMessage("tool", strings.Repeat("z", 1_024)), + } + + opened := turnOpenedAt(transcript) + if want := 5; opened != want { + t.Fatalf("the turn is read as opening at message %d, want the ask at %d", opened, want) + } + facts := readTurnFacts(transcript, opened, 3) + if facts.rounds != 3 { + t.Errorf("the rounds are %d, want the meter's own count", facts.rounds) + } + if facts.files != 1 { + t.Errorf("%d files are counted, want the one THIS turn opened", facts.files) + } + // THE BYTES ARE THE RESULT PLUS WHAT THE MESSAGE WEIGHS AROUND IT + // ([messageBytes]), so this asks the question the note asks: is it this turn's + // one kilobyte, or the session's five. + if facts.bytes < 1_024 || facts.bytes > 2_048 { + t.Errorf("%d bytes are counted, want the one result this turn is holding", facts.bytes) + } + // AND THE WHOLE-SESSION WALK IS WHAT IT WOULD HAVE SAID, which is the figure + // the note used to carry. + if whole := readTurnFacts(transcript, 0, 3); whole.files != 2 || whole.bytes < 5_120 { + t.Fatalf("the fixture does not distinguish the two walks: the session holds %d files and %d bytes", + whole.files, whole.bytes) + } +} + +// AND A DRAWING OF WHAT THIS CONVERSATION IS WAITING FOR IS NOT A CHECKLIST FOR +// A WORKER. +// +// "wait for the second | wait for the third | wait for the fourth" is three +// parts by the separator and no pairs of hands at all: every one of them is a +// verb this conversation owns and a worker in its own copy cannot do +// ([checkpointSketch.handsBack]). The gate used to ride on +// [checkpointSketch.split] — the promotion road asked for a split and got the +// hand-back refusal with it — and when the split went, this went with it. It is +// asked in its own right now. +// +// THE TURN HERE IS NOT A WATCHING TURN, which is what makes the case real: a +// turn that only looked at work already out never climbs a rung at all +// ([checkpointMeter.round]), so the coordination test on the other page passes +// whatever this road does. This turn WORKS, runs out of room, and is holding a +// drawing about somebody else's work when the net takes it. +func TestATurnHoldingCoordinationIsNotPromotedWithItAsAChecklist(t *testing.T) { + const asked = "keep the four pieces moving and land them when they report" + const coordination = "wait for the second | wait for the third | wait for the fourth\n" + + "The second, third and fourth pieces are the ones still out." + const dowry = "Land the pieces\nwhat is left, and everything this turn already found out" + + // THE LOOP ROAD, because it is the one the net takes that CAN promote: a turn + // whose context ran out cannot hand that context on and is refused a rung + // earlier ([checkpointMeter.outOfRoom]), so a fixture built on it would pass + // whatever this gate did ([loopingGrindSteps]). + rounds := checkpointMarkAt(checkpointMarks) + completer := &scriptedCompleter{steps: loopingGrindSteps(rounds, coordination, dowry)} + agent := checkpointAgent(t, completer, func(config *Config) { config.Divide = true }) + graph := stubbedGraph(agent, func(*TaskNode) {}) + + events, err := agent.Submit(context.Background(), asked) + if err != nil { + t.Fatalf("Submit: %v", err) + } + collected := collect(t, events) + + // NOTHING WAS PROMOTED. Whatever else this turn's ending did, it did not hand + // a worker a list of things only this conversation can do. + graph.mu.Lock() + var promoted []string + for _, id := range graph.order { + node := graph.nodes[id] + if node != nil && node.spec.quick != nil { + promoted = append(promoted, strings.Join(node.spec.quick.items, " | ")) + } + } + graph.mu.Unlock() + if len(promoted) > 0 { + t.Fatalf("a turn holding coordination was carried on as a quick task with %q as its list", promoted) + } + // AND THE CARRY-ON LINE WAS NOT SAID, because no carry-on happened. + if said := noticeTexts(collected); saidSomething(said, checkpointQuickNote) { + t.Fatalf("the carry-on said its line over a drawing of what this conversation is waiting for: %q", said) + } +} diff --git a/internal/session/lawregistry_test.go b/internal/session/lawregistry_test.go index 20dcc9ec03..052d298654 100644 --- a/internal/session/lawregistry_test.go +++ b/internal/session/lawregistry_test.go @@ -118,7 +118,11 @@ func lawElsewhere(t *testing.T, at string) string { case "code": // The harness messages a law can ride on, by the symbol a lane would go // and edit. A name missing here is a message nobody has registered yet. - messages := map[string]string{"standingNewsRule": standingNewsRule} + messages := map[string]string{ + "standingNewsRule": standingNewsRule, + "checkpointChoiceRule": checkpointChoiceRule, + "carriedResultsRule": carriedResultsRule, + } text, known := messages[name] if !known { t.Fatalf("code:%s is not a message this test knows; add it beside standingNewsRule", name) @@ -214,6 +218,22 @@ var lawRegistry = []lawUnit{ // itself and says what to do about itself, so the page explaining that // message was the second copy of a rule the message already carries. {id: "standing.news-is-not-a-request", class: lawEvent, at: "code:standingNewsRule", key: "Do not call stand again for it"}, + + // ── and the three laws of a turn that has run long (inherit.go). WHAT + // `inherit` DOES IS A FACT ABOUT ONE FIELD OF ONE VERB, so it rides that + // verb's schema and disappears with it — the page said it too for a while, + // and a page sentence is paid for by every request of every turn whether or + // not the belt carries the tool it is about. What choosing between the roads + // COSTS rides the note that announces the moment, because it is worth + // nothing until a turn is actually long enough to be told. The third is what + // a worker that could not be handed the transcript is handed instead, and it + // rides that document. + {id: "handoff.what-you-read-need-not-be-read-again", class: lawVerb, tool: "quick_task", + key: "Opens on this conversation"}, + {id: "checkpoint.the-turn-chooses", class: lawEvent, at: "code:checkpointChoiceRule", + key: "A worker that does not inherit opens on a brief about your work"}, + {id: "handoff.carried-results-already-happened", class: lawEvent, at: "code:carriedResultsRule", + key: "Do not run these again to see what they said"}, } // lawPlace is one searchable region of the fixed prefix, named the way a lane diff --git a/internal/session/loop_speed_test.go b/internal/session/loop_speed_test.go index 021d4c9795..73213329b7 100644 --- a/internal/session/loop_speed_test.go +++ b/internal/session/loop_speed_test.go @@ -195,8 +195,11 @@ func paceRounds(count int, answer string) []step { arguments, _ := json.Marshal(struct { Path string `json:"path"` }{Path: fmt.Sprintf("./%d", round)}) - return toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", string(arguments), - "Working through the next path."), nil + // AND IT FILLS THE WINDOW AS IT GOES, for [checkpointFillAt]'s reason: + // a turn is taken away from a person when its context can no longer + // hold another step, never for having finished a number of rounds. + return filling(toolResponseWithText(fmt.Sprintf("call-%d", round), "ls", string(arguments), + "Working through the next path."), round), nil }) } return append(steps, func(context.Context, []ai.Message) (*ai.Response, error) { @@ -247,9 +250,12 @@ func pacedTurn(t *testing.T, journal string) journalPace { // mark crossed part way through — with the recall, the namer, the narrator, the // work-or-words judge and the mark's own reader each taking three seconds. func TestTheOnlyWaitAPersonExperiencesIsTheModelGenerating(t *testing.T) { - // The first mark falls at [checkpointPrice] finished rounds; two more rounds - // past it are what the reading rides beside. - rounds := checkpointMarkAt(1) + 2 + // THE ONE RUNG THAT STILL ASKS ANYBODY TO READ THE WORK IS THE NET, so the + // script runs until the context it is filling can hold no more + // (inherit.go's [Agent.turnHasRunAway]). The rungs below it tell the turn and + // call nobody, which is a saving rather than a gap in this law: what this test + // pins is that the reading which DOES happen is never in front of the person. + rounds := checkpointMarkAt(checkpointMarks) + 2 completer := &paceCompleter{ steps: paceRounds(rounds, "all done"), slow: paceSlowReading, @@ -273,8 +279,11 @@ func TestTheOnlyWaitAPersonExperiencesIsTheModelGenerating(t *testing.T) { "a reading beside the work was awaited between two steps", pace.StepGapMS, paceLawBudget.Milliseconds()) } - if pace.Steps < rounds { - t.Fatalf("the turn took %d steps, want at least %d — the script did not run", pace.Steps, rounds) + // AND THE SCRIPT RAN. The net cuts the step it lands beside, so the turn ends + // a round or two short of its script and the floor is the rung rather than the + // whole of it. + if floor := checkpointMarkAt(checkpointMarks); pace.Steps < floor { + t.Fatalf("the turn took %d steps, want at least %d — the script did not run", pace.Steps, floor) } // AND THE READINGS REALLY WERE ASKED. A law that passed because nothing ran @@ -392,13 +401,14 @@ func TestARecallThatLandsAfterTheFirstWordRidesTheNextStep(t *testing.T) { } } -// A MARK'S READING RUNS BESIDE THE NEXT STEP AND INTERRUPTS IT. +// THE NET'S READING RUNS BESIDE THE NEXT STEP AND INTERRUPTS IT. // -// The reading takes three seconds and draws independent parts. The next step -// goes out at once — the law above — and the drawing then cuts it where it -// stands rather than waiting for it to end. +// The reading takes three seconds. The next step goes out at once — the law +// above — and the drawing then cuts it where it stands rather than waiting for +// it to end. The reading is the runaway net's now and the only one there is: the +// rungs under it tell the turn and buy nothing (inherit.go). func TestAMarksReadingRidesBesideTheStepAndCutsItOnIndependentParts(t *testing.T) { - rounds := checkpointMarkAt(1) + rounds := checkpointMarkAt(checkpointMarks) // The mark falls once `rounds` tool rounds have FINISHED, so the step that // rides beside the reading is the one after them. It waits for its own // context, which makes the cut the only thing that can end it. diff --git a/internal/session/route_checks_test.go b/internal/session/route_checks_test.go index a84f0e7bc5..8c164bd4f4 100644 --- a/internal/session/route_checks_test.go +++ b/internal/session/route_checks_test.go @@ -21,6 +21,10 @@ func TestAutomaticTaskChecksReachTheRealChecker(t *testing.T) { var agent *Agent asked := routeAsk if checkpoint { + // THE CONTEXT FILLS AS IT GOES, which is the only road a turn is + // moved on now: the rungs before the last one tell the turn and + // move nothing, and the net reads the window (inherit.go). + completer.fillsWindow = true agent, _, _ = racingAgent(t, completer) completer.ahead, completer.aheadConfirm = verdict, verdict asked = routeEnumerated @@ -105,7 +109,7 @@ func TestAPartialAutomaticHandoffDoesNotInheritWholeRequestChecks(t *testing.T) verdict := routeVerdict{Work: true, Goal: routeAsk, Checks: []string{"sh ./family-check.sh"}, checksRequest: routeAsk} over := agent.handOverRunningTurn(context.Background(), newEventHub(), &Usage{}, - time.Now(), agent.model, checkpointSplitNote, checkpointSeamMark, 0, nil, + time.Now(), agent.model, checkpointCeilingNote, checkpointSeamCeiling, 0, nil, verdict, read, nil) if !over.moved { t.Fatalf("the handoff did not admit the remainder: %+v", over) diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index acf2161e70..824cac0b30 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -84,6 +84,12 @@ type routeCompleter struct { // wrote. One call is enough to say so and four short of the write seam's own // allowance, so the mark is still what moves the work. writesFirst bool + // fillsWindow makes every scripted tool round report a prompt count past the + // line a conversation may grow to ([compactThresholdOf]), so the turn reaches + // the runaway net (inherit.go) in a handful of rounds instead of after a + // hundred thousand tokens of scripted results. It is the only way a fixture + // gets a CONVERSION now: a mark tells the turn and moves nothing. + fillsWindow bool // onAnswer is called with the conversation's answer number before it comes // back, which is where a test that needs something to happen MID-TURN — an // interrupt, most usefully — puts it. @@ -193,6 +199,7 @@ func (c *routeCompleter) CompleteWithMessages(ctx context.Context, messages []ai c.answers++ answer, round, hook := c.answer, c.answers, c.onAnswer call := round <= c.toolRounds + fills := c.fillsWindow && round > 1 wrote := c.writesFirst && call && round == 1 c.mu.Unlock() if hook != nil { @@ -203,12 +210,27 @@ func (c *routeCompleter) CompleteWithMessages(ctx context.Context, messages []ai `{"path":"notes.md","content":"what the turn found out\n"}`), nil } if call { - return toolResponse(fmt.Sprintf("call-%d", round), "ls", - fmt.Sprintf(`{"path":"./%d"}`, round)), nil + response := toolResponse(fmt.Sprintf("call-%d", round), "ls", + fmt.Sprintf(`{"path":"./%d"}`, round)) + if fills { + over := compactThresholdOf(defaultContextWindow) + 1 + response.Usage = &ai.Usage{PromptTokens: over, CompletionTokens: 7, TotalTokens: over} + } + return response, nil } return textResponse(answer), nil } +// stopFilling lets the conversation breathe again: the rounds after this one +// report an ordinary prompt count, so a turn that follows a conversion is a turn +// with room to work in. A fixture that kept filling would convert every turn +// through the net and could say nothing about what a gap gags. +func (c *routeCompleter) stopFilling() { + c.mu.Lock() + c.fillsWindow = false + c.mu.Unlock() +} + func (c *routeCompleter) asked() int { c.mu.Lock() defer c.mu.Unlock() @@ -1021,17 +1043,23 @@ func racingAgent(t *testing.T, completer *routeCompleter) (*Agent, *routeRun, *r return agent, runs, nodes } -// A REQUEST WITH SEVERAL INDEPENDENT DELIVERABLES IN IT IS LOOKED AT EARLY, AND -// THE LOOK CONVERTS THE TURN. +// A REQUEST WITH SEVERAL INDEPENDENT DELIVERABLES IN IT IS TOLD EARLY, AND WHAT +// CONVERTS THE TURN IS THE NET. // -// Two readings in sequence: the race says this one is worth looking at and pulls -// the checkpoint's first mark down to the boundary its verdict landed on; the -// sidecar at that mark reads the transcript, draws three independent parts, and -// THAT is what ends the turn. One task carries the work, the person's own words -// ride it verbatim, and what the conversation had already found out goes with it -// as the dowry — under the parts the sidecar named. +// The race says this one is worth telling the turn about sooner and pulls the +// first note down to the boundary its verdict landed on. It converts nothing — +// that was taken away from it when the benchmark measured it converting both of +// its small-work traps — and what ends this turn is the runaway net, on a +// context that can no longer hold another step (inherit.go). One task carries +// the work, the person's own words ride it verbatim, what the conversation had +// already found out goes with it as the dowry, and the race's verdict is still +// what the task is armed and finished against. func TestARacedYesIsLookedAtEarlyAndTheLookConvertsTheTurn(t *testing.T) { completer := &routeCompleter{answer: "I will start with the auth test."} + // THE CONTEXT FILLS AS IT GOES, which is the only road to a conversion there + // is now: a note moves nothing, and the net reads the window rather than a + // count of rounds. + completer.fillsWindow = true agent, runs, nodes := racingAgent(t, completer) collected := collect(t, mustSubmit(t, agent, routeEnumerated)) @@ -1045,10 +1073,11 @@ func TestARacedYesIsLookedAtEarlyAndTheLookConvertsTheTurn(t *testing.T) { if completer.answered() == 0 { t.Fatal("the race took the message away from the turn instead of racing it") } - // AND THE TURN STOPPED WHERE THE VERDICT LANDED. Eight rounds were scripted; - // what runs is the round or two before the boundary that had an answer waiting. - if completer.answered() > 3 { - t.Fatalf("the turn ground on for %d rounds before the race converted it", completer.answered()) + // AND THE TURN STOPPED WELL SHORT OF THE FIRST NOTE'S ORDINARY RUNG. Eight + // rounds were scripted; what runs is the handful before the net's drawing + // lands and cuts the step it is riding beside. + if completer.answered() >= checkpointMarkAt(1) { + t.Fatalf("the turn ground on for %d rounds before the net converted it", completer.answered()) } // The judge read the REQUEST and nothing about an answer — it was written // before there was one. @@ -1091,9 +1120,9 @@ func TestARacedYesIsLookedAtEarlyAndTheLookConvertsTheTurn(t *testing.T) { if !strings.Contains(node.acceptance(), "RELEASE-2.4.md exists") { t.Errorf("the task is finished against %q, want the judge's own done-condition", node.acceptance()) } - // AND THEY ARE TOLD, in the split's own line with the told-after line under it. + // AND THEY ARE TOLD, in the ceiling's own line with the told-after line under it. said := noticeTexts(collected) - if !saidSomething(said, checkpointSplitNote) { + if !saidSomething(said, checkpointCeilingNote) { t.Fatalf("the conversion never said its line; notices were %q", said) } notice := routeNotice(collected) @@ -1103,7 +1132,7 @@ func TestARacedYesIsLookedAtEarlyAndTheLookConvertsTheTurn(t *testing.T) { // THE TURN IS SEALED, and the transcript is not left with a request nobody // replied to: the next turn would open on it and answer it all over again. if last := lastMessage(agent); last.Role != "assistant" || - messageText(last) != checkpointSplitNote+"\n"+notice { + messageText(last) != checkpointCeilingNote+"\n"+notice { t.Fatalf("the transcript ends on %q by %q, want the two lines the person read", messageText(last), last.Role) } @@ -1116,24 +1145,20 @@ func TestARacedYesIsLookedAtEarlyAndTheLookConvertsTheTurn(t *testing.T) { } // AND A RACED YES ON ITS OWN CONVERTS NOTHING AND SAYS NOTHING. IT ONLY MAKES -// AFORGE LOOK SOONER. +// AFORGE SAY SOMETHING TO THE TURN SOONER. // -// This is the demotion, pinned. The benchmark measured the raced screen -// converting BOTH of its small-work traps — a message whose fastest correct -// answer was a few tool calls, taken out of the conversation that was answering -// it — because what the race reads is a REQUEST nobody has worked on yet. So the -// yes now buys one thing: the checkpoint's first mark lands at the next boundary -// instead of after ten rounds. Here the mark reader looks at the work and says it -// is one job, and the turn simply finishes. +// This is the demotion, pinned, and it is one step further along than it was. +// The benchmark measured the raced screen converting BOTH of its small-work +// traps — a message whose fastest correct answer was a few tool calls, taken out +// of the conversation that was answering it — because what the race reads is a +// REQUEST nobody has worked on yet. So the yes buys one thing: the checkpoint's +// first NOTE lands at the next boundary instead of after ten rounds. Nobody is +// asked to read anything, nothing is moved, and the turn simply finishes. // -// AND NOTHING IS SAID, because nothing the person can observe has happened — the -// emptiness law applied to an event rather than to a number. +// AND NOTHING IS SAID TO THE PERSON, because nothing they can observe has +// happened — the emptiness law applied to an event rather than to a number. func TestARacedYesOnlyMakesTheSidecarLookSooner(t *testing.T) { - completer := &routeCompleter{ - answer: "Here is the fix, and the suite is green.", - // The reading of the work says this is one job after all. - sketch: checkpointChainSketch, - } + completer := &routeCompleter{answer: "Here is the fix, and the suite is green."} agent, _, nodes := racingAgent(t, completer) collected := collect(t, mustSubmit(t, agent, routeEnumerated)) @@ -1142,21 +1167,33 @@ func TestARacedYesOnlyMakesTheSidecarLookSooner(t *testing.T) { t.Fatalf("the screen was asked %d times and the confirm %d, want one each", completer.preAsked(), completer.preConfirms()) } - // THE LOOK HAPPENED, AND IT HAPPENED EARLY. The ordinary first mark stands at - // ten finished rounds; this turn answered two or three times in all. - if read := completer.marksRead(); read != 1 { - t.Fatalf("the sidecar was asked %d times, want the one look the race bought", read) + // NOBODY WAS ASKED TO READ THE WORK. The note rungs buy no model call at all, + // which is the whole saving (inherit.go). + if read := completer.marksRead(); read != 0 { + t.Fatalf("the sidecar was asked %d times; a note rung reads nobody", read) + } + // AND THE NOTE CAME EARLY. The ordinary first rung stands at ten finished + // rounds; this turn was told inside two or three. + told := false + agent.mu.Lock() + for _, message := range agent.messages { + if strings.Contains(messageText(message), checkpointChoiceLead) { + told = true + } + } + agent.mu.Unlock() + if !told { + t.Fatal("the raced yes bought nothing: the turn was never told") } if completer.answered() >= checkpointMarkAt(1) { - t.Fatalf("the turn ran %d rounds before the work was looked at; the raced yes is supposed to "+ - "pull that mark down from %d", completer.answered(), checkpointMarkAt(1)) + t.Fatalf("the turn ran %d rounds before it was told; the raced yes is supposed to "+ + "pull that rung down from %d", completer.answered(), checkpointMarkAt(1)) } // AND NOTHING ELSE HAPPENED AT ALL. if nodes.count() != 0 || agent.graph().node(1) != nil { t.Fatal("a raced yes started work on its own") } - if said := noticeTexts(collected); saidSomething(said, checkpointSplitNote) || - saidSomething(said, checkpointCeilingNote) { + if said := noticeTexts(collected); saidSomething(said, checkpointCeilingNote) { t.Fatalf("a raced yes said something to the person: %q", said) } if notice := routeNotice(collected); notice != "" { @@ -1188,6 +1225,7 @@ func TestARacedYesOnlyMakesTheSidecarLookSooner(t *testing.T) { // Config literal). func TestARacedWideYesPutsTheDivisionVerbOnTheWorkersBelt(t *testing.T) { completer := &routeCompleter{answer: "I will start with the auth test."} + completer.fillsWindow = true agent, _, nodes := racingAgent(t, completer) agent.config.Divide = true @@ -1226,6 +1264,7 @@ func TestARacedWideYesPutsTheDivisionVerbOnTheWorkersBelt(t *testing.T) { // that way when work does not. func TestTheConfirmsReadingOfBreadthArmsWhatTheScreenMissed(t *testing.T) { completer := &routeCompleter{answer: "I will start with the auth test."} + completer.fillsWindow = true agent, _, nodes := racingAgent(t, completer) agent.config.Divide = true // The screen says work and says nothing about breadth; the mastermind reads @@ -1247,34 +1286,39 @@ func TestTheConfirmsReadingOfBreadthArmsWhatTheScreenMissed(t *testing.T) { } } -// AND NEITHER RACE READER SAYING IT ARMS NOTHING OF THE RACE'S OWN. What arms -// this task is the reading of the WORK, and the word on the spec says so. +// A RACE THAT SAID NOTHING ABOUT BREADTH WRITES NO BREADTH. // -// Both race readers here call the request one self-contained sweep, so nothing -// they wrote carries breadth. The mark reader then opens the transcript and draws -// three independent parts out of it — and THAT is what arms the road, under -// [armedJudged] rather than [armedWide], because the two are told apart by which -// reader decided (task_divide.go's [TaskNode.armedByJudgement]). -func TestARacedVerdictWithoutBreadthArmsNothingOfItsOwn(t *testing.T) { - const narrow = `{"work": true, "goal": "port the pricing tests to the new fixture", "why": "one self-contained sweep"}` - completer := &routeCompleter{answer: "I will start with the auth test."} - agent, _, nodes := racingAgent(t, completer) - agent.config.Divide = true - completer.ahead, completer.aheadConfirm = narrow, narrow - - collect(t, mustSubmit(t, agent, routeEnumerated)) - waitFor(t, "the task the reading of the work handed over", func() bool { return nodes.count() == 1 }) +// THE LAW MOVED WHEN THE ROAD DID, AND THIS IS WHERE IT MOVED TO. It used to be +// read off the admitted node — a converted turn whose race said nothing about +// breadth came out [armedJudged] rather than [armedWide], because the mark that +// moved it was not the ceiling. A mark moves nothing now (inherit.go), so every +// conversion out of a running turn is [Agent.checkpointCeiling]'s and that road +// sets `Wide` on its first line: read off the node, "armed wide" is a fact about +// the road and about nothing the race did. Asserting it there would be asserting +// the road under the name of the law. +// +// SO IT IS READ WHERE THE RACE'S OWN ANSWER LIVES: [routeWidth], the one +// function that composes the two readers into the breadth the verdict carries. +// Both readers here call the request one self-contained sweep, so the verdict +// the race hands on carries no breadth word — and either of them saying +// otherwise puts one there, which is the asymmetry that function exists for and +// the half that would silently stop working if somebody made it an `&&`. +func TestARacedVerdictWithoutBreadthWritesNoBreadthOfItsOwn(t *testing.T) { + const why = "one self-contained sweep" + narrow := routeVerdict{Work: true, Goal: "port the pricing tests to the new fixture", Why: why} + broad := routeVerdict{Work: true, Goal: narrow.Goal, Why: why, Wide: true} - node := agent.graph().node(1) - if node == nil { - t.Fatal("no node was admitted") + if routeWidth(narrow, narrow) { + t.Error("two readers who both called it one job handed on a verdict that says the work is broad") } - if node.spec.wide { - t.Errorf("a race verdict that said nothing about breadth still wrote it onto the spec (%q)", - node.armedBy()) + if !routeWidth(broad, narrow) { + t.Error("the screen read breadth and the confirm's silence disarmed it") } - if got := node.armedBy(); got != armedJudged { - t.Errorf("the task was armed by %q, want the mark reader's own judgement (%q)", got, armedJudged) + if !routeWidth(narrow, broad) { + t.Error("the mastermind read breadth the screen missed and it was dropped") + } + if !routeWidth(broad, broad) { + t.Error("both readers said broad and the verdict does not") } } @@ -1322,7 +1366,7 @@ func TestAVerdictThatLandsAfterTheTurnIsDropped(t *testing.T) { if nodes.count() != 0 || agent.graph().node(1) != nil { t.Fatal("a verdict that arrived after the answer started work anyway") } - if saidSomething(noticeTexts(collected), checkpointSplitNote) { + if saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("a dropped verdict said its line out loud: %q", noticeTexts(collected)) } if notice := routeNotice(collected); notice != "" { @@ -1361,7 +1405,7 @@ func TestAnInterruptDiscardsTheRacedYes(t *testing.T) { if nodes.count() != 0 || agent.graph().node(1) != nil { t.Fatal("an interrupted turn was converted into a task") } - if saidSomething(noticeTexts(collected), checkpointSplitNote) { + if saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("an interrupted turn drew the conversion line: %q", noticeTexts(collected)) } if notice := routeNotice(collected); notice != "" { @@ -1374,10 +1418,11 @@ func TestAnInterruptDiscardsTheRacedYes(t *testing.T) { // noticed, so the next three turns are quiet whatever any judge says. func TestAConversionSpendsTheGapOnce(t *testing.T) { completer := &routeCompleter{answer: "I will start with the auth test."} + completer.fillsWindow = true agent, _, nodes := racingAgent(t, completer) first := collect(t, mustSubmit(t, agent, routeEnumerated)) - if !saidSomething(noticeTexts(first), checkpointSplitNote) { + if !saidSomething(noticeTexts(first), checkpointCeilingNote) { t.Fatalf("the first turn was not converted; notices were %q", noticeTexts(first)) } waitFor(t, "the task the race converted the turn into", func() bool { return nodes.count() == 1 }) @@ -1385,12 +1430,16 @@ func TestAConversionSpendsTheGapOnce(t *testing.T) { // THE VERY NEXT TURN is inside the gap the conversion opened, so the race is // never even launched — no screen call, no confirm, no second task. before, beforeConfirms := completer.preAsked(), completer.preConfirms() + // AND THE NEXT TURN HAS ROOM TO WORK IN, which is what makes it a test of the + // gap: a turn still filling its window would be taken by the net whatever any + // gap says, because the net is not a judge and has nothing to be quiet about. + completer.stopFilling() second := collect(t, mustSubmit(t, agent, routeEnumerated)) if completer.preAsked() != before || completer.preConfirms() != beforeConfirms { t.Fatalf("the race ran again inside the gap (%d screens then %d)", before, completer.preAsked()) } - if saidSomething(noticeTexts(second), checkpointSplitNote) { + if saidSomething(noticeTexts(second), checkpointCeilingNote) { t.Fatalf("a second conversion happened inside the gap: %q", noticeTexts(second)) } if agent.graph().node(2) != nil { @@ -1433,7 +1482,7 @@ func TestARacedYesIsDroppedWhenTheModelSaysNothingIsLeft(t *testing.T) { completer.preAsked(), completer.preConfirms()) } // NO TASK, AND NO LINE ABOUT ONE. - if saidSomething(noticeTexts(collected), checkpointSplitNote) { + if saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("the person was told their answer was being moved and then watched it finish where "+ "it was; notices were %q", noticeTexts(collected)) } @@ -1464,6 +1513,7 @@ func TestARacedYesIsDroppedWhenTheModelSaysNothingIsLeft(t *testing.T) { // sentence is the one thing on this road nobody writes. func TestAConvertedTaskIsNamedFromTheAskAndNotFromTheDowry(t *testing.T) { completer := &routeCompleter{answer: "I will start with the auth test."} + completer.fillsWindow = true agent, _, nodes := racingAgent(t, completer) completer.handoff = dsmlSentinel + "\nwhatever this turn found out" @@ -1530,16 +1580,20 @@ func TestAnAutoStartedTasksNameCarriesNoMarkdown(t *testing.T) { // lane to touch this file will want a line here. func TestTheRaceHasNoLineOfItsOwn(t *testing.T) { const retired = "this reads like work · moving it to a task that is watched and can split" - for _, line := range []string{checkpointSplitNote, checkpointCeilingNote, taskEscalationNote} { + for _, line := range []string{checkpointCeilingNote, checkpointQuickNote, taskEscalationNote} { if line == retired { t.Errorf("the race's retired line came back as %q", line) } } - // AND THE LINE THAT REPLACED IT SAYS WHAT ITS OWN MOMENT SAW. The race read a - // request; the split read the work and drew parts out of it, which is a - // different and stronger claim. - if !strings.Contains(checkpointSplitNote, "parts") { - t.Errorf("the split's line does not say what it saw: %q", checkpointSplitNote) + // AND THE LINES THAT REPLACED IT SAY WHAT THEIR OWN MOMENT SAW. The race read + // a REQUEST nobody had worked on yet; what moves a turn now is the net, which + // read a turn that can no longer work where it is, and it says so in the words + // a person can check against their own screen (inherit.go). + if !strings.Contains(checkpointCeilingNote, "running long") { + t.Errorf("the ceiling's line does not say what it saw: %q", checkpointCeilingNote) + } + if !strings.Contains(checkpointQuickNote, "already read") { + t.Errorf("the carry-on's line does not say what it took with it: %q", checkpointQuickNote) } } diff --git a/internal/session/spawnfloor_test.go b/internal/session/spawnfloor_test.go index 9bd1ebcbe7..c1a1fb8a7e 100644 --- a/internal/session/spawnfloor_test.go +++ b/internal/session/spawnfloor_test.go @@ -163,7 +163,7 @@ func assertAskNeverSpawns(t *testing.T, asked string) { t.Fatalf("the checkpoint metered %q", asked) } over := agent.handOverRunningTurn(context.Background(), newEventHub(), &Usage{}, - time.Time{}, agent.model, checkpointSplitNote, checkpointSeamMark, 0, nil, + time.Time{}, agent.model, checkpointCeilingNote, checkpointSeamCeiling, 0, nil, routeVerdict{Work: true, Goal: asked}, checkpointRead{}, nil) if over.moved { t.Fatalf("the handover moved %q", asked) diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 249174a3ab..ffb6a1a95d 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -36,6 +36,7 @@ import ( "strings" "github.com/Agent-Field/aforge-v2/internal/exec/bare" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // quickWord is the word a quick node's row leads with, and it is the same word @@ -83,8 +84,27 @@ type quickTaskSpec struct { // moment: the edge itself lives on `dependsOn` like any other, and nothing // past the receipt reads this. waits []quickClaim + // seed is THE CALLER'S TRANSCRIPT, WHOLE, on a node that was PROMOTED rather + // than merely started — and nil on every other quick node there is + // (inherit.go). system is the page that stood in front of it. + // + // THEY ARE IN MEMORY AND THEY ARE NEVER WRITTEN DOWN, which is deliberate + // and is the reason they are unexported beside every other field here. A + // checkpoint record carrying a conversation's whole transcript for every + // promoted node would be a store that grows with the talking rather than + // with the work, and a promotion is a hand-off between two live agents in + // one process — there is no moment after this session ends at which it could + // be honoured. A promoted node that is resumed is resumed as an ordinary + // quick node on its line and items, which is what it would have been. + seed []ai.Message + system string } +// inherits reports that this node opens on its caller's transcript rather than +// on a page about it. It is the one question every reader of the two fields +// above asks, so it is asked in one place. +func (s *quickTaskSpec) inherits() bool { return s != nil && len(s.seed) > 0 } + // quickClaim is one collision found at admission: the node already claiming a // path, and the path both of them claim. type quickClaim struct { @@ -296,9 +316,18 @@ func quickReportOnClose(record taskRecord) string { // twice reasons from whichever copy drifted (design-law §ONE SOURCE OF TRUTH). // It is a var and not a const for that reason alone, exactly as // [taskDescription] is. +// AND THE PAIR THAT CHOSE THE ROAD WAS CUT ON 2026-09-11, because the page had +// come to say it word for word. "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." is beltfacts.go's "What must be checked and landed on +// 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. 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. 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 " + + "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." @@ -318,6 +347,7 @@ const quickTaskSchemaJSON = `{"type":"object","properties":{` + `"line":{"type":"string","description":"What to do, one sentence"},` + `"items":{"type":"array","items":{"type":"string"},"description":"Ordered steps it works through and ticks off"},` + `"files":{"type":"array","items":{"type":"string"},"description":"Paths it will write. Two claiming one path run one after the other; named none, it writes anywhere"},` + + `"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"}` + @@ -328,6 +358,7 @@ 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"` @@ -338,7 +369,7 @@ type quickArguments struct { // it waits on, and the two names a caller may give it. It is the tool's wire // form with the wire taken off, and it is the whole of what either road knows — // the tool parses one out of a call ([Agent.startQuickTask]) and the ceiling -// reads one out of a drawing ([Agent.quickFromDrawing]). +// reads one out of a drawing ([Agent.promotedFromTurn]). // // IT IS DELIBERATELY NOT A SPEC. Everything a quick node IS beyond these six // fields — where it runs, how it stands on its ground, that nobody names it, @@ -352,6 +383,12 @@ type quickAsk struct { dependsOn []uint64 title string model string + // 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 + // grow a quick node — the tool and the ceiling's carry-on — ask for it the + // same way and neither can build a promotion the other could not. + inherit bool } // askOf is the wire form read as an ask. It is a function rather than a @@ -359,7 +396,8 @@ type quickAsk struct { // this package's, and the two are allowed to drift apart. 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} + dependsOn: parsed.DependsOn, title: parsed.Title, model: parsed.Model, + inherit: parsed.Inherit} } // quickTools is the belt door for `quick_task`. @@ -445,6 +483,7 @@ func (a *Agent) admitQuick(ask quickAsk) (uint64, taskSpec, quickRefusal) { if refusal != "" { return 0, taskSpec{}, quickRefusal{said: refusal} } + if refused := graph.claimChild(spec.parent); refused != "" { return 0, taskSpec{}, quickRefusal{said: refused, fanFull: true} } @@ -567,7 +606,32 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { if named := strings.TrimSpace(ask.title); named != "" { title = clip(firstLine(named), titleLimit) } - quick := newQuickTaskSpec(line, kept, scope) + // AND A PROMOTION IS BUILT BY THE ONE FUNCTION THAT BUILDS PROMOTIONS + // (inherit.go), under the one bound there is, AGAINST THE WINDOW THE WORK + // WILL ACTUALLY RUN IN. That is why it stands below the resolver rather than + // above it: a `task.model` pin or the crew's worker seat hands tasks to a + // cheaper model than the person is talking to, and a bound measured against + // this conversation's own window would be a promise this road cannot keep. + // + // THE REFUSAL IS A RESULT THE MODEL READS — it names the two figures and the + // road that is still open — rather than a silent downgrade to a cold worker, + // because a verb that quietly did something cheaper than it was asked for is a + // verb nobody can reason about. + // + // AND THE PROMOTED NODE IS BUILT INSTEAD OF THE PLAIN ONE, never after it: + // [Agent.promotedQuick] IS [newQuickTaskSpec] with the seed hung on it, so a + // branch that built the plain one first would build the same node twice and + // throw one of them away. + var quick *quickTaskSpec + if ask.inherit { + refusal, fits := a.inheritFits(model) + if !fits { + return taskSpec{}, refusal + } + quick = a.promotedQuick(line, kept, scope) + } else { + quick = newQuickTaskSpec(line, kept, scope) + } quick.waits = a.graph().quickClaimsOn(a.config.Workspace, scope) for _, claim := range quick.waits { dependsOn = append(dependsOn, claim.id) @@ -598,7 +662,14 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { owner: a, request: a.taskRequest(), origin: a.taskOriginRef(), - admission: a.admissionContext(), + // AND AN INHERITED WORKER IS HANDED NO POINTERS, because it is holding the + // things they point AT. [AdmissionContext] exists to tell a cold worker + // where the calls it did not make ended up ("CALLS THAT HAVE ALREADY RUN", + // admission.go); a worker opening on the caller's own transcript has every + // one of those results in front of it in full, and a section of pointers + // beside them is an instruction to go and fetch what it already has. That + // is the 116 KB the measured worker re-read (inherit.go). + admission: admissionFor(quick, a), // WHERE IT RUNS, SAID HONESTLY ON THE CARD. There is no worktree and no // copy: the ground IS the workspace and the mode is the person's own "here", // so the card's "where" line names the folder the work is actually @@ -932,6 +1003,11 @@ func (a *Agent) runQuickNode(ctx context.Context, node *TaskNode, listed *job) T node.finish("the quick task could not be started: "+err.Error(), nil, "", "") return TaskFailed } + // AND A PROMOTED NODE OPENS ON ITS CALLER'S TRANSCRIPT (inherit.go). It is + // installed here, after the child exists and before its first request, and it + // does nothing at all for the ordinary quick node — which is every node that + // was not promoted. + adoptSeed(child, quick) defer func() { _ = child.Close() a.foldTaskUsage(node, child) diff --git a/internal/session/task_quick_door_test.go b/internal/session/task_quick_door_test.go index 1da7341925..4b93b05988 100644 --- a/internal/session/task_quick_door_test.go +++ b/internal/session/task_quick_door_test.go @@ -187,7 +187,10 @@ func TestTheCeilingsQuickNodeHasTheToolsShape(t *testing.T) { const asked = "work through the four things I listed and report back" const dowry = "Finish the four pieces\nwhat is left, and everything this turn already found out" - completer := &scriptedCompleter{steps: grindingSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, dowry)} + // A TURN GOING IN CIRCLES, because that is a road the net still takes a turn + // off: the rungs below the last one tell the turn and move nothing + // (inherit.go, [loopingGrindSteps]). + completer := &scriptedCompleter{steps: loopingGrindSteps(checkpointMarkAt(checkpointMarks), checkpointSplitSketch, dowry)} agent := checkpointAgent(t, completer, func(config *Config) { config.Divide = true }) ran := make(ranNodes, 2) stubbedGraph(agent, func(node *TaskNode) { ran <- node }) diff --git a/internal/session/taskcustody_unit_test.go b/internal/session/taskcustody_unit_test.go index 10776ac7fb..5f56daf820 100644 --- a/internal/session/taskcustody_unit_test.go +++ b/internal/session/taskcustody_unit_test.go @@ -669,7 +669,7 @@ func TestEveryEndingWritesOneRowAtTheSeamThatTookIt(t *testing.T) { } // AND THE FOUR SEAMS ARE FOUR WORDS, because a door spelled the same as // another door is a door nothing can tell apart afterwards. - seams := []string{checkpointSeamMark, checkpointSeamWrite, checkpointSeamCeiling, checkpointSeamWall} + seams := []string{checkpointSeamWrite, checkpointSeamCeiling, checkpointSeamWall} for i, one := range seams { for _, other := range seams[i+1:] { if one == other { diff --git a/internal/session/turnwall.go b/internal/session/turnwall.go index 5f322f18e3..965d931342 100644 --- a/internal/session/turnwall.go +++ b/internal/session/turnwall.go @@ -93,7 +93,7 @@ const taskAllowance = gitRootPatience + auditDeadline // share is moved. // // It is the register every line in this house is held to (checkpoint.go's -// [checkpointSplitNote] states it): an observation, a middle dot, a promise, all +// [checkpointCeilingNote] states it): an observation, a middle dot, a promise, all // lowercase, no full stop, nothing about machinery. // // WHAT IT OBSERVES IS THE CLOCK, because the clock is the only thing this seam diff --git a/internal/session/turnwall_test.go b/internal/session/turnwall_test.go index 0a617da086..c0cc0349ed 100644 --- a/internal/session/turnwall_test.go +++ b/internal/session/turnwall_test.go @@ -70,7 +70,7 @@ func TestATurnPastTheShareOfTheWallHandsOver(t *testing.T) { // AND IT MOVED ON THE CLOCK AND NOT ON THE LADDER. The split note is the // mark's own line, and reading it here would mean the turn had reached round // ten before anything noticed the wall. - if saidSomething(said, checkpointSplitNote) { + if saidSomething(said, checkpointCeilingNote) { t.Fatalf("the turn was moved by the mark ladder rather than by the wall; notices were %q", said) } lines := closedJournal(t, agent, transcript) @@ -119,7 +119,7 @@ func TestATurnUnderTheShareOfTheWallIsLeftAlone(t *testing.T) { if saidSomething(said, turnWallShareNote) { t.Fatalf("a turn a minute short of the share was moved by the wall; notices were %q", said) } - if !saidSomething(said, checkpointSplitNote) { + if !saidSomething(said, checkpointCeilingNote) { t.Fatalf("the mark ladder stopped governing the turn the wall left alone; notices were %q", said) } @@ -171,7 +171,7 @@ func TestATurnThatCannotBeCheckedBeforeTheWallIsNotMoved(t *testing.T) { if saidSomething(said, turnWallShareNote) { t.Fatalf("a turn with less than a task needs left was moved by the wall; notices were %q", said) } - if !saidSomething(said, checkpointSplitNote) { + if !saidSomething(said, checkpointCeilingNote) { t.Fatalf("the mark ladder stopped governing the turn the wall left alone; notices were %q", said) } diff --git a/internal/session/unattendeddoor_test.go b/internal/session/unattendeddoor_test.go index 37468f574a..5d9f9d08d7 100644 --- a/internal/session/unattendeddoor_test.go +++ b/internal/session/unattendeddoor_test.go @@ -61,7 +61,7 @@ func TestAHandoverWithWorkRunningCarriesOnAndHandsOver(t *testing.T) { if count := admitted(graph); count != 2 { t.Fatalf("%d nodes are in the graph, want the one still running and the one the handover started", count) } - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("the handover never said its line; notices were %q", noticeTexts(collected)) } if saidSomething(noticeTexts(collected), checkpointStoppedNote) { @@ -109,7 +109,7 @@ func TestAHandoverAtWhichTheGoalOwnerSaysDoneEndsTheRun(t *testing.T) { if count := admitted(graph); count != 1 { t.Fatalf("%d nodes are in the graph, want only the landed one: a finished ask started a task", count) } - if saidSomething(noticeTexts(collected), checkpointSplitNote) { + if saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("the handover said its line over an ask the goal owner had just called done: %q", noticeTexts(collected)) } if !saidSomething(noticeTexts(collected), checkpointDoneNote) { @@ -323,7 +323,7 @@ func TestAHandoverDoesNotSealOverWorkThatIsMoving(t *testing.T) { if saidSomething(noticeTexts(collected), checkpointStoppedNote) { t.Fatal("the turn was sealed over a unit of work that was still running") } - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("the work did not move; notices were %q", noticeTexts(collected)) } lines := closedJournal(t, agent, transcript) @@ -400,7 +400,7 @@ func TestAPersonsHandoverStillMovesTheWork(t *testing.T) { if count := admitted(graph); count != 1 { t.Fatalf("%d tasks were started at the mark, want exactly the one the handover moved", count) } - if !saidSomething(noticeTexts(collected), checkpointSplitNote) { + if !saidSomething(noticeTexts(collected), checkpointCeilingNote) { t.Fatalf("a person's handover never said its line; notices were %q", noticeTexts(collected)) } if saidSomething(noticeTexts(collected), checkpointStoppedNote) { @@ -411,29 +411,24 @@ func TestAPersonsHandoverStillMovesTheWork(t *testing.T) { } } -// splitSketchSteps is a turn that grinds past the first mark with a sidecar -// saying the work has parts — the shortest road to a handover there is. +// splitSketchSteps is a turn that grinds until its context can no longer hold +// another step — the runaway net's own rung (inherit.go), and the shortest road +// to a handover there is. It used to be a turn that crossed the first mark with +// a sidecar saying the work had parts; a mark moves nothing now. func splitSketchSteps() *scriptedCompleter { return &scriptedCompleter{ - steps: grindingSteps(checkpointMarkAt(1)+6, checkpointSplitSketch, + steps: grindingSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, checkpointSplitSketch, "Finish the four pieces\nwhat is left, and everything this turn already found out"), } } // writingSplitSketchSteps is [splitSketchSteps] for a turn that TOUCHED THE -// DISK, which is the turn the full handover road still takes: a drawing with -// parts out of a turn that only read is handed to a quick node instead -// (checkpoint_quick.go's [Agent.quickFromDrawing]). +// DISK. Both roads reach the same ceiling; what the disk decides is whether the +// work is PROMOTED where it stands or handed to a worker in a copy of the folder +// (checkpoint_quick.go's [Agent.promotedFromTurn]). func writingSplitSketchSteps() *scriptedCompleter { - // THE SCRIPT RUNS TO ONE SHORT OF THE SECOND MARK, which is slack rather than - // a figure: the drawing rides BESIDE the work now (checkpoint.go's [markAside]) - // and is spent at the next boundary it has landed by, so a fixture cut to the - // rounds the handover needs on a quiet machine is a fixture that runs its - // script out on a loaded one and hands nothing over. One short of the second - // mark is as much room as this road can be given without changing which rung - // it is about. return &scriptedCompleter{ - steps: writingGrindSteps(checkpointMarkAt(2)-1, checkpointSplitSketch, + steps: writingGrindSteps(checkpointMarkAt(checkpointMarks)+checkpointSlack, checkpointSplitSketch, "Finish the four pieces\nwhat is left, and everything this turn already found out"), } } @@ -568,7 +563,7 @@ func TestAHandoverTheGoalOwnerAsksForIsNeverDropped(t *testing.T) { // row against the ceiling at all. func TestTheCeilingUnderAStewardJournalsItsDecision(t *testing.T) { rounds := checkpointMarkAt(checkpointMarks) - steps := grindingSteps(rounds+checkpointSlack, checkpointDoneSketch, checkpointNothingLeft) + steps := grindingSteps(rounds+checkpointClaimSlack, checkpointDoneSketch, checkpointNothingLeft) agent, transcript := stewardCheckpointAgent(t, &scriptedCompleter{steps: steps}, nil) ran := make(ranNodes, 2) graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node })