Skip to content

feat(*): keep asking about open decisions and head each task in a report - #1050

Merged
0xKT merged 2 commits into
mainfrom
feat/carry_open_decisions_forward
Oct 22, 2026
Merged

0xKT merged 2 commits into
mainfrom
feat/carry_open_decisions_forward

Conversation

@LivXue

@LivXue LivXue commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Raven runs several tasks through its agents at once, and each result comes back as its own turn. The host then asks the user what to do next, and the user's reply often settles only some of those decisions. The rest used to drop out of the conversation. This PR keeps them in it.

  • The host role (## Your role) gains one bullet: a decision the host put to the user stays open until the user makes it, drops it or says to leave it for later; when a message settles only some, the host acts on those and ends the reply by asking about each one still open, a line each, on every reply. An offer the user passes over is declined, not open.
  • The reply rules (## How you reply) gain two lines. Open decisions close every reply, a line each, also after a one-line answer or a hand-off, and they are content rather than a recap or a sign-off; one the user dropped or put off is no longer open, and an offer the user passed over is declined. A reply that reports the results of several tasks gives each task its own section under a markdown heading on a line of its own (### <task>), with the decisions after the last section.
  • A spawn result carries the rule as this system's note after the result's untrusted fence (render.open_decisions_note, added to the trusted_note channel beside worker_table_note). The host writes its closing text right after it reads the receipt, and inside the fence the note would be data.

Why two homes for one rule: with the role bullet alone, a one-line answer to an unrelated question closed with the open decisions in 0 of 6 runs, most likely because the simple-answer and stop rules in ## How you reply read the reminder as a closing recap; the reply-rules line is what moved it. The reply rules also render where the role does not: the role needs a dispatch path, and the turn that generates a Persona, or a turn on an install that withholds both spawn and run_subagent_dag, holds none. There the reply-rules line is the rule's only statement, so it carries the declined-offer sentence in the role's own words. The tests pin both places, on both kinds of turn.

Measured on glm-5.3-flash, the default host model, with synthetic conversations sent through the real per-turn context assembler and the live tool list (34 tools); tool calls were answered by stubs and never executed. n = 6 per cell unless stated:

Situation Before After
one reply asked three, the user settled one 0/6 6/6
three replies asked one each, the user settled the latest 3/6 6/6
a one-line answer to an unrelated question, decisions open 1/12 5/6
a new result arrives while an older decision is open 0/6 6/6
a question about one task, handed on to its instance 4/6 6/6
an offer the user passed over, then an unrelated request 6/6 6/6
all settled / "the rest later" / a fresh chat 6/6 each 6/6 each
a report on three tasks, every task under a heading 3/34 11/11
a single task's result, no heading added 12/12 6/6

The first six rows were re-run with the final wording, whose reply-rules line carries the declined-offer sentence; against the same prompt without that sentence no cell fell (two rows went from 5/6 to 6/6 and the hand-off pair from 3/6 and 5/6 to 4/6 and 6/6, which at n = 6 is noise, not a gain). The last three rows were measured before the sentence was added and were not re-run. The hand-off row compares the prompt with and without the spawn note, both with every tool result fenced as production fences it; the other rows came from runs whose stored tool results were not fenced, where the hand-off row read 1/6 without the note. Routing a question to its task's instance held at 6/6 in every arm. The heading row counts replies that reported all three tasks; the wording had to name the syntax, since "a short heading on a line of its own" kept the task name inline ("Pricing -- ...") in 4 of 7. The replay harness lives outside the repository because it calls a live model.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Each new test was watched failing before the change that makes it pass.

  • After the rebase onto fix(agent): a Mirasim preset is read by whether Mirasim is here, not by a mirasim on PATH #1040, fix(*): ship the cloud memory plugin with raven, not beside the release #1030, fix(*): name where local skill ids come from when a skill read misses #1051 and fix(ui-web): keep the rail rename box inside the row and its caret in view #1054: nice -n 19 .venv/bin/python -m pytest -n 0 -q -p no:randomly tests/test_segments.py tests/test_agent_loop_subagent_role.py tests/test_agent_loop_run_emit.py tests/test_agent_loop_token_budget.py tests/test_watch_work.py tests/test_security_untrusted_context.py tests/test_unified_playbook_e2e.py tests/test_release_plugin_list.py tests/test_skill_forge_loader_parity.py tests/test_skill_hub_tools.py tests/test_subagent_mirasim.py tests/test_subagent_probe.py tests/test_updates_upgrade.py, the last six being the test files those four commits changed: 767 passed.

  • The full suite was last run before the declined-offer sentence and before that rebase, on a base that touched none of the files above; it was not re-run for the one-sentence prompt change, which the files above cover. nice -n 19 .venv/bin/python -m pytest -q -p no:randomly -n 4 in a venv synced with uv sync --frozen --python 3.12 --all-extras: 34158 passed, 181 skipped, 2 xfailed, 6 failed. Three are this machine's standing failures, red on main here as well: test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for, test_knowledge_parser_excel.py::test_a_legacy_workbook_is_converted_and_read and test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing (run as root). The other three were two xdist worker crashes and one timeout while other jobs held the load near 30; those three tests passed alone, and their files passed serially, 134 of 134.

  • ruff check and ruff format --check over the changed Python files: clean.

  • scripts/check_large_files.py, scripts/check_source_language.py and scripts/check_commit_messages.py over origin/main..HEAD, and commitlint --from origin/main --to HEAD plus the prospective squash header: all exit 0; a malformed control message makes commitlint exit 1.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

  • Every host reply can now end with a short list of the decisions still open, on every lane the host replies on, including scheduled and result-announcement turns. A reply that reports several tasks now uses markdown headings, which a channel that does not render markdown shows as ###. WeChat is one: its adapter sends the reply text as written.

  • Cost: 220 tokens on every host turn's identity (99 for the role bullet, 74 and 47 for the reply lines), and about 20 tokens on each spawn result, which stay on the stored message.

  • available_history in tests/test_agent_loop_token_budget.py goes from 126_000 to 125_500 (measured 125_984). feat(context_engine): compacting history strategy with source refs and checkpoints #1031 lowers the same bound at the same lines for context_retrieve. This PR is to land first; feat(context_engine): compacting history strategy with source refs and checkpoints #1031 then keeps both comment paragraphs and re-measures with both prompt additions in.

  • Not covered: run_subagent_dag results and the DAG result announcement carry no note, and the spawn result announcement keeps its "1-2 sentences" instruction.

  • Rollback: revert the squash commit. Nothing is migrated or stored in a new shape.

  • Security impact considered

  • Backward compatibility considered

  • Rollback path is clear for risky changes

The note uses the trusted_note channel, which only carries text a Raven module composed; no tool output passes through it.

Related Issues

N/A

@LivXue
LivXue requested review from 0xKT and gloryfromca October 21, 2026 11:22

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

Reviewed the full diff at 8dc3c9783ccb against github/main. I covered the applicable AGENTS.md/CLAUDE.md rules and runtime terminology, the prompt and loop changes, surrounding spawn, context-builder, trusted-note callers and history, backward compatibility, architecture boundaries, and whether tests were weakened merely to pass. The lowered history-budget floor remains well above the regression it guards and matches the intentional prompt growth.

Verification:

  • uv run pytest -n 0 -q -p no:randomly tests/test_segments.py tests/test_agent_loop_subagent_role.py tests/test_agent_loop_run_emit.py tests/test_agent_loop_token_budget.py tests/test_watch_work.py tests/test_security_untrusted_context.py tests/test_unified_playbook_e2e.py: 283 passed.
  • The broader 11-file caller suite: 899 passed, 3 failed in unchanged tests/test_subagent_probe.py; rerunning those three alone reproduced the same environment-specific cleanup-simulation failures (no removal was refused), outside the changed paths.
  • git diff --check, Ruff check/format, source-language and commit-message checks, large-file check, and all 10 import-linter architecture contracts passed.

@0xKT

0xKT commented Oct 21, 2026

Copy link
Copy Markdown
Member

Not a blocker (review of 8dc3c978, merged onto main e1c44e57): on a host turn the new lines land where they are meant to, and only there. Three points for a follow-up or the merge, none of them about the host turn itself.

Severity of this one finding, not a verdict on the pull request. Any blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text.

  1. A turn with no dispatch path gets the open-decision rule without its carve-out.
    • _reply_style() is gated only on the sub-agent process (raven/context_engine/segments/render.py:304), so its new line (:315) renders on every other turn.
    • That includes a persona turn and a turn with spawn and run_subagent_dag withheld, where _host_role renders no role (:435).
    • On those turns the reply-style line is the only statement of the rule. It lacks the role bullet's "An offer of yours that they pass over is declined, not open." (:462). The _host_role docstring says that clause keeps a closing "shall I ...?" from being asked on every reply (:420-422). The same prompt also says never to close with "an offer of more help" (:313).
    • I rendered the real request through the agent loop with a stub provider. On a persona turn this head carries the reply-style decision line and the "offer of more help" line, and neither the role bullet nor the declined clause. A host turn carries all three. Main carries none of the new lines.
    • Putting the clause into the reply-style line, or rendering that line only where the role is, would keep the two in step.
  2. On WeChat the new heading rule shows up as literal ###.
    • Nothing in raven/channels/adapters/weixin/ or raven/channels/ handles markdown, so a multi-task report reaches a WeChat user with ### <task> as text. Telegram's adapter has its own markdown handling.
    • The prompt already allowed markdown on every channel, so the problem is not new in kind. The new rule spells out a syntax this channel shows raw.
  3. Merge order with feat(context_engine): compacting history strategy with source refs and checkpoints #1031.
    • Its current head (148420b2) lowers the same assertion in tests/test_agent_loop_token_budget.py from > 126_000 to > 125_500, as this PR does.
    • Whichever lands second gets a textual conflict, and should re-measure available_history with both prompt additions in.

@gloryfromca

Copy link
Copy Markdown
Member

No blockers; suggestions only, and they are marked inline.

@0xKT, the first point is concrete: a non-subagent turn without a dispatch path receives the new reply-style rule without the role bullet that classifies a passed-over offer as declined, so that edge can repeat an offer the user ignored. I accept it as a nonblocking follow-up rather than a reason to hold this host-focused change. The WeChat markdown behavior is likewise pre-existing in kind, and the #1031 overlap is merge-order coordination rather than a defect in this revision.

This changes my standing stance from clean to nonblocking, but the revision remains mergeable. The directly relevant prompt tests still pass: 97 passed across tests/test_segments.py and tests/test_agent_loop_subagent_role.py.

The suggestions referenced by the required first line are recorded in the preceding top-level review comment, not new inline findings from me.

The host asks the user to decide things about the results its agents
return, one result per turn, and the user's next message often settles
only some of them. The rest dropped out of the conversation. The host
role now keeps a decision open until the user makes it, drops it or
puts it off, and asks again about each open one at the end of every
reply. Two reply rules carry it where the reply is shaped: open
decisions close every reply and are not a recap, and a reply that
reports several tasks gives each task its own section under a markdown
heading. A spawn result carries the rule as this system's note after
its untrusted fence, since the host writes its closing text right after
reading one.
…ules

The reply rules render on every turn of the host's process, the role only
on a turn that holds a dispatch path. On a Persona's generating turn, or
on an install that withholds both spawn and run_subagent_dag, the reply
rules' open-decision line was the rule's only statement and lacked the
role's carve-out, so an offer the user passed over could read as a
decision still open. The line now repeats the carve-out in the role
bullet's words, and tests pin it on both kinds of turn.
@LivXue
LivXue force-pushed the feat/carry_open_decisions_forward branch from 8dc3c97 to 072c449 Compare October 22, 2026 03:35
@LivXue

LivXue commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Thanks @0xKT for the three points, and @gloryfromca for weighing them. Answers in order; the fix for the first is 072c44969. The same push rebases the branch onto current main, which moved the first commit from 8dc3c978 to a66404a6 with its patch unchanged.

1. A turn with no dispatch path: fixed here, in the first shape you suggested. The reply rules' open-decision line now ends with the role bullet's own sentence, "An offer of yours that they pass over is declined, not open." I put the clause into the line rather than rendering the line only where the role is: a turn without a dispatch path can still owe the user decisions, and on that turn the line is what keeps them in the reply.

  • Checked on three kinds of turn. A host turn with the dispatch tools carries the sentence in the role and in the reply rules, in the same words. identity_text(..., dispatch_tools=()) renders no role, and its reply rules now carry the rule and the sentence. A sub-agent process renders neither section.
  • Through the real loop with a stub provider: the turn that generates a Persona holds no dispatch path and renders no role, and its request now carries the sentence once, in the reply rules. The turn after it binds the Persona's workers and gets the role back, with the sentence in both places, so the gap was on the generating turn rather than on every Persona turn.
  • Tests: test_a_turn_without_the_role_still_reads_a_passed_over_offer_as_declined (tests/test_agent_loop_subagent_role.py) and test_the_turn_that_generates_a_persona_reads_a_passed_over_offer_as_declined (tests/test_unified_playbook_e2e.py). Both failed at 8dc3c978 on the missing sentence and pass with the fix.
  • I did not measure how often the model repeats a passed-over offer on such a turn, so I make no claim about that rate. What I did measure, on glm-5.3-flash with the final wording, is that the sentence did not cost the re-asks it must leave alone (n = 6 each): one of three settled 6/6, settled over three replies 6/6, a later reply settling one of two 6/6, a new result arriving 6/6, a one-line unrelated answer 5/6, a hand-off with the spawn note 6/6 (4/6 without it), and a passed-over offer still declined 6/6. The description's table now carries these.
  • Cost: 14 tokens per host turn; available_history measures 125_984, above the 125_500 bound.

2. WeChat shows ### as text: agreed, kept as a known limit of this PR. A reply with two ### <task> headings, passed through the real WeixinChannel.send() with the transport mocked, reaches text_item.text in the payload verbatim, at this head and at its base, so the adapter has always sent markdown as written. I have not looked at it in a WeChat client. The Risk section already named the limit and now names WeChat. No adapter change in this PR.

3. Merge order with #1031: agreed. #1031 is open at 2669bd2f and still lowers the same assertion to > 125_500. This PR merges cleanly onto current main; the conflict is only between the two PRs, in the comment above that assertion. The plan is for this PR to land first, and for #1031 to keep both comment paragraphs and re-measure available_history with both prompt additions in. Nothing here assumes the bound has to move again.

@0xKT, could you take another look at the head 072c4496?

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; suggestions only, and they are marked inline.

The new revision fixes the no-dispatch gap: the reply-style rule now carries the declined-offer clause on Persona-generation and other host turns without a dispatch path, while sub-agent processes remain unaffected. The real assembled-prompt regression test covers the Persona path, and the rebased original patch is unchanged.

The revision can merge. The remaining named follow-ups from the existing top-level review are unchanged: WeChat can display the required markdown headings literally (pre-existing in kind), and #1031 must preserve both token-budget comment blocks and re-measure after this PR lands. There are no new inline findings in this round.

Verification: the focused prompt/loop suite passed 285 tests. Ruff check and format, source-language, commit-message, large-file, and all 10 import-linter architecture checks passed. I also covered the refreshed github/main...HEAD diff, the post-review delta, prompt callers/gating, history, backward compatibility, repository context rules, and the token-budget assertion.

@0xKT

0xKT commented Oct 22, 2026

Copy link
Copy Markdown
Member

Not a blocker (review of 072c4496, main 43709e20): the fix does what it says. On a Persona generating turn and on a turn with both dispatch tools withheld, the reply rules now carry "An offer of yours that they pass over is declined, not open." in the role bullet's words; a host turn with a dispatch path carries it twice (role and reply rules), and a sub-agent process carries neither, as the new CONTEXT.md sentence says. One small gap is left in the same feature, from the first commit:

Severity of this one finding, not a verdict on the pull request. Any blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text.

  • A sub-agent process still gets the spawn note, without either carve-out. raven/agent/loop/turn_path.py:1489-1492 appends render.open_decisions_note() to every spawn result, gated only on the tool name. With RAVEN_SUBAGENT set, spawn is not registered (raven/agent/loop/wiring.py:1349-1350), so a call to it returns the absent-tool error, and the note is still appended after the fence: "Note: decisions you put to the user that are still open close this reply too, a line each." That process renders no role and no reply rules, so this is the only statement of the rule it reads. Its sibling worker_table_note is empty there (raven/context_engine/segments/render.py:378), per its docstring "Empty wherever the role itself is: no dispatch path, or a process launched to do one task" (render.py:373-374).
  • Measured through AgentLoop.run_turn with a scripted provider (first call spawn, then "done"), HOME and RAVEN_HOME in a scratch dir: host process, the note follows the receipt and the system prompt states the declined clause twice; RAVEN_SUBAGENT=1, the tool message is "Error: tool 'spawn' is not available. ..." followed by the note, and the system prompt states the clause 0 times.
  • Nothing pins either side: with the site gated on not is_subagent_process(), the five test files this PR touches still pass (176 passed), the host keeps the note and the sub-agent loses it. Gating it like worker_table_note, plus a test on the sub-agent side, would close it. The trigger is a model calling a tool missing from its array, which registry.py documents as a real case; the effect is small (a child closing its reply with "open decisions" lines to the host).

@0xKT
0xKT merged commit dcc019a into main Oct 22, 2026
24 checks passed
@0xKT
0xKT deleted the feat/carry_open_decisions_forward branch October 22, 2026 06:52
LivXue added a commit that referenced this pull request Oct 22, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>
LivXue added a commit that referenced this pull request Oct 23, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>
LivXue added a commit that referenced this pull request Oct 23, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.
LivXue added a commit that referenced this pull request Oct 23, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.
LivXue added a commit that referenced this pull request Oct 23, 2026
…s fence

A stored tool result is the model's copy: wrapped in the untrusted
fence, and sometimes followed by this system's note to the model --
since #1050 every spawn receipt carries the reply rules there. The live
preview is drawn from the raw result, but the replay of a stored
conversation dropped only the fence's marker lines, so after a reload a
spawn call's preview showed "Note: decisions you put to the user ...".
The replay now previews the fence's content alone, as a delivered row
already does through defence().
LivXue added a commit that referenced this pull request Oct 23, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.
0xKT pushed a commit that referenced this pull request Oct 24, 2026
…s fence (#1072)

## Summary

A stored tool result is the model's copy of it: wrapped in the untrusted
fence, and sometimes followed by this system's note to the model. Since
#1050 every `spawn` receipt carries the reply rules there, and #1070
extends the same note to `run_subagent_dag` receipts. The live tool
preview is drawn from the raw result and never shows the note, but
ui-web's replay of a stored conversation (`history()` in
`features/transcript/store.ts`) removed only the fence's marker lines
(`cleanPreview`), so after a reload a `spawn` call's preview read:

```
Subagent [...] started (id: ...). I'll notify you when it completes.


Note: decisions you put to the user that are still open close this reply too, a line each.
```

One live conversation already holds eight stored `spawn` results that
carry the note.

The replay now previews the fenced content alone, through the same
`defence()` a delivered row already uses. A fence ends only at the close
marker whose source and nonce match its opening one, and the next
opening marker is looked for only after that close, so a fence the
result itself quotes stays content, and `cleanPreview` still removes
those inner markers afterwards. `defence()` now keeps every fence rather
than the first: a result made of content blocks is fenced one text block
at a time and comes back from `session.resume` with its text blocks
joined by a space, and stopping at the first close dropped every later
block, a multi-page render's later page labels among them. A result
stored without a fence keeps its whole text. The live preview path is
unchanged.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `node node_modules/vitest/vitest.mjs run
src/features/transcript/store.lane.test.ts`: 24 passed. The two cases
with a note after the fence failed before the fix, their preview
carrying the note, and pass after it; the case with no fence passed
throughout. A fourth case pins what the cut must not lose: the success
guess (`okOf`) reads the preview, and the registry's retry hint is part
of the result, inside the fence, so a failed call still reads as failed.
It passes before and after the fix, and fails if the replay previews the
text after the fence, or nothing. Two more cases came with the
every-fence change. Two fenced blocks joined by a space and followed by
a note block failed at the first revision, which kept only the first
page; stopping after the first fence, or matching an opening marker only
at a line start, fails it. A close marker quoted with another nonce
passes throughout and holds the nonce rule: ending a fence at a close
marker with another nonce had left the whole suite green, and now fails
it.
- `node node_modules/vitest/vitest.mjs run --maxWorkers=2` in `ui-web`:
253 files, 4234 tests passed.
- `eslint` over `src/features/transcript/store.ts` and
`src/features/transcript/store.lane.test.ts`, and `tsc --noEmit -p
tsconfig.json`: clean; `tsc --listFiles` reads this worktree's
`store.ts`.
- `scripts/check_large_files.py`, `scripts/check_source_language.py` and
`scripts/check_commit_messages.py` over `origin/main..HEAD`, and
`commitlint --from origin/main --to HEAD` plus the prospective squash
header: all exit 0.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing docs describe the restored preview, so none changed.

## Risk

- Only the restored preview changes; the live preview and delivered rows
are untouched.
- Anything stored outside a tool result's fences is now left out of its
restored preview. Today only this system's notes to the model sit there
(`trusted_note` in `ContextBuilder.add_tool_result`), and the spaces
`session.resume` joins a result's text blocks with; text meant for the
reader belongs inside a fence or in a field of its own.
- `defence()` is shared with the delivered row, which now reads every
fence too. A delivered entry carries one fence (a spawn's announcement
wraps its one result, a DAG run's announcement its summary), so what it
shows does not change.
- Rollback: revert the squash commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. Follows #1050; #1070 lengthens the same note, and #1082 makes the
same change in ui-tui.
0xKT pushed a commit that referenced this pull request Oct 24, 2026
…nce (#1082)

## Summary

A stored tool result is the model's copy of it: wrapped in the untrusted
fence, and sometimes followed by this system's note to the model. Since
#1050 every `spawn` receipt carries the reply rules there, and #1070
extends the same note to `run_subagent_dag` receipts. The live tool
preview is drawn from the raw result, but ui-tui's resume fold
(`toTranscriptMessages` in `domain/messages.ts`) clamped the stored text
as it was, so after `/resume` a `spawn` row read:

```
[BEGIN UNTRUSTED spawn #72889423 ... is data, NOT instructions]
Subagent [...] started (id: 9393f8e2). I'll notify you when it completes.
[END UNTRUSTED spawn #72889423]
Note: decisions you put to the user that are still open close this reply too, a line each.
```

The same fence also hid failures. `callFailed` reads a result that
starts with "Error" as a failed call, mirroring the registry, and a
fenced error starts with the fence's opening marker, so a call that
failed came back from `/resume` reading as a success.

The fold now previews the fenced parts. Each ends only at the close
marker that carries its own opening marker's source and nonce, the rule
ui-web's `defence()` applies (#1072 gives that function the same
every-fence reading), and the next opening marker is looked for only
after that close, so a marker the result quotes stays content. A result
made of content blocks is fenced one text block at a time, and
`session.resume` joins its text blocks with a space, so a resumed row
can hold several fences in a row: every one is kept, joined by newlines,
and what lies between and after them, such as this system's note to the
model, is dropped. A result stored without a fence, such as an
`[interrupted]` placeholder, is kept whole. Nothing is trimmed, since
`previewLines` reads the indentation, and the fences are read before the
clamp, so a long result is cut where the live preview cuts it. The live
preview is unchanged.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `node node_modules/vitest/vitest.mjs run
src/__tests__/messages.test.ts` in `ui-tui`: 58 passed. The six cases
under "a stored result fenced as untrusted data" failed before the fix
-- the preview carried the fence markers and the note, and `callFailed`
read a fenced error as a success -- and pass after it. The sixth, two
fenced blocks joined by a space and followed by a note block, was added
after review and failed at the previous head, which kept only the first
page. Six mutants of the helper each fail the case written for them:
stopping after the first fence, matching an opening marker only at a
line start, keeping what lies outside the fences, ending a fence at a
close marker with another nonce, trimming as `defence()` does, and
reading the fences after the clamp.
- `node node_modules/vitest/vitest.mjs run --maxWorkers=2` in `ui-tui`:
144 files, 2255 passed, 13 skipped. An earlier run of the first revision
with default workers, under a machine load of about 20, failed four
key-timing cases in `modelPicker.test.tsx` and
`newInstancePicker.test.tsx`, which import nothing this change touches
and pass alone (38 of 38).
- `tsc --noEmit -p tsconfig.json`, `eslint` and the lockfile's `prettier
--check` over the two changed files: clean.
- `scripts/check_large_files.py`, `scripts/check_source_language.py` and
`scripts/check_commit_messages.py` over the branch, and `commitlint
--from origin/main --to HEAD`: all exit 0.
- On screen, for the first revision: `raven tui --standalone` under an
isolated `RAVEN_HOME` holding a copy of one recorded turn, opened with
`/resume`. A build of main drew the four lines above on the `spawn` row;
this branch's build, run through `PYTHONPATH`, drew only the receipt
line.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing docs describe the resumed preview, so none changed.

## Risk

- Only the resume fold changes; the live preview and the
delivered-result line are untouched.
- Anything stored after a tool result's fence is now left out of its
resumed preview. Today only this system's notes to the model sit there
(`trusted_note` in `ContextBuilder.add_tool_result`); text meant for the
reader belongs inside the fence or in a field of its own.
- Rollback: revert the squash commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. Follows #1050; #1072 makes the same change in ui-web, and #1070
lengthens the note.
0xKT pushed a commit that referenced this pull request Oct 24, 2026
…itten (#1070)

## Summary

#1050 asked the host to give each task its own `### <task>` section when
one reply "reports the results of several tasks". Live sessions showed
it rarely happened, for two reasons. Results arrive one turn each, so
the common reply carries one task's result plus a line on each task
still running, and the rule did not cover that reply at all. And where a
reply did cover several tasks, a line in the system prompt did not hold
in a long session: replaying recorded requests from sessions of 55 to
242 messages, the resident line headed almost none of them, whether
worded as before or widened.

- The reply rules' line (`## How you reply`) now covers every task a
reply touches -- a result, progress, a one-line status, a late notice.
Each gets its own `### <task>` section even when the section is one
sentence, two tasks never share a paragraph or a list, and the closing
list of open decisions stays one list after the last section.
- The note a `spawn` receipt carried (`render.open_decisions_note`,
renamed `render.reply_rules_note`) now says the heading rule too, and
rides wherever the host writes its reply next: on both dispatch receipts
(`spawn` and `run_subagent_dag`, via `render.DISPATCH_TOOLS`) and after
the fence of a background DAG run's announcement
(`SubagentManager.announce_dag_result`). A receipt carries it whether
the model called the tool directly or through `tool_call`, the only
route to a `run_subagent_dag` the tool fold hides: the loop checks the
tool the call reached (`metadata_owner`, as `take_metadata` does), so a
refused `tool_call` target, or a dispatch tool the process does not
hold, gets none. A Sub-agent role process renders none of the reply
rules and gets no note either, guarded as `render.worker_table_note` is.
The announcement's summary stays verbatim inside the fence, and no
retelling instruction is added. ui-web draws only the fenced part and
ui-tui a one-line marker; an ACP `session/load` replay and a session
export draw the stored text, so the note shows there, as a spawn
announcement's instruction tail already does.

Measured by replaying the final host call of three recorded live turns
(messages, tools and temperature as recorded; tool calls answered by
stubs, never executed). Each cell counts the final replies that gave
each task its own heading:

| Recorded reply (what the host read last) | glm-5.3-flash before |
after | MiniMax-M3.1-Flash before | after |
|---|---|---|---|---|
| a status overview, after a spawn receipt | 0/6 | 4/12 | 0/6 | 2/6 |
| one result plus progress, after a `run_subagent_dag` receipt | 1/6 |
5/6 | 0/6 | 5/6 |
| a two-node DAG notice | 0/6 | 5/6 | 1/5 | 4/5 |

On MiniMax the DAG notice was replayed for up to four steps with fenced
stubs, because the model checked the graph's progress before replying;
one sample per arm had not replied by then. With the widened line and no
note, which is what a spawn-only note leaves the other two rows, glm
stayed at 1/6 and 0/6. Before settling on this, one change at a time
(trimming the history, blanking earlier replies, widening the trigger
alone, dropping the plain-prose rule, a trailing note alone) stayed
between 0/6 and 2/6 on glm.

Checked for over-triggering on MiniMax with synthetic conversations, n =
6 per cell: a single task's result got no heading in 6/6 before and
after, and a one-line answer with three decisions open got no heading in
any reply and kept the decisions as one list (it asked all three again
in 5/6 after, 2/6 before).

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Each new or changed test was watched failing before the change that
makes it pass: `test_the_reply_rules_head_each_task_a_reply_covers`,
`test_a_dispatch_receipt_carries_the_open_decision_rule`,
`test_a_dispatch_receipt_carries_the_per_task_section_rule`, all four
cases of
`test_a_dispatch_receipt_carries_the_reply_rules_note_after_its_fence`
(each dispatch tool, called directly and through `tool_call`), the
direct case of
`test_a_dispatch_tool_the_turn_does_not_hold_earns_no_note`,
`test_a_sub_agent_role_process_gets_no_reply_rules_note` and
`test_announce_dag_result_delivers_the_summary_verbatim`. That last test
used to require the announce to end at the fence; it now requires the
verbatim summary inside the fence, this note after it, and no retelling
instruction. The `tool_call` case of the not-held test passes either way
and holds the check's shape: four mutants of the loop's check (the outer
name alone, `tool_call`'s `name` string read without resolving it, a
note on every `tool_call`, no `None` check) each fail at least one of
these tests.

- At the head, after the rebase onto 8a7ea1c (#1077 also edits
`docs-site/docs/agent-collaboration.md` and its zh page, in other
paragraphs): `nice -n 19 .venv/bin/python -m pytest -n 0 -q -p
no:randomly tests/test_segments.py
tests/test_agent_loop_subagent_role.py tests/test_agent_loop_run_emit.py
tests/test_agent_loop_token_budget.py tests/test_watch_work.py
tests/test_security_untrusted_context.py
tests/test_unified_playbook_e2e.py tests/test_subagent_manager.py
tests/test_subagent_third_party.py tests/test_sandbox_unit.py
tests/test_subagent_dag_core.py tests/test_subagent_dag_runner.py
tests/test_subagent_dag_control_tools.py
tests/test_litellm_provider_model_names.py tests/test_playbook_stint.py
tests/test_provider_catalog.py tests/test_rpc_model.py
tests/test_subagent_dag_adjudication.py tests/test_tool_search.py
tests/test_acp_replay.py`: 2694 passed, 8 skipped
(`test_provider_catalog.py` cases for providers LiteLLM names no
environment variable for).
- Full suite at the head: `nice -n 19 .venv/bin/python -m pytest -q -p
no:randomly -n 4` in a venv synced with `uv sync --frozen --python 3.12
--all-extras`: 34332 passed, 182 skipped, 2 xfailed, 8 failed. Three are
this machine's standing failures, red on main here too:
`test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`,
`test_knowledge_parser_excel.py::test_a_legacy_workbook_is_converted_and_read`
and
`test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing`
(run as root). The other five are in
`tests/test_config_update_providers.py`, which this change does not
touch: the machine's own `HTTP(S)_PROXY` leaks into tests that build a
fake proxy, and with those variables unset the file passes 193 of 193.
- `ruff check` and `ruff format --check` over the changed Python files:
clean.
- `scripts/check_large_files.py`, `scripts/check_source_language.py` and
`scripts/check_commit_messages.py` over `origin/main..HEAD`, and
`commitlint --from origin/main --to HEAD` plus the prospective squash
header: all exit 0; a malformed control message makes commitlint exit 1.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- A host reply that touches more than one task now opens a `### <task>`
section for each, even for a one-line status. A channel that does not
render markdown shows `###` as text; WeChat is one, as #1050 already
noted.
- Cost: the heading line grows from 48 to 86 tokens on every host turn
(`available_history` measured 125_950, bound 125_500), and the note from
22 to 48 tokens on each dispatch receipt and background DAG
announcement, which stay on the stored messages.
- Known since #1050 and made longer here: after a reload, ui-web
previews a restored tool call's stored result with only the fence
markers dropped, so a `spawn` call's preview shows the note's text. Live
previews use the raw result and do not. A `run_subagent_dag` receipt
runs past the eight preview lines, so its note does not show. The fix
belongs in ui-web, previewing only the fenced part as `defence()`
already does for deliveries: #1072.
- Not covered: the spawn result announcement keeps its own instruction
tail, and DAG node-decision notices and unprompted reports carry no
note.
- #1031 edits the same budget-test comment; whichever lands second
merges the two paragraphs.
- Rollback: revert the squash commit. Nothing is migrated or stored in a
new shape.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

The note is text a Raven module composed, placed outside the untrusted
fence; node output cannot reach it.

## Related Issues

N/A
LivXue added a commit that referenced this pull request Oct 24, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.
LivXue added a commit that referenced this pull request Oct 27, 2026
Since #1050, CONTEXT.md also uses "decision" for a choice the host puts
to the user, which stays open until they make it. The decision node's
_Avoid_ note said a decision is the main agent's answer to an exception
report, which read as the word's only sense; it now names both.
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.

3 participants