Repository navigation
Add text-retract stream event for failed attempt recovery - #1039
Conversation
…d attempt's tail
AgentTask held every text and reasoning delta of a round until the attempt
settled, because the stream folded each delta into the port and nothing could
take one back: a retried attempt's partial would have been joined to the
retry's text. The cost was that a host showing an agent turn saw no streaming
at all.
A new stream event, `text-retract { port, length }`, removes the last
`length` characters streamed on an append port. AgentTask now forwards deltas
as they arrive and, when an attempt fails, retracts exactly what that attempt
forwarded on each port (its reasoning separator included), so earlier rounds
are untouched and the folded wire equals the settled output.
- StreamProcessor truncates the accumulator. Bytes already teed to a cache
sink cannot be unwritten, so a retraction fails that port's router (the
sink discards the partial write) and the port settles inline for the rest
of the run; with no accumulator either, finish refuses unless the producer
states the port's value.
- StreamPump treats the retraction as a per-port delta, so it travels down its
own port's edges and no others.
- StreamEventAccumulator, chatTurn, StreamingAiTask, TextGenerationTask and
ToolCallingTask honor it; `retractText` is the shared fold.
- CLI rows, run events and the web console fold it; the agent chat and eval
stderr streams, which cannot erase, say the partial is discarded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
main split AgentTask's turn loop into modules. The round code this branch changed now lives in AgentRoundAttempt.ts, so AgentTask.ts takes main's version, and live forwarding moves into attemptRound. A failed attempt's text-retract per port, and passing on the child round's own retractions, move with it. settleRound and attemptRound now yield text-retract alongside text-delta. main's new AgentRoundAttempt test asserted the old hold-until-settled behaviour. It now checks that the retried attempt's partial is forwarded, then retracted, and that the folded port holds only the retry's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Comment — found 1 issue(s) at a6601a3.
Actionable comment posted: 1
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 226, Important):
`Math.max(0, NaN)` and the resulting `Math.min(...)` are `NaN`. Unlike the shared `retractText()` fold (which treats `NaN` as a no-op), this makes `textSent` become `NaN`; no retraction is yielded here, and the failure cleanup's `textSent > 0` check is false. If a child stream emits a `text-retract` with `length: NaN` after sending text and then fails, the failed attempt's text remains in the outer stream alongside the retry. Preserve the fold's no-op semantics or reject non-finite lengths before updating the counter.
main's typecheck:tests step rejects the direct cast from StreamFinish to the test's output shape (TS2352). Find the finish event with a type guard instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
A child round's text-retract with length NaN passed through Math.max and Math.min unchanged. That turned the attempt's sent-count into NaN, so a later failure had nothing to take back and the failed attempt's text stayed in the stream. Skip such a retraction before it reaches the counters, matching retractText. CLAUDE.md said AgentRoundAttempt holds deltas; it now forwards them live and takes a failed attempt's back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Comment — found 1 issue(s) at 5578b44.
Actionable comment posted: 1
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 256, Important):
`yield event`
If a consumer calls `return()`/breaks from `AgentTask.executeStream()` while this yield is suspended, `attemptRound` exits without aborting `roundAbort`/the owned task, clearing `timer`, or unsubscribing `off`. The in-flight model request can therefore keep running (and continue billing) after the stream has been abandoned; with no `timeoutMs`, the task and listener can remain indefinitely. Add cleanup for the generator-return path, including aborting the active attempt.
Now that a round yields its deltas while the model call is still running, a consumer that stops reading (return() on the generator, or a throw in the loop over it) left attemptRound at a yield with the call in flight. Nothing aborted the request, so it kept running and billing. With no timeoutMs, the owned task and its listener were never released. A finally now abandons an attempt that has not settled; the run's own finally then clears the timer and the listener. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Comment — found 1 issue(s) at 61d93e6.
Actionable comment posted: 1
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 232, Important):
`Math.min(event.length, textSent)` subtracts the raw fractional length, but `retractText` folds it through `slice`, which removes an integer number of code units. For example, with earlier-round text `P` and this attempt's `abc`, a child retraction of `1.5` removes `bc` from the outer stream while leaving `textSent === 1.5`; if the attempt then fails, the cleanup retraction of `1.5` also removes the earlier `P`. Normalize the effective length to the same amount the fold removes (or reject non-integer lengths) before updating these counters; apply the same treatment to the reasoning branch.
A retraction's length could be fractional or NaN, and each place that folded or counted it handled that differently. retractText let slice truncate a fractional length, while AgentRoundAttempt subtracted it raw. That drifted the attempt's count away from the text: a child retraction of 1.5 removed two characters but counted 1.5, so a later failure's own retraction could reach into an earlier round's text. retractionLength is now the rule: a fraction rounds down, and anything not positive removes nothing. retractText folds through it, AgentRoundAttempt counts through it, and the web example's TaskNode now uses retractText. The CLI's classifyTextRetract rounds the same way, so its own fold and the web console client (which gets lengths through it) agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Comment — found 1 issue(s) at d0d3597.
Actionable comment posted: 1
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 37, Important):
`acc.slice(0, Math.max(0, acc.length - length))` does not implement the newly documented round-down rule for fractional `length`. For example, folding `"Pabc"` with `length: 1.5` using this expression yields `"Pa"`, while `retractText`/`retractionLength` yields `"Pab"`. A consumer following this public example will diverge from `StreamProcessor` and the agent retry fold; document the canonical helper or normalize `length` in the expression.
The StreamTextRetract doc comment and the dataflow doc both gave a raw slice expression as the fold. That expression doesn't round a fractional length down, so a consumer following it would disagree with StreamProcessor and the agent retry fold. Both now name retractText and retractionLength. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Request changes — found 1 issue(s) at 58ce47e.
Actionable comment posted: 1
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
`return length > 0 ? Math.floor(length) : 0;`
This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
…es text StreamProcessor still checked the raw event.length > 0 before failing a port's cache router. A retraction of 0.5, which retractText treats as removing nothing, detached the sink anyway. With no accumulator, the finish handler then refused the port it no longer had a copy of. The decision now goes through retractionLength. The CLI's agent chat and the eval stream print their "attempt withdrawn" notices on the same rule, so a retraction that removes nothing prints nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Request changes — found 2 issue(s) at 0de83cd.
Actionable comments posted: 2
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
Additional findings outside changed lines:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
A sub-unit retraction can fail a cached no-accumulation run
`return length > 0 ? Math.floor(length) : 0;`
This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
Still open from earlier reviews (1) — the lines they point at have not changed:
- 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run ·
packages/task-graph/src/task/StreamTypes.tsline 57 · from58ce47e
return length > 0 ? Math.floor(length) : 0;
This makes alengthsuch as0.5a no-op, butStreamProcessorstill tests the rawevent.length > 0before detaching a cache router. WithnoAccumulationenabled, an append stream that emits text, then atext-retractof0.5, thenfinishwithout that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
| } | ||
| if (accumulated) { | ||
| const text = accumulated.get(event.port); | ||
| if (text !== undefined) accumulated.set(event.port, retractText(text, event.length)); |
There was a problem hiding this comment.
🔴 Blocker · Blocker
Preserve an accumulated empty string after full retraction
retractText can leave this accumulator entry as "" when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream like text-delta("partial"), text-retract(99), finish({}) completes with no text property instead of text: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
🤖 Prompt for AI Agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
There was a problem hiding this comment.
Not changing this here. As far as I can tell it's the intended behaviour, not a regression.
- The finish merge's
if (text.length > 0)predates this PR (df50135). Empty accumulated text never overrides the finish payload. - So a port retracted to
""ends up exactly like a port that never streamed. The port is absent unless the producer states it infinish. That's the contracttext-retractdocuments: the folded port reads as if the withdrawn text had never been sent. AgentTask, the only producer that retracts, always statestextandreasoningin its finish payload, so its output always carries them.- Writing
""for a fully retracted port would make it differ from a never-streamed one. Doing that for every empty accumulator would also change ports that only ever streamed empty deltas, which is outside this PR.
Leaving this thread open for the author to decide.
Generated by Claude Code
Ctrl-C mid-reply aborts the turn, and AgentTask retracts what the aborted attempt streamed. The REPL read that retraction as a failed attempt and printed "that attempt failed; the partial reply above is discarded" before "interrupted", although nothing failed and nothing is retried. The note is now skipped once the turn has been interrupted. Also splits an inline `type` import in projectRunEvents.test.ts into a top-level `import type`, per the repo's import convention. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Request changes — found 2 issue(s) outside changed lines at b4716c0.
Actionable comments posted: 2
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
Preserve an accumulated empty string after full retraction
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
A sub-unit retraction can fail a cached no-accumulation run
`return length > 0 ? Math.floor(length) : 0;`
This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
Still open from earlier reviews (2) — the lines they point at have not changed:
- 🔴 Blocker · Preserve an accumulated empty string after full retraction ·
packages/task-graph/src/task/StreamProcessor.tsline 310 · from0de83cd
retractTextcan leave this accumulator entry as""when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream liketext-delta("partial"), text-retract(99), finish({})completes with notextproperty instead oftext: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled. - 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run ·
packages/task-graph/src/task/StreamTypes.tsline 57 · from58ce47e
return length > 0 ? Math.floor(length) : 0;
This makes alengthsuch as0.5a no-op, butStreamProcessorstill tests the rawevent.length > 0before detaching a cache router. WithnoAccumulationenabled, an append stream that emits text, then atext-retractof0.5, thenfinishwithout that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
A host bound to AGENT_ROUND_RUNNER could stream a round's text and reasoning to the turn but had no way to take any of it back. A host whose own model call withdrew part of a reply (a ToolCallingTask whose provider retracted) had to keep the withdrawn text in the turn, although the in-process path removes it. AgentRoundContext gains onTextRetract(port, length). The turn passes it on as its own text-retract, counted through retractionLength and capped at what the attempt forwarded on that port, so a host can never take back an earlier round's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Request changes — found 2 issue(s) outside changed lines at 241edfa.
Actionable comments posted: 2
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
Preserve an accumulated empty string after full retraction
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
A sub-unit retraction can fail a cached no-accumulation run
`return length > 0 ? Math.floor(length) : 0;`
This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
Still open from earlier reviews (2) — the lines they point at have not changed:
- 🔴 Blocker · Preserve an accumulated empty string after full retraction ·
packages/task-graph/src/task/StreamProcessor.tsline 310 · from0de83cd
retractTextcan leave this accumulator entry as""when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream liketext-delta("partial"), text-retract(99), finish({})completes with notextproperty instead oftext: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled. - 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run ·
packages/task-graph/src/task/StreamTypes.tsline 57 · from58ce47e
return length > 0 ? Math.floor(length) : 0;
This makes alengthsuch as0.5a no-op, butStreamProcessorstill tests the rawevent.length > 0before detaching a cache router. WithnoAccumulationenabled, an append stream that emits text, then atext-retractof0.5, thenfinishwithout that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
…magupta-4xsmg4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo
There was a problem hiding this comment.
Review progress ██████████ 29/29 files
Request changes — found 2 issue(s) outside changed lines at 23d3f3d.
Actionable comments posted: 2
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
Preserve an accumulated empty string after full retraction
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
A sub-unit retraction can fail a cached no-accumulation run
`return length > 0 ? Math.floor(length) : 0;`
This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
Still open from earlier reviews (2) — the lines they point at have not changed:
- 🔴 Blocker · Preserve an accumulated empty string after full retraction ·
packages/task-graph/src/task/StreamProcessor.tsline 310 · from0de83cd
retractTextcan leave this accumulator entry as""when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream liketext-delta("partial"), text-retract(99), finish({})completes with notextproperty instead oftext: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled. - 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run ·
packages/task-graph/src/task/StreamTypes.tsline 57 · from58ce47e
return length > 0 ? Math.floor(length) : 0;
This makes alengthsuch as0.5a no-op, butStreamProcessorstill tests the rawevent.length > 0before detaching a cache router. WithnoAccumulationenabled, an append stream that emits text, then atext-retractof0.5, thenfinishwithout that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.
Summary
Implements
text-retractstream events to allow producers (likeAgentTask) to take back text they streamed before abandoning an attempt. When a model call fails partway and is retried, the failed attempt's text is retracted so consumers see only the successful retry's output, as if the failed attempt never ran.Key Changes
New
StreamTextRetractevent type (packages/task-graph/src/task/StreamTypes.ts):lengthcharacters (UTF-16 code units) from a port's accumulated textretractionLength()is the one rule for how much a retraction removes (a fraction rounds down; anything not positive, NaN included, removes nothing), andretractText()is the fold built on itStreamProcessorretraction handling (packages/task-graph/src/task/StreamProcessor.ts):AgentTaskretry recovery (packages/ai/src/task/AgentRoundAttempt.ts, where main's refactor moved the round loop):text-retractfor exactly what that attempt sentretractionLength, so retraction lengths stay exactHost round runners can retract (
packages/ai/src/task/AgentRoundRunner.ts):AgentRoundContext.onTextRetract(port, length)lets anAGENT_ROUND_RUNNERwithdraw text its own call streamed; the turn caps it at what that attempt forwardedStreamPumpedge forwarding (packages/task-graph/src/task-graph/StreamPump.ts):UI and CLI updates:
streamedText.ts: AddedclassifyTextRetract()(rounds the length likeretractionLength) and retraction foldingAgentTaskTurnControls.test.ts: New tests for retraction on failed/timed-out attemptsAgentTaskReasoningAndImages.test.ts: Tests for multi-port retractionAgentRoundAttempt.test.ts: the retry test now expects the partial to be forwarded, then retracted; a new test stops reading mid-stream and expects the call to be aborted; another passes a host runner's retraction through, cappedComprehensive test coverage (
StreamTextRetract.test.ts):Implementation Details
finish(AgentTaskalways does)retractText(acc, length), which removesretractionLength(length)characters🤖 Generated with Claude Code
https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo