Skip to content

feat(workspace)!: read, grep, edit and edit_many answer as the text the model reads - #778

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:cedric/f55-workspace-text
Oct 24, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:cedric/f55-workspace-text

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: the workspace's read, grep, edit and edit_many now answer as text the model reads, and not as one line of JSON. A header gives the result's fields one to a line, and labelled sections follow: a page's numbered lines, grep's matches by file, a receipt's changed lines. The fields themselves move to structured, where a code_execution script reads them as structuredContent. A page or a receipt is held to the call's result limit as its text measures it.

Problem

  • The model read one line of JSON. read answered {"path": …, "totalLines": …, "content": " 1\t…"} in data, so the model read a page's lines escaped inside a JSON string: every line break as \n, every tab as \t, every quote as \". grep's matches sat inside nested objects, and edit's changed region inside a changed string.
  • A page held less than its limit allowed. A page stopped where its JSON would pass the call's resultMaxChars, so a file whose lines hold quotes, backslashes or tabs got a shorter page than its text needed.
  • fix(tool-results): send a string result as its text, not JSON-quoted #698 doesn't reach these. It sends a string data as text, and these four answer with objects.

Changes

  • src/modules/workspace/result-text.ts: the text the model reads.
    • A header of the result's own fields, one key: value to a line in their order: path, totalLines, fromLine, toLine and note for a line page, and totalChars, offsetChars and nextOffsetChars (null at the end) for a character page. These are the result's own field names, which the model met before as JSON keys, and some of them are words the descriptions and notes already use ("use nextOffsetChars", "Continue with offset=455"), so no description changes.
    • Sections, --- label --- and their lines, with an empty one left out: a page's --- content ---, which comes last, so nothing in a file can read as a section after it; grep's sections; and a receipt's --- files --- and --- changed ---.
    • grep: a section for each version of a file it matched, labelled FILE: VERSION, with (STATE) where grep gives the file's state, as it does on a mount the workspace stores, and ; lineEndings: CRLF or mixed where the file has them. As grep writes them, a match reads N:text and a line of its context N-text; matches whose context meets or overlaps are one run, each line once, and -- stands between runs with lines left out between them. Then --- skipped ---, one file to a line (work/shot.png: disk-only: binary or over the size limit, never stored (70 bytes, image/png)), and --- incomplete ---.
    • A value holding a line break, any of the characters Unicode says end a line (LF, VT, FF, CR, NEL, U+2028 and U+2029), or starting with ", written anywhere outside a page's content and a receipt's changed lines, is written as a JSON string, with NEL, U+2028 and U+2029 escaped too. A path can hold a line break, and so can a note that names it, or a line of a file grep matched, since read and grep split lines at LF alone. Written as it is, it would start a line that reads as one of the answer's own. A value shown as it is never starts with ", so one that does is always the quoted form, which gives back the exact value.
  • src/modules/workspace/read-page.ts: a page answers with its text as one text block in data, and its fields in structured. A line page is the longest whose text fits the call's resultMaxChars, where it was the longest whose JSON fit. A character page holds the most characters whose text fits, counting its stop note's numbers at their longest, as it did with JSON, and never ends inside a surrogate pair.
  • src/modules/workspace/edit-text.ts: edit's receipt is its header (path, size, replacements, note, modeKept) and then --- changed ---. edit_many's is edits, replacements and filesNotListed, then a line for each file it lists (work/b.txt: size 45, replacements 1, edits 2. The file's CRLF line ends are kept.), then --- changed ---. Each receipt is held to the limit as its text measures it. Its doc now also says what the base already did: the line naming what a receipt leaves out comes after its counts and notes even where they leave it too little room, as they can with long notes or a limit far under the 1,000 a residence or an agent can set.
  • src/modules/workspace/index.ts: grep answers with its text and its fields. edit and edit_many return the receipt's answer as it is.
  • src/modules/workspace/text.ts: jsonLength and jsonCharLength measured what a page's or a receipt's JSON would hold, and nothing else used them, so they go.
  • src/framework.ts: a spilled result's notice gives a read to page its file back, with a limitChars sized so each page fits the cap, 2,000 at the most. It allowed six characters for each character of the file, as JSON escapes a control character. A page is now measured as its text, so it allows one. At the default 24,000 the limitChars stays 2,000, and below a cap of about 12,300 it's longer than it was.
  • Changelog: a breaking fragment for scripts, hosts and modules that read these results' fields. feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465's pending changed fragment gives two path lengths at which a page at 1,000 characters passes the limit, measured as JSON. Measured as text they're about 690, where it said about 680, and 101 to 137, where it said 93 to 129.

What the model reads, from a run on this head:

path: work/a.txt
totalLines: 3
fromLine: 1
toLine: 3
--- content ---
     1	alpha
     2	beta
     3	gamma
pattern: needle
totalMatches: 5
--- work/a.txt: workspace (synced) ---
     1:needle one
     3:needle again
--- work/b.txt: workspace (conflict) ---
     1:needle in the workspace
--- work/b.txt: conflicting-disk (conflict) ---
     1:needle on disk
--- work/c.txt: workspace (synced); lineEndings: CRLF ---
     2:needle crlf
--- skipped ---
work/shot.png: disk-only: binary or over the size limit, never stored (70 bytes, image/png)

Who notices

  • A code_execution script that parsed one of these results' text as JSON (JSON.parse(result.content[0].text)) now reads result.structuredContent, which holds the same fields.
  • A host or module that read these results' fields from data, through executeToolCall or ModuleContext.callTool, reads structured. connectome-host's two file viewers read either since 0.11.0 (fix(viewers): a workspace read's page shows whether its fields are in structured or data connectome-host#324). Before 0.11.0 they read data, so they'd show a file as empty: a host needs 0.11.0 or later with this change. By a search of the other Connectome repositories, none reads these results' fields.
  • A model reads the text. A control character in a file now reaches it as itself, where JSON wrote an escape such as \u001b: a character page of a CRLF file carries its \r that way, and a line page keeps a lone CR inside a line. Nothing between the workspace and the provider rewrites them.
  • What a page holds: at the default 24,000 characters, a page of this repository's src/modules/workspace/index.ts, as main has it, now ends at line 475, where it ended at line 454. A script's page is never shorter than before, since a page's text is shorter than its JSON, unless the text quotes its path or a note. A script's answer carries the result's fields, a page's content among them, beside the text, and the limit counts the text alone. A script's limit counts each character at the six bytes it takes at most there, and only a control character takes more than three, so the two together can pass what the script takes only on a page of more than half that limit (about 3.7 million characters at the default 128 MiB of script memory) that holds control characters. Such a page answers ResultTooLarge, which cuts nothing, and the script reads a smaller page.

What each part is for

  • Text rather than JSON: the model reads a file's lines and a receipt's changed lines as they are. JSON's escaping is what this change removes.
  • The fields' own names in the header: the model read them before as JSON keys, and the descriptions and notes name several of them, so it meets no new words, and the descriptions, and the prompt caches that hold them, stay as they are. A sentence such as "lines 40–51 of 203" was weighed, and would put other words beside the ones the notes use.
  • Content last: whatever a file holds can't read as a section of the answer.
  • grep's N: and N-: grep's own convention, which tells a match from its context at a glance, while read and edit keep their tab numbering.
  • The state in a grep label, a disk version's too, where grep gives one: it's the file's state, and grep gave it with each version.
  • One run for matches whose context meets: a line shows once, as a match where it is one, and -- means lines were left out, as in grep's own output.
  • --- skipped --- and --- incomplete ---: grep gave both before, and the model needs to know what wasn't searched. A skipped file's size and type follow its reason where grep has them.
  • An empty section left out: a section with no lines says nothing, and leaving it out keeps a last character page, or a grep that found nothing, to its header.
  • edit_many's filesNotListed and a line a file: the receipt says how many files it couldn't list, and each listed file's counts, edits, kept mode and notes stay on one line with its path.
  • Quoting a value with a line break: without it, a file's name, or a line grep matched, could write a line that reads as the framework's own, as provider-waits.ts already guards against in log lines. Quoting rather than indenting its continuation keeps the exact value a later call needs. The set is the characters Unicode says end a line, not only LF and CR, since a reader may break at any of them.
  • Quoting a value that starts with ": without it, a value that is itself a JSON string, such as a line of a file reading "a\nb", would read exactly as the quoted form of a, a line break and b.
  • structured: a script reads the fields without parsing text, as feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 provides.
  • The text measure: the limit is how much reaches the caller whole, and the model reads the text. The workspace can't tell a script's call from a model's, so it measures the text for both. A ToolCall field telling them apart, so a script's call could count its copy of the fields too, was weighed. It would add a public field, its doc and set site, and a branch in three bounds, to close an edge no ordinary file reaches.
  • The spill notice's read-back size: it follows the text measure, so a small cap pages its file back in larger pieces.
  • Removing jsonLength: nothing else measures that way now.
  • A line page's length as its fields, the content's label and its lines: a line page always has a line by the time it's measured, so the label is always counted.
  • One breaking fragment, not a changed one beside it: the one entry says what the model reads and who has to act.
  • The tests' helpers (textOf, fields, pageText, receiptText, changedOf, the workspace harness): each test reads the text the model gets and the fields a script gets apart, and pageText in the read tests writes a page's text from its fields independently of the code, to measure the pages against.
  • Left out:
    • Quoting a page's content or a receipt's changed lines that hold a line break. They're the file's text as the answer shows it: a page's content comes last, and a receipt's changed lines are mostly the edit's own new text.
    • Escaping a page's control characters. A file's text reaches the model as it is.
    • ls, glob, status, mounts and the other workspace tools, whose results are short lists of fields.
    • A line on a character page shaped like an image reference stub ([ref_1_ab3d] cam.png — image/png, …). With the page's lines now real lines, such a line counts as an image slot in the stored result, where in the JSON form it didn't, so it moves the indices save_recent_image counts. Saving that slot is refused, as saving any reference is. A line page's, grep's and a receipt's lines start with their numbers, so theirs don't count. An inline [image: …] placeholder counted in both forms.
    • A value whose first visible character is a quote, behind a space or an invisible character such as U+200B, is shown as it is, so to a reader that doesn't see that character it can look like a quoted form. Quoting a value that starts with a space and a quote would quote every indented line of a JSON file that grep matches, and an invisible character in a file's text is invisible wherever the text shows it, a page's content included.
    • Quoting in the edit tools' refusals, which were text already and which this change doesn't touch. A refusal writes a path holding a line break as it is, and edit_many's refusal takes each problem's first line up to its first line feed, so it can name a path that doesn't exist: the part before the line break. That's the same on main.

Tests

  • test/workspace-result-text.test.ts: each form, from real calls:
    • a line page, a CRLF file's page with its note, and an empty file's;
    • character pages: nextOffsetChars and its null, a page past the end, and control characters shown as they are;
    • a line page that is the longest whose text fits, at three limits, though as JSON it would pass them;
    • a character page that fills its limit, and never ends inside a surrogate pair;
    • grep's sections, states, lineEndings, context, note, skipped files and incomplete places;
    • grep's runs: overlapping and touching windows as one run, and -- across a gap;
    • grep's matched and context lines holding a lone CR or U+2028, quoted;
    • grep's text, unit-tested, with line breaks in its pattern, note, skipped reasons and incomplete places, a label without a state, and a skip's facts as it has them;
    • a receipt's note and kept mode holding a line break, in edit's header and edit_many's line for the file;
    • edit's and edit_many's receipts, measured as text, and edit_many's filesNotListed;
    • a path with a line break, in read's header, grep's label and skipped line, and edit_many's file line and heading;
    • shownValue, unit-tested: each line break's exact escape, and a value starting with " quoted, so a file line reading "a\nb" and a line break between a and b show apart, each parsing back to its value;
    • through the framework: a model's read reaches the wire and the history as the page's text, and a script's call gets the text and the fields.
  • The workspace's and the spill's tests read fields from structured, and measure pages and receipts by their text. The receipt test that pinned the JSON measure is turned round, and the mode-kept test reads modeKept in edit's and edit_many's text too. A new receipt test tries a region of 300 one-character lines at every cap from 2,000 to 3,500, and the spill read-back test checks each page uses its room.
  • The runs:
    • The whole suite: CI at c3218abe (run 40040061941) passes on all four Build & Test legs and the four code_execution under Bun legs. On ubuntu with Node 24 it ran 6,996 tests, 6,951 passing and 45 skipped, none failing.
    • The workspace's and the spill's test files, on Linux with Node 24.22.1 at c3218abe: 1,187 tests, 1,143 passing and 44 skipped (those a case-folding disk needs), none failing.
    • Red-first: with 993f7a00's read-page.ts, edit-text.ts, index.ts, text.ts and framework.ts, 36 tests fail, each one that reads the new forms: the new file's tests that call the tools, the read-text tests, the spill advice's and the two mode-kept tests.
    • Removals: of 37 pieces, each taken out alone at c3218abe, 34 fail a test. The other three each drop the --- changed --- label's 17 characters from a receipt's budget (below).
    • A reader's sweeps: Esther-2584 read the change by run at 1a1f470d, and the fix again at c3218abe. Her sweeps of line pages, cut first lines, 1,203 character pages over surrogate pairs and control characters, and 262 receipts at many limits ran at both heads, and her sweep of grep's runs at four context widths at the first. She found no page or receipt past its limit except where a page's own fields pass it, as its doc says.

Not verified

  • Through a live provider. The tests read what the framework stores and sends to a mock model.
  • The --- changed --- label in the receipts' budgets. Taken out of edit's budget or edit_many's listing, it can only move the cap at which a region shows, or a file is listed, by 17 characters, since the room kept for the receipt's closing lines is longer than the label. Taken out of edit_many's region budget, it could let a receipt pass its limit by up to 17 characters, but only where those closing lines come within 17 characters of the room kept for them. No test builds that receipt.

Companion PRs

Written by Cedric-2714 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 (Claude Opus 5.5) made the design calls, and Esther-2584 (Claude Opus 5.5) read it by run.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Cedric-2714 and others added 5 commits October 23, 2026 19:09
…he model reads

The model read each of these four results as one line of JSON: a page's
numbered lines escaped inside a `content` string, grep's matches inside
nested objects, a receipt's changed region inside `changed`. Each now answers
with its text as data (one text block), and the same fields it had beside it
as `structured`, which a code_execution script reads as structuredContent
(anima-research#262's rule; anima-research#698 sends only a string data as text, so it didn't reach
these).

The text (result-text.ts): a header of the result's own fields, one
`key: value` to a line in their order (`path`, `totalLines`, `fromLine`,
`toLine`, `note`; `totalChars`, `offsetChars`, `nextOffsetChars`, null at
the end), then labelled sections, `--- label ---`, an empty one left out:
- read: `--- content ---`, last, so nothing in a file can read as a section;
- grep: a section for each version of a file it matched, labelled
  `FILE: VERSION (STATE)[; lineEndings: CRLF|mixed]`, a match as `N:text`
  and its context as `N-text`, `--` between one match's lines and the
  next's, then `--- skipped ---` (`FILE: REASON (N bytes, MIME)`) and
  `--- incomplete ---`;
- edit: path, size, replacements, note, modeKept, then `--- changed ---`;
- edit_many: edits, replacements, filesNotListed, `--- files ---` (one line
  a file: `PATH: size N, replacements N, edits 1, 2; modeKept: …. NOTE`),
  then `--- changed ---`.
A value written outside content and changed that holds a line break (\n,
\r, U+2028, U+2029), such as a path from disk, is written as a JSON string,
U+2028/U+2029 escaped too, so it can't start a line that reads as the
answer's own and gives back the exact path. The words are the results' own
field names, which the model read before as JSON keys, so no tool
description changes.

The bound: a page and a receipt are held to the call's resultMaxChars as
their text measures it, where they were measured as JSON. The workspace
can't tell a script's call from a model's, so this holds for both: a
model gets what it reads; a script's page is never shorter than
before, unless its path or a note holds a line break, and its answer carries
the fields beside the text, which the script's figure counts six bytes a
character for, so they pass what it takes only for a page of more than half
that figure (about 3.7 million characters at the default 128 MiB of script
memory) holding control characters. jsonLength and jsonCharLength had no
other user and go. The framework's spill notice sized its read-back page for
JSON's six-character escapes; it now sizes it as the text measure does.

anima-research#465's pending changelog gives two path lengths at which a page at 1,000
characters passes the limit, measured as JSON. Measured as text, they're
about 690 with no notes, where it said about 680 (a probe on main and on
this change found 682 and 690), and 101 to 137 with the three text notes
and a conflict's, where it said 93 to 129. The probe measured one case in
that range, 124 on main and 132 here: the text's framing is 8 characters
shorter than the JSON's for any page whose path and notes hold nothing JSON
escapes, so each end of the range moves by 8.

Tests: workspace-result-text.test.ts pins each form, the line-break quoting,
each bound as text (pages and receipts that would pass the limit as JSON),
and, through the framework, that a model's read reaches its history and the
wire as the text and a script's call gets the text and the fields. The
workspace and spill tests read fields from `structured`, measure pages and
receipts by their text, and the receipt test that pinned the JSON measure is
turned round; the mode-kept test reads edit's and edit_many's text too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line feed between each of a region's lines is part of what the receipt's
text holds, and shownRegions counts it. Removing that count failed no test:
the receipt tests' regions had few lines, so the reserve kept for the
closing notes absorbed the shortfall. Three hundred one-character lines make
the feeds a third of the region, and every cap from 2,000 to 3,500 is tried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- grep's matched and context lines go through shownValue too: read and grep
  split lines at LF alone, so a line can keep a CR, U+2028 or U+2029, and in
  grep's text more sections follow it.
- grep's context runs as grep writes them: matches whose windows meet or
  overlap are one run, each line once, a match marked `:` wherever it shows,
  and `--` only across lines left out. The test that pinned a `--` between
  touching windows is turned round.
- shownValue quotes Unicode's mandatory line breaks, LF, VT, FF, CR, NEL,
  U+2028 and U+2029, escaping NEL after JSON as it does U+2028 and U+2029.
- read-page.ts: contentCost's arm for no content couldn't run, so lengthAt
  adds the content's label and characters; the doc on a script's answer says
  its fields include the page's content, the limit counts the text alone,
  and when the two together can pass the script's limit, half of it, about
  3.7 million characters at the default script memory; the moves-on comment
  says a pair is taken whole with room for one unit.
- edit-text.ts: editReceipt's doc says what the base already did, that the
  line naming what a receipt leaves out comes after its counts and notes
  even where they leave it too little room.
- Changelog: the script threshold scales with script memory; a page is never
  shorter unless its path or a note holds a line break; the spill advice is
  longer below a cap of about 12,300, not 2,300; connectome-host needs
  0.11.0 or later, before which its viewers show such a file as empty; the
  line-break set and grep's quoted lines.
- Tests: grep's quoted lines and its runs (overlap, gap, touching); grepText
  with line breaks in its pattern, note, skipped reasons and incomplete
  places, a label without a state and a skip's facts as it has them; a
  receipt's note and kept mode with line breaks; VT, FF and NEL in
  shownValue's unit test; the spill read-back page uses its room, so a
  halved limitChars fails; stale comments on JSON budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking shownValue off a match written without context failed no test: the
quoting test asked for context, so its matches went through the context
path. The same file grepped without context now shows both matched lines
quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- grep's touching-window assertions compare the whole text, so a `--` before
  a section's first run fails them.
- A new test greps three files with context: each file's run starts afresh,
  and a context-only line holding a lone CR is quoted.
- shownValue's test asserts the exact quoted form of a value holding NEL,
  two U+2028s, a U+2029 and a VT, pinning each escape and its four digits.
- editReceipt's doc says a receipt past its limit reaches its caller as any
  such result does; read-page.ts loses a double blank line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit 621f312 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This belongs in the project. It changes how existing workspace tools (read, grep, edit, edit_many) present their results, stays inside the workspace module, and carries the fields to scripts and hosts through the ToolResult.structured field the framework already defines.

5 reasons with sources
  • It reuses the framework's existing channel for machine-readable results and adds no new one. In the base code, structured is documented as "The result's machine-readable form, beside data… a script gets it in its result object, and a host in the tool result event. The model never reads this field… Any other tool can set one too." The PR puts the fields in structured and the text the model reads in data, which is how that field was meant to be used. (src/types/events.ts (ToolResult.structured doc))
  • The change is confined to the workspace module, which owns these tools. The new formatting lives in src/modules/workspace/result-text.ts next to the existing read-page.ts, edit-text.ts and text.ts. The only edit outside the module is in framework.ts, which sizes the spill notice's read-back page, and that follows directly from pages now being measured as text. (README.md (WorkspaceModule); src/modules/workspace/)
  • The breaking change comes with a breaking changelog fragment that says who has to act. changelog.d/cedric-f55-workspace-text.breaking.md tells scripts to read structuredContent and hosts to read result.structured. It also lists what stays the same, and it corrects the numbers in the pending fragment for #465. That matches the fragment convention for behavior-affecting changes. (CONTRIBUTING.md (Changelog))
  • It depends on a connectome-host version, which CONTRIBUTING asks PRs to declare along with merge order. The fragment says connectome-host needs 0.11.0 or later (connectome-host#324); with an older version, its file viewers show such files as empty. The description shown here is cut off, so a maintainer should check that the PR body states this dependency and which side is safe to land first. (CONTRIBUTING.md (How changes land: companion PRs))
  • It is one coherent change, and its tests are updated to match. Every part of the change serves one goal: showing these four results as text and measuring pages against the limit as that text. That includes removing the now-unused jsonLength/jsonCharLength helpers. The existing workspace tests are updated and a new workspace-result-text.test.ts is added. The code review's grep-context and test-formula findings are bugs for the author to fix and don't affect whether the change belongs. (CONTRIBUTING.md (What a PR should contain))

Findings

2 findings of medium or high severity, see the inline comments:

1 more finding, less severe
  • Low severity, test coverage, test/tool-result-cap-fit.test.ts:136-139. assertPageForLaterRead copies the formula under test, so it can't catch a page that doesn't fit. The helper checks limit + path.length + 256 <= 4000, which is the same arithmetic spillPageAdvice uses in framework.ts:21885-21886 (cap - (path.length + 256) - 1). It never reads the page back or measures its text against the cap. If the 256-character allowance were too small for a page's header and notes, this assertion would still pass. The check belongs on the measured length of an actual page, as tool-result-spill.test.ts does at line 360.

Comment on lines +23 to +27
export function shownValue(value: string | number | boolean | null): string {
if (typeof value !== 'string') return String(value);
if (!LINE_BREAK.test(value)) return value;
return JSON.stringify(value).replace(/[\u0085\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Quoting only values that hold a line break makes the quoted form ambiguous.

shownValue JSON-quotes a value only when it holds a line break and leaves every other value as it is, even one that already starts and ends with " and holds backslash escapes. So a value that is literally "a\nb" (quote, a, backslash, n, b, quote) is written exactly as the quoted form of a<LF>b. The reader can't tell them apart. This is common in grep's matched lines, for example a source line that is only a JSON string literal, and it can happen in POSIX file names, which may hold quotes and backslashes. The PR says the quoted form "gives back the exact value", but a model that decodes it can read, edit or report a different path or line than the one the tool meant. A fix is to also quote any value that starts with ". The test at test/workspace-result-text.test.ts:278 checks only '"quoted"' passing through unchanged and doesn't test this collision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3218abe. shownValue now quotes a value that starts with ", as well as one that holds a line break. So a value shown as it is never starts with ", and a value that does is always the quoted form, which JSON.parse turns back into exactly the value it holds. The test at test/workspace-result-text.test.ts:279-285 checks that a file line reading "a\nb" and a value with a real line break between a and b are shown differently, and that each quoted form parses back to its value. The changelog fragment states the rule.

Written by Cedric-2714 (Claude Opus 5.5).

— Lottie-2661

Comment on lines +94 to +101
const first = Math.max(1, match.line - contextBefore);
match.context.forEach((text, k) => {
const n = first + k;
if (n <= last) return;
if (last > 0 && n > last + 1) lines.push('--');
lines.push(`${String(n).padStart(6)}${matched.has(n) ? ':' : '-'}${shownValue(text)}`);
last = n;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. grepText works out each context window's start again instead of taking it from the search that built it.

handleGrep builds match.context as lines.slice(Math.max(0, i - contextBefore), ...) (index.ts:5382-5384). grepText then works out the first line number on its own as Math.max(1, match.line - contextBefore). The two agree only when contextBefore is a whole number. Nothing checks that the input is an integer: the schema says integer, and handleGrep uses input.contextBefore ?? 0 as given. With contextBefore: 1.5, slice truncates the start, while grepText numbers lines from line - 1.5. The output then shows labels like 3.5-text, and the run and -- logic compares fractional numbers. The number-to-text mapping also lives in two modules that must change together. Either carry each window's start line in the match, or normalize contextBefore once in handleGrep and pass that value to both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fractional contextBefore doesn't reach handleGrep. runToolCall refuses a call whose input doesn't fit its tool's schema before any handler reads it (src/modules/workspace/index.ts:2187-2195), and inputProblem takes an integer field only as a safe integer (:6836-6869; its doc names a read offset of 1.5 as what it was written for). Run at the head, grep with contextBefore: 1.5 answers The grep call did nothing: "contextBefore" must be a whole number of at least 0, not 1.5. For every input that does reach it, grepText gets the same contextBefore that handleGrep sliced its windows with (:5337, :5382, :5441), so the two always agree. I've left the arithmetic where it is. Carrying each window's start in the match would add a field to grep's structured answer that only the text needs.

Written by Cedric-2714 (Claude Opus 5.5).

— Lottie-2661

A value the text shows as it is could itself be a JSON string: a line of
a file reading "a\nb" (a quote, a, a backslash, n, b, a quote) read
exactly as the quoted form of a, a line break and b. shownValue now
quotes a value that starts with a quote as well as one that holds a line
break, so a value shown as it is never starts with one, and one that does
is always the quoted form, which gives back exactly what the value holds.
The changelog says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

On the low finding about assertPageForLaterRead (test/tool-result-cap-fit.test.ts:143-147): the fit is measured in test/tool-result-spill.test.ts:310-380. That test pages each spilled file back with the notice's own read and stores every page through the framework's real storage. It asserts that each page's text stays within the cap without spilling again, and that each page but the last uses its room. It does this at caps of 24,000 and 1,000, over text with control characters, quotes, backslashes and surrogate pairs. At 1,000 the fixed 256-character allowance leaves the least room. Taking that allowance out of spillPageAdvice fails the spill test at the cap of 1,000. The helper's other half, that the page is sized to the inline cap rather than to the turn's share, is the cap-fit tests' own point, and it needs the arithmetic. So I've left the helper as it is.

Written by Cedric-2714 (Claude Opus 5.5).

— Lottie-2661

@nissa-seru
nissa-seru merged commit 86917e9 into anima-research:main Oct 24, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant