Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions docs/changes/unreleased/1067-no-change-keeps-the-answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
kind: changed
title: a wrong completion check is answered with [no change], and the answer before it stays in view
pr: 1067
surface: [chat, engine, docs]
invalidates:
- "The end-of-turn reader was shown the first 600 bytes of the turn's last message (`checkpointSaidBytes = checkpointSketchBytes`), cut with a bare `…`. It is shown up to 8 KB now (`checkpointSaidBytes = 8 * 1024`), less when the ask fills the digest's head, and a cut it still has to make ends with `[clipped by codeaf: N of M bytes]` (`checkpointClipSaid`, which takes the room left). The remains ask (`checkpointRemainsAsk`) now says clipped text is never evidence the person saw a cut-off answer."
- "`checkpointCarryOnLead` told a model that found the note mistaken to `explain the evidence briefly, and finish`. It now says codeaf's completion check wrote the note, the person does not see it, and a wrong note is answered with exactly `[no change]` (`session.NoChangeReply`). The old lead is kept as `explainingCheckpointCarryOnLead` only so old journals still hide it."
- "A carry-on the model answered was always read again until `checkpointCarryOnCap` (3) or the standstill rule stopped it. A carry-on answered with `[no change]` now ends the turn at once, with no further reader call. It is honoured only as the very next reply to the reader's note (`answeredNoteUnchanged` reads the transcript): after tool calls, after a person's steer, after a load or ask nudge, or with other words beside it, the turn is read as before. A turn ending on the token neither starts the route judge nor hands the token to the memory reflex, which reads the answer before it."
- "Every assistant reply was a row a person could read back, and the fold's answer was the last one. A tool-less reply that is only `[no change]` is now left out of replay (`entryRows` answers 0) and emptied in the live feed at its response boundary (`feed.confirmResponse`), so the settled answer before it is what `deriveWorkfolds` leaves standing. `bin/codeaf chat --once` prints deltas as they stream and can still print the token."
- "`internal/session/prompts/system.md` had `# Tone` and `# Delivery`. It has `# The answer`, `# Answer or change`, `# When corrected` and `# Messages from codeaf` instead, all four kept on the lean page, and the rules the old two carried — match the person's task and vocabulary, choose the safe option under uncertainty, evidence and blocking details complete, never a compiling scaffold or a narrowed test, never half-solved work — live inside them. `# The answer` says that once a turn ends only its last message stays in view (a plan and the per-batch sentence are still shown while it runs), and `# Answer or change` says an answer asked for in chat is written in the reply, not a file, unless the person asks for a file (`Messages from codeaf` is an explicit `keeps: true` row). That section names the user-role tags codeaf writes to the conversation — `[carry on]`, `[taking stock]`, `[silent]`, `[stuck]`, `[folded …]`, `[context compacted]` — while `[image #N]` marks the person's attachment; it does not list the private reader's `[still asked]`, the tool result's `[held]`, or standing news's `[something you set up fired]`, whose line carries its own instruction under the event and is not repeated on the page (`TestTheSteeringLineReadsAsNewsAndNotAsARequest`). `TestMessagesFromCodeafNamesEveryUserRoleOpening` pins the list from the production constants. `for reversible work act and offer to unwind it` now ends `when the person asked for a change`, and `NEVER yield while actionable work remains` is `Don't end the turn while work the person asked for remains`."
- "The prefix byte waivers were `fixedPrefixWaiver = 5_291` and `leanPrefixWaiver = 13_566`. After the new sections and the provenance-list correction they are 7_280 and 15_555: fixed measures 55,280 bytes and lean 47,055, 2,004 bytes over `origin/dev` on both arms, and both waivers sit exactly on the measurement. The page's overall length is round two of #1065."
---

Round one of #1065. A person asked for a per-seat model table; the reader,
shown 600 bytes of it, said three times that it was cut off; the model
reprinted it twice and then argued, and the argument was the only answer left
standing under the `worked` fold.
30 changes: 30 additions & 0 deletions internal/manual/chat/starting-codeaf.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,36 @@ correctly reported that a file did not exist, and the reader claimed three times
running that the missing file had not been reported: the reply spent three turns
explaining that the observation was mistaken, and all three were on the screen.

## It said my answer was cut off when it was not · it argued with the check instead of answering · my table disappeared under worked · [no change]

When a reply ends, codeaf's completion check can read a summary of the turn and send the
model a note that starts with `[carry on]`. You never see that note, and you did not
write it. The model compares it with your request and its own work, then does one of two
things:

- **The note found a real gap.** The model closes the gap and ends with a complete final
answer. Only that last message stays in view when the turn folds under its `worked`
line, so it has to carry the whole deliverable again.
- **The note is wrong.** The model replies with exactly `[no change]` and nothing else.
That ends the reply. The check is not asked again, the same note is not sent again,
and the answer it gave before the note stays in view, in the chat and when the
conversation is opened again. It counts only as the model's very next reply to the
note. After tool calls, after your own message typed in between, or with any other
words beside it, the reply is checked again as usual. A reply that is only
`[no change]` is never drawn, whether or not it counted.
Headless `--once` output is printed as it streams, so if a check runs there the token
can appear in it.

The summary shows the check up to 8 KB of the reply's last message, less when your request
itself fills most of the summary. A longer message is
cut to fit and ends with `[clipped by codeaf: N of M bytes]`, and the check is told that
this cut belongs to the summary and is never a sign that you saw a cut-off answer. A
table or report under that size reaches the check whole.

Before this, the check saw only the first 600 bytes and the model was told to explain
itself when the note was wrong. A long table read as "cut off", the note was sent three
times, and the model's explanation ended up as the only visible answer.

## A task waiting on one that did not finish · work that will never start · it says something is still running

**Work that is still going is never a standstill, and never finished either.** A
Expand Down
18 changes: 17 additions & 1 deletion internal/session/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -4177,6 +4177,15 @@ const legacyCheckpointCarryOnLead = "[carry on] You stopped, but what was asked
"Somebody reading the work against the request says this is what is left. " +
"Carry on with it, and do not summarise what you have already done:\n"

// explainingCheckpointCarryOnLead is the continuation prefix journals wrote
// before [NoChangeReply] existed, when a model that found the note mistaken was
// told to explain itself (#1065). Its complete prefix is matched for the same
// reason as [legacyCheckpointCarryOnLead]'s.
const explainingCheckpointCarryOnLead = "[carry on] A reader of a bounded account of the work raised the observation below. " +
"Check it against the actual current work and the person's request before changing anything. " +
"Fix any confirmed gap. If the observation is mistaken or already satisfied, preserve the correct work, " +
"explain the evidence briefly, and finish; do not invent a change to satisfy the observation.\n"

// entryRows is how many rows the shaping above makes of ONE message: none at
// all for system messages and private continuation context, and otherwise the
// message's own row plus one for every tool call riding it. It is the rule the loop appends by, written
Expand All @@ -4197,10 +4206,17 @@ func entryRows(msg ai.Message) int {
// The continuation's full reserved lead identifies older journals too:
// they wrote it as an unmarked user message even though nobody typed it.
// Keep the model's record intact; only its display projection omits it.
if isVolatileNote(text) || strings.HasPrefix(text, checkpointCarryOnLead) || strings.HasPrefix(text, legacyCheckpointCarryOnLead) {
if isVolatileNote(text) || strings.HasPrefix(text, checkpointCarryOnLead) ||
strings.HasPrefix(text, explainingCheckpointCarryOnLead) || strings.HasPrefix(text, legacyCheckpointCarryOnLead) {
return 0
}
}
// A CARRY-ON ANSWERED [NoChangeReply] WITHDREW ITSELF AS THE ANSWER. The model
// needs the token in its record; a person reading the history needs the
// settled answer before it, which is what a missing row leaves standing.
if msg.Role == "assistant" && len(msg.ToolCalls) == 0 && IsNoChangeReply(messageContentText(msg)) {
return 0
}
return 1 + len(msg.ToolCalls)
}

Expand Down
5 changes: 3 additions & 2 deletions internal/session/carryonwait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,9 @@ func TestCarryingOnAnAskHasACeilingOfItsOwn(t *testing.T) {
// the same piece said twice.
//
// The model's continuation answer is scripted as a rebuttal rather than a
// change, which is what a model does with an observation that is already
// satisfied: the lead itself tells it to explain and finish.
// change, which is what a model did with an observation that was already
// satisfied while the lead told it to explain and finish — and what a model
// that ignores the lead's [NoChangeReply] still does.
func TestAReaderObservationThatWasAnsweredIsNotRaisedAgain(t *testing.T) {
const answered = "there is no zeta.txt, so nothing was written about it; the check ran and reported the miss"
const observation = "the missing zeta.txt has not been reported"
Expand Down
125 changes: 115 additions & 10 deletions internal/session/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,12 +423,54 @@ const (

// checkpointSaidBytes is how much of the turn's last words the reader is
// shown. It is the one part of the digest that is the model's own account of
// where it has got to, and a paragraph of it is the whole of what a reader
// needs to place the ledger above it — the same bound a sketch's own halves
// are held to, for the same reason.
checkpointSaidBytes = checkpointSketchBytes
// where it has got to — and, on a turn that answered in words, it IS the
// deliverable.
//
// IT WAS A SKETCH'S SIX HUNDRED BYTES, AND THAT WAS MEASURED WRONG (#1065). A
// person asked for a per-seat model table, the model wrote it, and the reader
// — shown its first six hundred bytes — said three times running that the
// table was cut off. The model reprinted it twice and then argued, and the
// argument was the only thing left standing under the fold. Eight kilobytes
// holds any table or report a turn ends on and is still under half of
// [checkpointDigestBytes], so the ledger keeps its room.
checkpointSaidBytes = 8 * 1024
)

// checkpointClippedMark is the line that ends last words the digest had to
// shorten. A bare `…` was the old marker and a reader took it for the answer
// itself breaking off; this one says in words that the cut is the SUMMARY'S, who
// made it, and how much was kept.
const checkpointClippedMark = "[clipped by codeaf: %d of %d bytes]"

// checkpointClipSaid fits the turn's last words into room bytes — never more
// than [checkpointSaidBytes] — marker included, and names the cut with
// [checkpointClippedMark] when it makes one. Words that already fit are returned
// untouched, and room too small to hold even the mark keeps nothing rather than
// a mark with no words in front of it.
//
// THE ROOM IS THE CALLER'S BECAUSE THE ASK SHARES THE PAGE. An ask up to the
// whole digest bound rides in the digest's head ([checkpointCompletionPage]),
// and eight kilobytes of last words behind a twelve-kilobyte ask would reach
// the digest's backstop clip — which cuts from the end with a bare `…`, the very
// marker this function exists to replace.
func checkpointClipSaid(said string, room int) string {
room = min(room, checkpointSaidBytes)
if len(said) <= room {
return said
}
// The room for the mark is measured at its WIDEST — the kept count can never
// exceed the room — so saying how much was kept cannot push past it.
widest := "\n" + fmt.Sprintf(checkpointClippedMark, max(room, 0), len(said))
cut := room - len(widest)
if cut <= 0 {
return ""
}
for cut > 0 && !utf8RuneStart(said[cut]) {
cut--
}
return said[:cut] + "\n" + fmt.Sprintf(checkpointClippedMark, cut, len(said))
}

// The digest's headings. They are SHOUTED and they are few, because what they
// have to do is let a reader tell four kinds of evidence apart at a glance in
// one message that also carries the ask.
Expand Down Expand Up @@ -694,6 +736,7 @@ const checkpointRemainsAsk = "[still asked] Above is what the person asked for a
"A successful write confirms only that bytes were written; it does not prove their contents meet the request. " +
"Tool failures and exact data mismatches outweigh an assistant claim of success. " +
"Abbreviated or omitted content is unknown, not evidence of a defect. " +
"Text marked as clipped was shortened for this summary only; it is never evidence that the person saw a cut-off answer. " +
"Ground any claimed defect in the evidence shown; if a necessary check is missing, name that check instead of inventing its result. " +
"In one line, say what of the ASK is still not done. " +
"If everything they asked for is done, answer with the single line " + checkpointNothingLeft +
Expand Down Expand Up @@ -909,10 +952,53 @@ func partsText(message ai.Message) string {
return text.String()
}

const checkpointCarryOnLead = "[carry on] A reader of a bounded account of the work raised the observation below. " +
"Check it against the actual current work and the person's request before changing anything. " +
"Fix any confirmed gap. If the observation is mistaken or already satisfied, preserve the correct work, " +
"explain the evidence briefly, and finish; do not invent a change to satisfy the observation.\n"
const checkpointCarryOnLead = "[carry on] codeaf's completion check read a clipped summary of this turn and raised the note below.\n" +
"The person did not write it and does not see it. Compare it with the person's request and your work.\n" +
"If there is a real gap, close it and end with a complete final answer.\n" +
"If the note is wrong, reply with exactly " + NoChangeReply + " and nothing else.\n"

// answeredNoteUnchanged reads the transcript for the one shape [NoChangeReply]
// is an answer to: the newest message is a tool-less reply that is only the
// token, and the message it answers is the reader's continuation
// ([checkpointCarryOnLead]). A volatile note that landed between the two is the
// harness's own context and is walked through; anything else is not.
func answeredNoteUnchanged(messages []ai.Message) bool {
last := len(messages) - 1
if last < 1 || messages[last].Role != "assistant" || len(messages[last].ToolCalls) > 0 ||
!IsNoChangeReply(partsText(messages[last])) {
return false
}
for index := last - 1; index >= 0; index-- {
text := partsText(messages[index])
if messages[index].Role == "user" && isVolatileNote(text) {
continue
}
return messages[index].Role == "user" && strings.HasPrefix(text, checkpointCarryOnLead)
}
return false
}

// NoChangeReply is the whole of the model's answer to a carry-on it judges
// mistaken, and the harness's token for "the answer already given stands".
//
// IT REPLACED "EXPLAIN THE EVIDENCE BRIEFLY, AND FINISH" (#1065). An explanation
// is words at the end of a turn, and words at the end of a turn are what a
// surface folds the turn down to — so a model that rightly refused a false
// "the table was cut off" left the person a rebuttal where the table had been.
// A token is a thing the model chose to say rather than a phrase the harness
// thought it heard, for [checkpointNothingLeft]'s reason, and it ends two things
// at once: carrying on for this ask ([Agent.checkpointReopen]), and its own
// place as the answer — the transcript keeps it for the model, and every surface
// leaves it undrawn ([IsNoChangeReply]) so the settled answer before it stands.
const NoChangeReply = "[no change]"

// IsNoChangeReply says whether a response's words are [NoChangeReply] and
// nothing else. It is the ONE reading of the token, used by the harness and by
// every surface that draws an answer, so the two can never disagree about which
// reply was withdrawn.
func IsNoChangeReply(text string) bool {
return strings.TrimSpace(text) == NoChangeReply
}

// ── the meter ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -2385,7 +2471,8 @@ func checkpointDigest(asked string, messages []ai.Message) string {
tail.WriteString(line)
tail.WriteString("\n\n")
}
if said := clip(checkpointLastSaid(messages), checkpointSaidBytes); said != "" {
room := checkpointDigestBytes - head.Len() - tail.Len() - len(checkpointDigestSaid) - len("\n\n")
if said := checkpointClipSaid(checkpointLastSaid(messages), room); said != "" {
tail.WriteString(checkpointDigestSaid)
tail.WriteString("\n")
tail.WriteString(said)
Expand Down Expand Up @@ -2767,7 +2854,10 @@ func checkpointLastSaid(messages []ai.Message) string {
for _, part := range messages[index].Content {
said.WriteString(part.Text)
}
if text := strings.TrimSpace(said.String()); text != "" {
// A WITHDRAWN REPLY IS NOT THE LAST THING SAID. [NoChangeReply] left the
// answer before it standing, and that answer is what a later reading is
// about.
if text := strings.TrimSpace(said.String()); text != "" && !IsNoChangeReply(text) {
return text
}
}
Expand Down Expand Up @@ -3198,6 +3288,21 @@ func (a *Agent) checkpointReopen(ctx context.Context, hub *eventHub, user userMe
return false, false
}
said := response.Text()
// A CARRY-ON ANSWERED WITH [NoChangeReply] ENDS CARRYING ON, AND IT COSTS NO
// READER. The model was handed the reader's note, compared it with the ask and
// the work, and said the note was wrong — so the turn ends on the answer it
// already gave, and nothing below may spend a reading to re-raise what was
// just rejected. Ending the turn is what keeps the note from coming back: the
// meter that counts carry-ons dies with it.
//
// IT IS HONOURED ONLY AS THE VERY NEXT REPLY TO THE NOTE ([answeredNoteUnchanged]).
// A token after calls made in answer to the note would end the turn on an
// answer those calls may have made stale, unread; a token after the person's
// own words, or after a load or ask nudge, answers something that is not the
// reader's note at all.
if meter != nil && answeredNoteUnchanged(a.snapshot()) {
return false, false
}
// A TURN THAT LOADED A TOOL AND STOPPED WITHOUT USING IT IS SENT BACK ONCE,
// AND THIS COSTS NO READER. It stands ahead of every gate below because it is
// not a reading of the work at all: it is the harness finishing something it
Expand Down
Loading
Loading