Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6d33dc1
checkpoint: the race's tightening never stands the first mark below h…
santoshkumarradha Sep 12, 2026
b8bbdcf
journal: billed arms leave their own call lines, and a turn that hopp…
santoshkumarradha Sep 12, 2026
61a3434
changelog: 1007 title under the one-line bound
santoshkumarradha Sep 12, 2026
f5c8797
turn fold: slices of a repeatedly read file point at the file, and th…
santoshkumarradha Sep 12, 2026
90accd4
session: a read the conversation already holds answers with a pointer
santoshkumarradha Sep 12, 2026
be5919c
lanes: the cache pin joins the lane chooser's enforced set rather tha…
santoshkumarradha Sep 12, 2026
75a1fc2
provider: the OpenRouter-takeover gate counts a refusal only when the…
santoshkumarradha Sep 12, 2026
7d8d932
tui3, session: a launch sweep names every live session on an older bu…
santoshkumarradha Sep 12, 2026
515d8bb
checkpoint: a probe pins the first-rung floor against race-at-any-round
santoshkumarradha Sep 12, 2026
5bc3d35
changelog: entries renumbered to the pull request they ship with
santoshkumarradha Sep 12, 2026
dd7164c
fmt: sweep and law-visit edits through gofmt
santoshkumarradha Sep 12, 2026
d2a6634
tests: pin the hop's split seal instead of the old one-line shape
santoshkumarradha Sep 12, 2026
0c0cf3d
provider: the gate's shape test is a demand, not the ladder's membership
santoshkumarradha Sep 12, 2026
8959aff
session: restore the offset/limit pin sentence and route held-reads t…
santoshkumarradha Sep 12, 2026
5429c8d
exec/bare: the edit merge sentence owns its whole rule, reclaiming th…
santoshkumarradha Sep 12, 2026
adce61b
changelog: 1015-stale-build-sweep surface fixed to the enum it checks
santoshkumarradha Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1015-cache-pin-frontier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: the cache pin is admitted into the lane chooser's enforced set rather than dropped by it
pr: 1015
surface: [chat]
invalidates:
- "A cache pin the lane chooser's speed frontier did not name was dropped from the wire by `applyLaneChoice`, so a warm-but-second-fastest endpoint lost the call and one measured chat session rotated through five endpoints in eight calls, each hop a cold 80-110k-token prefix. The pin is now added to the enforced `only` set and still leads its order; only a role's patience refusal or a person's own lane pin removes it."
---

The advisory `provider.order` first-entry was honoured 29% of the time over
ten days of call log, while a strict preference's named machine served 93%.
The pin now rides inside the enforced set — and the two removals it may still
suffer are the one's measured: a role's patience refusal (2026-09-11) and a
person's own lane row.
21 changes: 21 additions & 0 deletions docs/changes/unreleased/1015-first-rung-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
kind: fixed
title: The race can no longer pull the first taking-stock mark below half the handoff price
pr: 1015
surface: [chat, engine]
invalidates:
- "A raced both-yes from the pre-turn route judge pulled the checkpoint's first mark down to the very step boundary the verdict landed on (`checkpointMeter.tighten` set `firstAt = rounds + 1`). Measured on a real planning turn (ded9ace743f58581, 2026-09-12): the verdict landed two rounds in, the first `[taking stock]` fired at round three over one issue and two listings — 43 KB of mostly listing noise — the model waved the note past, and the round-twenty note inherited that worthlessness. `firstAt` is now clamped at `checkpointFirstRungFloor`, derived as `checkpointPrice / checkpointRatio` (round 5 today): early enough to catch a runaway, late enough that the note's rounds, files and bytes are real. An unraced turn's ladder is byte-identical — rungs at 10, 20, 40."
---

The tightening itself is untouched in purpose: it is triage, the power the race
kept when the benchmark took conversion away from it — look sooner, never
oftener. Nothing in the measurement that introduced it (the mark moving to the
verdict's own boundary) spoke to how early a note is worth sending, so the floor
follows the only evidence there is, the failure above: half the price, derived
from the two constants it is a compromise between rather than written as a
number of its own.

The manual's screen page needed no change: it describes the ordinary ladder
("after ten finished rounds, and again after twenty"), which is exactly what an
unraced turn still climbs, and it never documented the race's exception that
this bounds.
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1015-fold-rereads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: changed
title: The turn fold keeps the newest slice of a repeatedly read file and points the older ones at it
pr: 1015
surface: [engine, docs]
invalidates:
- "Every folded tool result used to be replaced by a pointer to a copy filed under the session's stubs folder. When one turn reads the same file several times, the older slices now fold to a pointer naming the file with the offset and limit that read it — no copy is filed — and the newest slice of the file stays whole."
---

Measured on a planning conversation that read one file in ten overlapping
slices and carried all ten, verbatim, through twenty requests: the fold bound
generic tool-result accumulation but had no notion that slices of one file are
re-readable where they came from. The trigger, target, batch and horizon laws
of the pass are unchanged; bash and every other tool's results keep their
existing rules.
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1015-gate-demand-refusals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: the OpenRouter-takeover gate counts a refusal only when the router's own pick was refused
pr: 1015
surface: [engine, docs]
invalidates:
- "The gate counted every named refusal toward the takeover, including the refusals a takeover's own narrowed demands came back with. On a live chat (2026-09-12) that looped four times — demand, 404, re-arm — each with a full ~80k-token re-send, before the ladder's first rung went bare. Only a refusal of a bare (membership-unnarrowed) attempt counts now; a narrowed demand's refusal is evidence about our choice and never enters the gate (routefirst.go's Client.noteRouterRefusal)."
---

The manual page for lanes said every refused or unusable answer counts toward
aforge taking over from the router. It now says only refusals the router
itself earned count, and the takeover's own demands cannot keep it alive.
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1015-journal-billed-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: billed arms journal their call lines, and a hopped turn seals one usage line per model
pr: 1015
surface: [chat, engine]
invalidates:
- "A transcript's `call` lines were written only for requests whose usage block arrived on the stream. A billed request reconciled from a late receipt — a hedge arm or a cut retry — now writes one too, marked `arm: hedge` or `arm: reconciled`; it is evidence like every call line and moves no total."
- "A turn's `usage` seal was one line naming the model latched at the seal, so a mid-turn model hop attributed the whole turn to the last model. The seal now writes one line per model that answered, each with that model's own tokens, calls and cost; the turn's wall time rides the alphabetically first line only."
- "`session.Usage` was a plain comparable struct. It now carries a `*modelShares` breakdown field — still comparable and still nil on every value but a live turn's — so `usage == (Usage{})` keeps compiling."
---

The measured planning session's journal summed to $0.789 of a $1.412 bill, and
its one turn seal named deepseek for 31 requests that z-ai/glm-5.3 had served
thirteen of. Both gaps were the same shape: the expensive requests left no
evidence. The meter was always right; only the journal was silent, which is why
neither fix touches a total.
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1015-read-held-ranges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: fixed
title: a `read` of lines the conversation already holds answers with a pointer
pr: 1015
surface: [engine, docs]
invalidates:
- "`read` fetched from disk on every call, no matter what was already in the transcript — measured: one planning chat issued eighteen reads of the same file as seven sliced ranges. A range fully covered by bytes the conversation still carries, with the file's stamp unmoved, now comes back as the one-line pointer `[already read] <path> lines X–Y …`, and a partial overlap or a changed file still fetches whole."
- "`read`'s tool description priced only tokens, so a model slicing a file small had no reason to broaden it. It now states that each call is one round trip and that a range already in the conversation answers as a pointer, the system prompt's BELT_FACTS area teaches the pointer sentence beside the stub-pointer line, and the chat manual's `what-i-can-do` page quotes the pointer and the fresh-after-a-change rule."
---

The ledger (internal/session/heldreads.go) is per conversation and in memory
only. A span counts as held only while its transcript message still carries the
bytes verbatim — a stubbed or folded result drops out of coverage by itself —
and a changed stamp retires the file's whole entry, never merging two
generations of the file into coverage no version ever had.
22 changes: 22 additions & 0 deletions docs/changes/unreleased/1015-stale-build-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
kind: added
title: a launch sweep names live sessions on an older build
surface: [chat, engine]
pr: 1015
---

Every launch now sweeps `~/.aforge/v3/projects/*/presence.json` and names any
live session holding a rev other than this binary's, with the serving pid —
the third binary-shadow incident ends with a visible warning instead of a
session that "stopped working" on the old rev.

`internal/session` exports [SweepStaleBuilds] — a fail-open glob + freshness
filter, `presenceWindow` the only liveness rule — and `internal/tui3`'s open
path calls it once beside the landing-keys note.

The law the sweep obeys is the presence file's own: age is the only liveness
check (the pid is reported for a person's `kill`, never consulted), which is
the same rule every other reader of those files has.

Manual: starting-aforge.md's `/status says an older build is holding this
conversation` documents the line.
25 changes: 23 additions & 2 deletions internal/exec/bare/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,35 @@ const lsSchemaJSON = `{"type":"object","properties":{"path":{"type":"string","de
// every request on the belt.

func readDescription(caps Caps) string {
return fmt.Sprintf("Read a file. Text, or an image (jpg, png, gif, webp, bmp) which comes back as an attachment. Text is cut at %d lines or %s, whichever comes first; page the rest with offset/limit.", caps.MaxLines, sizeWord(caps.MaxBytes))
return fmt.Sprintf("Read a file. Text, or an image (jpg, png, gif, webp, bmp) which comes back as an attachment. Text is cut at %d lines or %s, whichever comes first; page the rest with offset/limit. Each call is one round trip; an already-read range answers as a pointer.", caps.MaxLines, sizeWord(caps.MaxBytes))
}

// ── what a read result's text can conclude about the file ────────────────────

// ReadPagingFooter reports whether a read result's text carries either of the
// two paging footers [readTool] appends. The converse is the ONLY door the
// holder of those bytes learns the file ended inside what it got: the footers
// are the tool's own sentences, built here and matched here, so the session's
// ledger reads the format at its one seam instead of guessing at strings.
func ReadPagingFooter(text string) bool {
return strings.Contains(text, "[Showing lines ") || strings.Contains(text, " more lines in file.")
}

// ReadContentless reports whether a successful read answer carried no file
// bytes at all. The ONE such answer is the exceeds-limit line diagnostic
// [readTool] emits instead of content, and the held-ranges ledger must never
// record it as bytes the conversation holds.
func ReadContentless(text string) bool {
return strings.HasPrefix(text, "[Line ") && strings.Contains(text, " exceeds ")
}

func bashDescription(caps Caps) string {
return fmt.Sprintf("Execute a bash command in the current working directory. Returns stdout and stderr, cut to the last %d lines or %s, whichever comes first; when it is cut the whole output is saved to a temp file the result names. Optionally provide a timeout in seconds.", caps.MaxLines, sizeWord(caps.MaxBytes))
}

const editDescription = "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes."
// The merge sentence owns the whole of it; "do not include large unchanged
// regions to connect distant changes" said the same thing twice.
const editDescription = "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits."

const writeDescription = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."

Expand Down
5 changes: 5 additions & 0 deletions internal/manual/chat/compacting-over-and-over.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ has not produced work stays verbatim, even when keeping it costs more tokens. Th
of a result that does become a pointer remain in the session journal and the path named by
the pointer.

One refinement for repetition: when the turn has read the **same file several times**, only
the newest slice stays whole. Each older slice becomes a pointer naming the file with the
`offset` and `limit` that read it, and no copy is filed — the file itself is where those
bytes came from, and `read` brings the slice back.

## A pass that cannot reach its target

The fold walks the oldest assistant work first and stops at the target, but it never folds
Expand Down
11 changes: 7 additions & 4 deletions internal/manual/chat/lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ answer on it is being learned like any other.
back refused (a 429, a machine that cannot serve the shape) or unusable (the
thread lost, tool markup, a stream that had to be cut) — twice in a short while —
aforge stops lending the router the choice and picks the machine itself, from the
lanes it has been watching all along. The conversation says so once, in one
sentence, and after about half an hour of good answers the choice is the
router's again. Pinning a machine yourself in `/model` ends it there and then:
your word outranks either of them.
lanes it has been watching all along. Only a refusal the ROUTER earned counts:
once aforge is the one choosing a machine — during a takeover, or under your
pin — a refusal of that pick is about the pick, not another strike against
the router, so a takeover's own demands cannot keep it alive. The conversation
says so once, in one sentence, and after about half an hour of good answers the
choice is the router's again. Pinning a machine yourself in `/model` ends it
there and then: your word outranks either of them.

**`openrouter` is `auto` without the safety.** It is the same router routing, and
aforge never takes over no matter what comes back. Choose it when you would
Expand Down
6 changes: 6 additions & 0 deletions internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,12 @@ runs, the transcript gets one line such as `[folded 8 results · ~24k tokens]`.
result bytes remain in `logs/stubs/`, the model can `read` the path in each stub, and the
session journal keeps the original result bytes.

One shape of result needs no copy filed. When the turn has read the **same file several
times**, the older slices point at the file itself — the stub names the path with the
`offset` and `limit` that bring that slice back — and the newest slice is kept whole,
because those bytes were never anywhere else to begin with. The journal keeps them too,
as it keeps everything.

**Rung 0 — what an old result looks like in the request.** Before any of the rungs below
fire, the copy of the conversation that goes to the model already carries the tool results
of *earlier* turns shortened. Your transcript and the session journal keep every byte; only
Expand Down
12 changes: 12 additions & 0 deletions internal/manual/chat/sessions-and-rewind.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@ planner, a harness run. Those are marked `aux` so a reader can tell the turn its
the work done around it. A turn that spent nothing writes no line at all, because a zero is
absence, not a fact worth a line.

One exception to "one line per turn": a turn that was answered by **more than one model**
— the model hopped mid-turn — writes **one line per model that answered**, each with that
model's own tokens and money. A single sum could only name one of them, and naming the one
standing at the end would hand the whole turn's bill to the wrong model.

**On a resume these lines are added up, and that sum is the session's totals.** So `/cost`,
`/status` and the status line show what the whole conversation has spent across every
restart, not only what has happened since you reopened it. The spend ceiling reads the same
Expand All @@ -340,6 +345,13 @@ answered, the endpoint that served it when the provider names one, tokens in, ca
cache write, tokens out, and the provider's own figure for the money. A request the
provider reported no usage for writes no line — a row of zeros would read as a fact.

The one exception is a request whose usage block **never arrived** — a stream cut short,
or a rescue arm that ran beside a slow answer. Those are exactly the expensive requests,
and a record of only the cheap ones reads as a smaller bill than you paid. So when the
provider's own **receipt** is fetched afterwards, the request writes its line then, marked
with `arm`: `hedge` for a rescue arm, `reconciled` for any other late receipt. A receipt
names no endpoint, so the line leaves that field out rather than guess.

These lines are **evidence, never spending**. Nothing adds them up, `/cost` does not read
them, and a resume ignores them entirely, because every dollar on them is already in the
`usage` line that sealed the turn. They are there so a question about *shape* — what does a
Expand Down
15 changes: 15 additions & 0 deletions internal/manual/chat/starting-aforge.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ the life of the conversation. Starting in the "wrong" place is not a dead end,
though: name the folder you actually meant with `/folder`, and the work goes
there.

## /status says an older build is holding this conversation

On every launch aforge performs one sweep: it reads every presence file under
`~/.aforge/v3/projects`, and if a live session there is running from an older
rev than this binary's own, one line in the conversation names the pids. That
is all `on an older build` means — the sweep warns. It never blocks, never
locks, never kills.

The `/status` build line shows the serving process's build: for a chat that is
the process typed `aforge` with; for a conversation hosted by the resident
engine (`engine --daemon`) it is still whatever binary the engine launched
from. In both cases the fix is a close: close the older session or the older
engine, and its next open runs this build. No line at launch means nothing
stale is running.

## The flags you can start it with

| Flag | What it does |
Expand Down
7 changes: 7 additions & 0 deletions internal/manual/chat/what-i-can-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ cut is announced so paging is possible:
the end is an error: `Offset 900 is beyond end of file (120 lines total)`.
A single line over the size cap is reported, not shown.

A read for a range already in the conversation comes back as a pointer instead
of fresh bytes: `[already read] <path> lines X–Y are in this conversation above
— use those bytes.` Once any part of a file has been read here, asking for it
again is answered, not fetched — which keeps paging cheap. A file that changed
on disk since is simply read fresh, and a range some of which was never read is
fetched whole.

## How much of a file does one read return — what is the truncation limit

**The limit follows the model's context window: a tenth of it, and never more
Expand Down
Loading
Loading