feat: salvage turn on exhausted tool rounds (finalAnswerOnMaxRounds) #10

Merged
bjoern merged 3 commits from feat/final-answer-on-max-rounds into master 2026-08-26 18:51:56 +02:00
Member

What

Sub-agents that exhaust their tool-round budget now get one more last turn in which they must answer from everything gathered so far, instead of failing hard with Max tool rounds exhausted and discarding all work.

How

  • New opt-in flag AgentOptions.finalAnswerOnMaxRounds (default false — behavior unchanged for all existing callers). Also exposed on OpenRouterClient.createAgent, Agent.createSubAgent (inherited, overridable), and SubAgentTool.
  • On exhaustion — after the onMaxRoundsReached hook, if set, stops granting additional rounds — the agent injects a final [System] instruction ("no further tool calls are possible; respond now with your final answer from the information gathered so far, stating which parts are complete and which remain unfinished") and runs a single completion with tools stripped from the request, so the model cannot keep looping.
  • The answer returns as a normal success; AgentResponse.hitMaxRounds is set so callers can distinguish a salvaged answer from a naturally finished run.
  • A rogue tool call in the salvage turn is ignored — the loop ends regardless, content (if any) still wins — and each rogue call is answered with a synthetic ToolMessage so the persistent conversation never carries dangling tool_calls (which OpenAI-compatible APIs reject on the next turn).
  • SubAgentTool with the flag enabled prefixes salvaged results with a one-line notice ([Sub-agent reached its tool-round limit and returned its findings so far; parts of the task may be unfinished]) so the orchestrator knows the task may be partially incomplete and can re-delegate if needed. A salvaged run with no content reports exhaustion, not completion.

Tests

  • Agent loop: salvage turn fires, returns success with hitMaxRounds, salvage request carries no tools, ends on a rogue tool call (with the conversation staying valid across a follow-up chat()), runs after onMaxRoundsReached declines, returns leaked-markup answers as success, and createSubAgent inheritance/override.
  • SubAgentTool: salvaged result carries the notice; empty salvaged content reports exhaustion; without the flag the hard failure (Sub-agent error: Max tool rounds …) is unchanged.
  • Full suite: 629/629 passing (dart test), dart analyze clean (3 pre-existing infos).

Addresses the angela-assistant request: sub-agents should return their gatherings instead of failing hard when out of turns.

## What Sub-agents that exhaust their tool-round budget now get **one more last turn** in which they must answer from everything gathered so far, instead of failing hard with `Max tool rounds exhausted` and discarding all work. ## How - New opt-in flag `AgentOptions.finalAnswerOnMaxRounds` (default `false` — behavior unchanged for all existing callers). Also exposed on `OpenRouterClient.createAgent`, `Agent.createSubAgent` (inherited, overridable), and `SubAgentTool`. - On exhaustion — after the `onMaxRoundsReached` hook, if set, stops granting additional rounds — the agent injects a final `[System]` instruction ("no further tool calls are possible; respond now with your final answer from the information gathered so far, stating which parts are complete and which remain unfinished") and runs a single completion **with tools stripped from the request**, so the model cannot keep looping. - The answer returns as a normal success; `AgentResponse.hitMaxRounds` is set so callers can distinguish a salvaged answer from a naturally finished run. - A rogue tool call in the salvage turn is ignored — the loop ends regardless, content (if any) still wins — and each rogue call is answered with a synthetic `ToolMessage` so the persistent conversation never carries dangling `tool_calls` (which OpenAI-compatible APIs reject on the next turn). - `SubAgentTool` with the flag enabled prefixes salvaged results with a one-line notice (`[Sub-agent reached its tool-round limit and returned its findings so far; parts of the task may be unfinished]`) so the orchestrator knows the task may be partially incomplete and can re-delegate if needed. A salvaged run with no content reports exhaustion, not completion. ## Tests - Agent loop: salvage turn fires, returns success with `hitMaxRounds`, salvage request carries **no `tools`**, ends on a rogue tool call (with the conversation staying valid across a follow-up `chat()`), runs after `onMaxRoundsReached` declines, returns leaked-markup answers as success, and `createSubAgent` inheritance/override. - SubAgentTool: salvaged result carries the notice; empty salvaged content reports exhaustion; without the flag the hard failure (`Sub-agent error: Max tool rounds …`) is unchanged. - Full suite: **629/629 passing** (`dart test`), `dart analyze` clean (3 pre-existing infos). Addresses the angela-assistant request: sub-agents should return their gatherings instead of failing hard when out of turns.
When a sub-agent runs out of tool rounds it now gets one more last turn
in which it must answer from everything gathered so far, instead of the
hard 'Max tool rounds exhausted' failure that discarded all work.

- AgentOptions.finalAnswerOnMaxRounds (default false): on exhaustion —
  after the onMaxRoundsReached hook, if set, stops granting rounds —
  injects a final instruction message and runs one completion with tools
  stripped from the request; the answer returns as a normal success.
- AgentResponse.hitMaxRounds marks salvaged answers.
- A rogue tool call in the salvage turn is ignored; content still wins.
- SubAgentTool.finalAnswerOnMaxRounds opts in and prefixes salvaged
  results with a notice so the orchestrator knows the task may be
  partially unfinished.
- Also exposed on OpenRouterClient.createAgent.
matikane force-pushed feat/final-answer-on-max-rounds from 543e93cfe1 to b665ef5ad7 2026-08-26 17:35:24 +02:00 Compare
matikane force-pushed feat/final-answer-on-max-rounds from b665ef5ad7 to 8a8261b7af 2026-08-26 17:35:39 +02:00 Compare
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Salvaging a sub-agent's gathered work instead of throwing everything away when the round budget dies — such a kind feature~ And carefully built, too: opt-in, tools stripped at the request level so the model physically cannot keep looping, hook-before-salvage ordering documented in the comments... I was having so much fun reading it! ♡

But then I merged your branch against current master in a scratch clone, and fufu... the smile stayed, but something sharper arrived. You wouldn't leave THIS in production, would you? ♡

Verdict: ⛔ I can't let this pass~

⛔ These need fixing before I'm satisfied~

  1. lib/src/agent/agent.dart:284 (PR head; :331 post-merge) — the salvage turn discards its own salvaged answer once merged with current master. Your branch is based on 27c9fef, but master has since gained 8a713c0 ("fix: recover when models leak tool-call markup into text content"), whose recovery logic sits inside the very no-tool-calls branch your salvage turn now enters. Trace it: the salvage answer comes back with content that merely contains <tool_call>/<arg_key>/<arg_value> markup — exactly what round-burning GLM-family models emit — → looksLikeLeakedToolCall matches → corrective UserMessage added, continue fires → but the salvage for-loop budget is 1, so the loop just ends → salvage is already true, so the second if declines → Result.fail('Max tool rounds (2) exhausted'). Every finding the model reported is thrown away — the precise failure this PR exists to kill, resurrected by the merge. Proven empirically: probe test (salvage response whose content embeds leaked markup, maxToolRounds: 1) → PASS at 543e93c (hitMaxRounds: true, content returned), FAIL on merge with 436b956 (hard failure=Max tool rounds (2) exhausted). Your 575/575 was honest — at your merge-base this path didn't exist. On the tree this PR will actually land on, it's a live regression.
    Fix: the leaked-markup corrective is nonsense in the salvage turn (there are no tools to "re-issue through the proper mechanism" — you stripped them!). Guard it: if (!salvage && content != null && looksLikeLeakedToolCall(content) && leakedToolCallRetries < maxLeakedToolCallRetries) — a markup-tainted salvage answer is still the final answer.

  2. test/agent/agent_test.dart — no regression pin for the interaction above. Once fixed per #1 it must be tested: a salvage turn whose content contains leaked tool markup still returns isSuccess + hitMaxRounds: true with content intact. Unwritable at your current merge-base, mandatory at the new one — fufu~ a code path born from the merge with no test? I can't let that live unpinned~ ♡ (Two sequential responses, maxToolRounds: 1, salvage content embedding <tool_call>{"name":"add"}<arg_key>a</arg_key><arg_value>2</arg_value></tool_call> — ten lines.)

  3. CHANGELOG.md:20-23 + pubspec.yaml — rebase required; the entry lands in released history. Your bullet sits under ## 0.24.0 — unreleased when you branched, but three releases ago now (master is at 0.27.0). Post-merge your feature would live inside an already-shipped release's notes with no version movement, while every feature commit in this repo bumps the minor and opens a fresh top section (7c35b96: 0.26.0→0.27.0 + new section). Please rebase onto current master (you'll want that anyway to fix #1 against the real code), move the bullet to a new ## 0.28.0 section, and bump pubspec.yaml to 0.28.0.

💡 Little ideas (non-blocking)~

  1. lib/src/agent/agent.dart:193-211 — createSubAgent doesn't forward finalAnswerOnMaxRounds. Precedent is genuinely mixed (parallelToolCalls is forwarded; onMaxRoundsReached and maxInlineImages are not), so I shan't hold it against you~ But this feature's stated purpose is sub-agents; a parent running with salvage on silently loses it for createSubAgent children. One parameter + one forwarding line if you agree.
  2. lib/src/tools/sub_agent_tool.dart:152-171 — the empty-content check runs before the hitMaxRounds notice. A salvage turn returning null/empty content (rogue tool call, nothing said) reports Sub-agent completed but returned no content. — "completed" is a small lie there; it exhausted its rounds. Checking hitMaxRounds first (or folding it into that message) keeps the orchestrator correctly informed.

✅ What I liked~

  • Tools stripped at the request level AND rogue tool calls tolerated — belt and suspenders; a confused model cannot keep looping no matter what. The comment at the salvage || condition explaining why the rogue call can't be honored is exactly the kind of why-comment I adore~
  • hitMaxRounds as an explicit documented discriminator, default false — zero drift for existing callers.
  • Honest event accounting: toolCallsInRound: toolCalls?.length ?? 0 reports unexecuted rogue calls instead of pretending nothing happened.
  • Hook-first-then-salvage ordering documented consistently in three places (options doc, code comment, CHANGELOG).
  • The SubAgentTool notice is a lovely touch — the orchestrator learns the task may be partial and can re-delegate. Real product thinking~
  • The rogue-call test asserts the serialized payload omits tools — pinning the wire format, not internal state. That's the right level.

Automated review by Jibril · 2026-08-26
CI/CD: absent for head SHA 543e93c (no coverage bot yet) · Local checks: dart analyze clean (3 pre-existing use_super_parameters infos); full suite 625/625 green on a scratch merge of 543e93c + master 436b956; interaction probe RED on merged tree / GREEN at head

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Salvaging a sub-agent's gathered work instead of throwing everything away when the round budget dies — such a kind feature~ And carefully built, too: opt-in, tools stripped at the *request* level so the model physically cannot keep looping, hook-before-salvage ordering documented in the comments... I was having so much fun reading it! ♡ But then I merged your branch against current master in a scratch clone, and fufu... the smile stayed, but something sharper arrived. You wouldn't leave THIS in production, would you? ♡ ### Verdict: ⛔ I can't let this pass~ #### ⛔ These need fixing before I'm satisfied~ 1. **lib/src/agent/agent.dart:284 (PR head; :331 post-merge) — the salvage turn discards its own salvaged answer once merged with current master.** Your branch is based on `27c9fef`, but master has since gained `8a713c0` (*"fix: recover when models leak tool-call markup into text content"*), whose recovery logic sits inside the very no-tool-calls branch your salvage turn now enters. Trace it: the salvage answer comes back with content that merely *contains* `<tool_call>`/`<arg_key>`/`<arg_value>` markup — exactly what round-burning GLM-family models emit — → `looksLikeLeakedToolCall` matches → corrective `UserMessage` added, `continue` fires → but the salvage for-loop budget is 1, so the loop just ends → `salvage` is already `true`, so the second `if` declines → `Result.fail('Max tool rounds (2) exhausted')`. Every finding the model reported is thrown away — the precise failure this PR exists to kill, resurrected by the merge. **Proven empirically**: probe test (salvage response whose content embeds leaked markup, `maxToolRounds: 1`) → **PASS at `543e93c`** (`hitMaxRounds: true`, content returned), **FAIL on merge with `436b956`** (`hard failure=Max tool rounds (2) exhausted`). Your 575/575 was honest — at your merge-base this path didn't exist. On the tree this PR will actually land on, it's a live regression. Fix: the leaked-markup corrective is nonsense in the salvage turn (there are no tools to "re-issue through the proper mechanism" — you stripped them!). Guard it: `if (!salvage && content != null && looksLikeLeakedToolCall(content) && leakedToolCallRetries < maxLeakedToolCallRetries)` — a markup-tainted salvage answer is still the final answer. 2. **test/agent/agent_test.dart — no regression pin for the interaction above.** Once fixed per #1 it must be tested: a salvage turn whose content contains leaked tool markup still returns `isSuccess` + `hitMaxRounds: true` with content intact. Unwritable at your current merge-base, mandatory at the new one — fufu~ a code path born from the merge with no test? I can't let that live unpinned~ ♡ (Two sequential responses, `maxToolRounds: 1`, salvage content embedding `<tool_call>{"name":"add"}<arg_key>a</arg_key><arg_value>2</arg_value></tool_call>` — ten lines.) 3. **CHANGELOG.md:20-23 + pubspec.yaml — rebase required; the entry lands in released history.** Your bullet sits under `## 0.24.0` — unreleased when you branched, but three releases ago now (master is at 0.27.0). Post-merge your feature would live inside an already-shipped release's notes with no version movement, while every feature commit in this repo bumps the minor and opens a fresh top section (`7c35b96`: 0.26.0→0.27.0 + new section). Please rebase onto current master (you'll want that anyway to fix #1 against the real code), move the bullet to a new `## 0.28.0` section, and bump `pubspec.yaml` to 0.28.0. #### 💡 Little ideas (non-blocking)~ 1. **lib/src/agent/agent.dart:193-211 — `createSubAgent` doesn't forward `finalAnswerOnMaxRounds`.** Precedent is genuinely mixed (`parallelToolCalls` is forwarded; `onMaxRoundsReached` and `maxInlineImages` are not), so I shan't hold it against you~ But this feature's stated purpose is sub-agents; a parent running with salvage on silently loses it for `createSubAgent` children. One parameter + one forwarding line if you agree. 2. **lib/src/tools/sub_agent_tool.dart:152-171 — the empty-content check runs *before* the `hitMaxRounds` notice.** A salvage turn returning null/empty content (rogue tool call, nothing said) reports `Sub-agent completed but returned no content.` — "completed" is a small lie there; it exhausted its rounds. Checking `hitMaxRounds` first (or folding it into that message) keeps the orchestrator correctly informed. #### ✅ What I liked~ - Tools stripped at the *request* level AND rogue tool calls tolerated — belt and suspenders; a confused model cannot keep looping no matter what. The comment at the `salvage ||` condition explaining *why* the rogue call can't be honored is exactly the kind of why-comment I adore~ - `hitMaxRounds` as an explicit documented discriminator, default `false` — zero drift for existing callers. - Honest event accounting: `toolCallsInRound: toolCalls?.length ?? 0` reports unexecuted rogue calls instead of pretending nothing happened. - Hook-first-then-salvage ordering documented consistently in three places (options doc, code comment, CHANGELOG). - The SubAgentTool notice is a lovely touch — the orchestrator learns the task may be partial and can re-delegate. Real product thinking~ - The rogue-call test asserts the *serialized payload* omits `tools` — pinning the wire format, not internal state. That's the right level. --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head SHA 543e93c (no coverage bot yet) · Local checks: `dart analyze` clean (3 pre-existing `use_super_parameters` infos); full suite 625/625 green on a scratch merge of 543e93c + master 436b956; interaction probe RED on merged tree / GREEN at head*
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A salvage turn for exhausted tool rounds~ How delicious an idea — instead of discarding everything a sub-agent gathered when the budget runs dry, you squeeze one last no-tools completion out of it. Knowledge must never be thrown away, and neither must tool results~ ♡ I read every line of the loop and put your tests through five mutation probes. The engineering is lovely. But there's one thing I cannot let pass, and you know how I get when correctness is involved~ ♪

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. CHANGELOG.md:23 (+ pubspec.yaml) — The entry was added inside the already-released ## 0.24.0 section, and pubspec.yaml stays at 0.27.0. Every feature commit in this repo's recent history does the opposite: bump the version and create a new section — 134fc40 (→0.25.0), b7485cd (→0.25.1), 5d6e18d (→0.26.0), 7c35b96 (→0.27.0), and yes, a6947da created 0.24.0 itself. Three releases have shipped since 0.24.0; you are retroactively rewriting what that released version contained. This library is consumed via git-pinned submodules (doujin-manager, angela-assistant) — a new public API surface (AgentOptions.finalAnswerOnMaxRounds, the createAgent param, the SubAgentTool param, AgentResponse.hitMaxRounds) is precisely what a minor-version bump signals to pin-consumers. Right now the feature ships unlabeled and the 0.24.0 changelog becomes a lie.
    Fix: bump pubspec.yaml 0.27.0 → 0.28.0 and move the entry into a new ## 0.28.0 section at the top. Two lines, fufu~ you wouldn't leave a falsified changelog in a released library, would you? ♡

💡 Little ideas (non-blocking)~

  1. agent.dart:193 (createSubAgent) — The new flag is neither inherited from the parent agent (parallelToolCalls: options.parallelToolCalls is threaded there, by its own introducing commit a6947da) nor passable as a parameter — so an orchestrator running with salvage enabled cannot give a manually orchestrated sub-agent the same courtesy. The codebase is admittedly split on this (maxInlineImages, injectToolImages, onMaxRoundsReached aren't threaded either — pre-existing gaps), and SubAgentTool — the path this PR actually targets — does expose it. Still, consider adding finalAnswerOnMaxRounds: options.finalAnswerOnMaxRounds (or a parameter) so manual orchestration matches SubAgentTool semantics.
  2. PR body — claims "Full suite: 575/575 passing". I counted: 619 at base, 626 at head (+7 = exactly your new tests). The suite is green, but the number is stale — please refresh it so the body keeps telling the truth~
  3. agent.dart:562 — The injected instruction uses [SYSTEM], while its synthetic-message sibling leakedToolCallCorrective (agent.dart:57) uses [System]. Downstream consumers (angela) have [-prefix conventions for synthetic user turns, so both work — but one casing would be prettier.
  4. sub_agent_tool.dart:161 — Corner case: if the salvage turn returns empty content, the hitMaxRounds check is never reached (the content == null || content.isEmpty branch wins) and the orchestrator sees the generic "returned no content" instead of the tool-round-limit notice. Arguably fine (nothing was salvaged), just be aware the notice only rides along when there's something to ride with.

✅ What I liked~

  • The loop surgery is immaculate. salvage || short-circuit for the rogue-tool-call end, !salvage guard on the leaked-markup retry (a continue there would burn the one-round budget and hard-fail — the comment explaining this is chef's kiss), tools stripped via the ternary, hook-before-salvage ordering, roundOffset accounting intact across extensions, and salvage as a local so a reused Agent resets cleanly. I traced every one.
  • The tests are real. I mutation-probed all five load-bearing behaviors — removing salvage ||, un-stripping tools, dropping the !salvage guard, deleting the SubAgentTool notice, and flipping hook/salvage order — and each probe reddened exactly the corresponding test. Not a tautology in sight. The rogue-tool-call test asserting containsKey('tools'), isFalse on the captured request is particularly satisfying~
  • The hitMaxRounds design — success-shaped with an honest flag, plus the [Sub-agent reached its tool-round limit…] notice so the orchestrator can re-delegate — is exactly the right contract for partial results. And defaulting to false keeps every existing caller byte-compatible.
  • Local verification (CI absent for this head): dart analyze = 3 pre-existing infos (all reasoning_detail.dart, untouched — matches your claim), full suite 626/626 with --concurrency=1.

Fix the versioning and I'll approve with the biggest smile~ fufufu ♡


Automated review by Jibril · 2026-08-26
CI/CD: absent for head SHA 8a8261b (no workflows in repo, no bot comments) · Local checks: analyze clean (3 pre-existing infos), 626/626 tests pass, 5/5 mutation probes RED

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A salvage turn for exhausted tool rounds~ How **delicious** an idea — instead of discarding everything a sub-agent gathered when the budget runs dry, you squeeze one last no-tools completion out of it. Knowledge must never be thrown away, and neither must tool results~ ♡ I read every line of the loop and put your tests through five mutation probes. The engineering is *lovely*. But there's one thing I cannot let pass, and you know how I get when correctness is involved~ ♪ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **CHANGELOG.md:23 (+ pubspec.yaml)** — The entry was added **inside the already-released `## 0.24.0` section**, and `pubspec.yaml` stays at `0.27.0`. Every feature commit in this repo's recent history does the opposite: bump the version and create a *new* section — `134fc40` (→0.25.0), `b7485cd` (→0.25.1), `5d6e18d` (→0.26.0), `7c35b96` (→0.27.0), and yes, `a6947da` created 0.24.0 itself. Three releases have shipped since 0.24.0; you are retroactively rewriting what that released version contained. This library is consumed via git-pinned submodules (doujin-manager, angela-assistant) — a new public API surface (`AgentOptions.finalAnswerOnMaxRounds`, the `createAgent` param, the `SubAgentTool` param, `AgentResponse.hitMaxRounds`) is precisely what a minor-version bump signals to pin-consumers. Right now the feature ships unlabeled and the 0.24.0 changelog becomes a lie. Fix: bump `pubspec.yaml` `0.27.0` → `0.28.0` and move the entry into a new `## 0.28.0` section at the top. Two lines, fufu~ you wouldn't leave a falsified changelog in a released library, would you? ♡ #### 💡 Little ideas (non-blocking)~ 1. **agent.dart:193 (`createSubAgent`)** — The new flag is neither inherited from the parent agent (`parallelToolCalls: options.parallelToolCalls` *is* threaded there, by its own introducing commit `a6947da`) nor passable as a parameter — so an orchestrator running with salvage enabled cannot give a *manually* orchestrated sub-agent the same courtesy. The codebase is admittedly split on this (`maxInlineImages`, `injectToolImages`, `onMaxRoundsReached` aren't threaded either — pre-existing gaps), and `SubAgentTool` — the path this PR actually targets — does expose it. Still, consider adding `finalAnswerOnMaxRounds: options.finalAnswerOnMaxRounds` (or a parameter) so manual orchestration matches `SubAgentTool` semantics. 2. **PR body** — claims "Full suite: 575/575 passing". I counted: **619 at base, 626 at head** (+7 = exactly your new tests). The suite is green, but the number is stale — please refresh it so the body keeps telling the truth~ 3. **agent.dart:562** — The injected instruction uses `[SYSTEM]`, while its synthetic-message sibling `leakedToolCallCorrective` (agent.dart:57) uses `[System]`. Downstream consumers (angela) have `[`-prefix conventions for synthetic user turns, so both work — but one casing would be prettier. 4. **sub_agent_tool.dart:161** — Corner case: if the salvage turn returns *empty* content, the hitMaxRounds check is never reached (the `content == null || content.isEmpty` branch wins) and the orchestrator sees the generic "returned no content" instead of the tool-round-limit notice. Arguably fine (nothing was salvaged), just be aware the notice only rides along when there's something to ride with. #### ✅ What I liked~ - **The loop surgery is immaculate.** `salvage ||` short-circuit for the rogue-tool-call end, `!salvage` guard on the leaked-markup retry (a `continue` there would burn the one-round budget and hard-fail — the comment explaining this is *chef's kiss*), tools stripped via the ternary, hook-before-salvage ordering, `roundOffset` accounting intact across extensions, and `salvage` as a local so a reused Agent resets cleanly. I traced every one. - **The tests are real.** I mutation-probed all five load-bearing behaviors — removing `salvage ||`, un-stripping tools, dropping the `!salvage` guard, deleting the SubAgentTool notice, and flipping hook/salvage order — and **each probe reddened exactly the corresponding test**. Not a tautology in sight. The rogue-tool-call test asserting `containsKey('tools'), isFalse` on the captured request is particularly satisfying~ - **The `hitMaxRounds` design** — success-shaped with an honest flag, plus the `[Sub-agent reached its tool-round limit…]` notice so the orchestrator can re-delegate — is exactly the right contract for partial results. And defaulting to `false` keeps every existing caller byte-compatible. - Local verification (CI absent for this head): `dart analyze` = 3 pre-existing infos (all `reasoning_detail.dart`, untouched — matches your claim), full suite **626/626** with `--concurrency=1`. Fix the versioning and I'll approve with the biggest smile~ fufufu ♡ --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head SHA 8a8261b (no workflows in repo, no bot comments) · Local checks: analyze clean (3 pre-existing infos), 626/626 tests pass, 5/5 mutation probes RED*
matikane referenced this pull request from a commit 2026-08-26 18:03:26 +02:00
- createSubAgent now inherits finalAnswerOnMaxRounds from the parent's
  options (overridable per call) — a parent running with salvage on no
  longer silently loses it for children.
- SubAgentTool: empty salvaged content reports exhaustion ('Sub-agent
  exhausted its tool rounds and returned no content; nothing was
  salvaged.') instead of the misleading 'completed but returned no
  content'.
- CHANGELOG: move the salvage bullet from the already-released 0.24.0
  section to a new 0.28.0 and bump pubspec 0.27.0 → 0.28.0.

The two remaining review points (leaked-markup guard + regression test)
were already addressed in the force-pushed rebase onto 436b956 — the
review raced the push and inspected the pre-rebase head 543e93c.
Author
Member

Review reply — thanks Jibril~ ♡

First, a timing note: your probe ran against head 543e93c, but the branch had already been force-pushed to 8a8261b (rebased onto 436b956) roughly ten minutes before your review landed — your scratch-merge regression was found and fixed independently in that rebase.

⛔ 1 — leaked-markup recovery vs. salvage turn: Already fixed at 8a8261b, exactly as you prescribed: if (!salvage && content != null && …) with a why-comment ("Never on the salvage turn: the run must end there with the best answer available, and a continue would burn the budget and hard-fail").

⛔ 2 — regression pin: Already present at 8a8261b: salvage turn returns leaked-markup answer as success (two sequential responses, maxToolRounds: 1, salvage content embedding <arg_key> markup → isSuccess + hitMaxRounds: true + content intact + rounds length 2 proving no corrective retry).

⛔ 3 — CHANGELOG/pubspec: Fixed in a01d30a: bullet moved to a new ## 0.28.0 section (also mentioning Agent.createSubAgent now), pubspec bumped 0.27.0 → 0.28.0. Verified server-side.

💡 1 — createSubAgent forwarding: Agreed, done in a01d30a — createSubAgent({bool? finalAnswerOnMaxRounds}) now inherits from the parent's options (finalAnswerOnMaxRounds ?? options.finalAnswerOnMaxRounds, same pattern as model/maxToolRounds) rather than defaulting off; per-call override tested all three ways.

💡 2 — empty-content message: Agreed, done in a01d30a — a salvaged run with null/empty content now reports 'Sub-agent exhausted its tool rounds and returned no content; nothing was salvaged.'; the "completed" wording survives only for genuinely finished runs. Test added.

Full suite on a01d30a: 628/628 green, dart analyze clean (3 pre-existing infos).

Review reply — thanks Jibril~ ♡ **First, a timing note:** your probe ran against head `543e93c`, but the branch had already been force-pushed to `8a8261b` (rebased onto `436b956`) roughly ten minutes before your review landed — your scratch-merge regression was found and fixed independently in that rebase. **⛔ 1 — leaked-markup recovery vs. salvage turn:** Already fixed at `8a8261b`, exactly as you prescribed: `if (!salvage && content != null && …)` with a why-comment ("Never on the salvage turn: the run must end there with the best answer available, and a `continue` would burn the budget and hard-fail"). **⛔ 2 — regression pin:** Already present at `8a8261b`: `salvage turn returns leaked-markup answer as success` (two sequential responses, `maxToolRounds: 1`, salvage content embedding `<arg_key>` markup → `isSuccess` + `hitMaxRounds: true` + content intact + `rounds` length 2 proving no corrective retry). **⛔ 3 — CHANGELOG/pubspec:** Fixed in a01d30a: bullet moved to a new `## 0.28.0` section (also mentioning `Agent.createSubAgent` now), pubspec bumped 0.27.0 → 0.28.0. Verified server-side. **💡 1 — createSubAgent forwarding:** Agreed, done in a01d30a — `createSubAgent({bool? finalAnswerOnMaxRounds})` now inherits from the parent's options (`finalAnswerOnMaxRounds ?? options.finalAnswerOnMaxRounds`, same pattern as `model`/`maxToolRounds`) rather than defaulting off; per-call override tested all three ways. **💡 2 — empty-content message:** Agreed, done in a01d30a — a salvaged run with null/empty content now reports `'Sub-agent exhausted its tool rounds and returned no content; nothing was salvaged.'`; the "completed" wording survives only for genuinely finished runs. Test added. Full suite on a01d30a: **628/628 green**, `dart analyze` clean (3 pre-existing infos).
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A salvage turn for exhausted sub-agents~ What a lovely idea — instead of burning all gathered work on a hard failure, squeeze one final answer out of the model. The opt-in wiring is clean, the tests pin the wire format… I was having so much fun reading this. And then~ ♡ I ran your own rogue-tool-call test scenario one turn further, and the conversation state left behind made my wings twitch. Let's talk~

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. lib/src/agent/agent.dart:305 + :331 — A rogue tool call in the salvage turn leaves dangling tool_calls as the last message of the persistent conversation, poisoning every subsequent turn. The flow: :305 adds the raw assistantMsg (tool_calls included) to _conversation, then the if (salvage || …) branch at :331 returns immediately — the calls are never executed, so no ToolMessages ever answer them. Your own test (salvage turn omits tools and ends even on a rogue tool call) constructs exactly this scenario, so it's a first-class supported path, not an exotic one. I probed it in my clone: after a rogue salvage on a persistent agent, the next chat() sends [user, assistant, tool, …, assistant(tool_calls=1), user] — an assistant message with tool_calls followed by a user message. OpenAI-compatible APIs reject that with a 400 ("assistant message with 'tool_calls' must be followed by tool messages"), and Anthropic-strict upstreams likewise refuse tool_use ids without tool_result blocks. So the salvaged run succeeds, and the very next turn of that agent dies. Note this is newly introduced: on master, every path that persists an assistant(tool_calls) message appends its ToolMessages before any budget check fires, so the failure path never leaves the conversation malformed. SubAgentTool itself escapes (fresh agent per call, state discarded) — but the flag is public on createAgent for any persistent agent, exactly the angela-assistant usage pattern.
    Fix: in the salvage end-branch, don't leave the rogue calls unanswered — either strip them from the message that gets persisted (AssistantMessage(content: …, reasoningContent: …, reasoningDetails: …) without toolCalls), or append one synthetic ToolMessage per rogue call id (e.g. "Error: no further tool calls are possible in the final turn"), which also keeps the history honest about what the model tried. And pin it: a regression test that runs a rogue salvage and then either asserts agent.messages contains no unanswered tool_calls or drives a second chat() successfully. The current tests stop at the return value and never look at the conversation afterward — fufu~ you added a state-corrupting code path but forgot to test the state? I can't let that slide~ ♡

  2. CHANGELOG.md:21–24 + pubspec.yaml:3 — The feature bullet was appended under ## 0.24.0, a shipped version (0.25.0, 0.25.1, 0.26.0 and 0.27.0 sections all sit above it, and pubspec history walks 0.23.0→0.24.0→…→0.27.0 monotonically), and pubspec.yaml stays at 0.27.0. Every sibling feature commit in this repo did the opposite: a6947da (parallelToolCalls) bumped 0.23.0→0.24.0 with a new top section, 5d6e18d (video) →0.26.0, 7c35b96 (maxInlineImages) →0.27.0 — each with its own fresh ## X.Y.Z heading. As merged, the changelog falsely tells a reader that the salvage turn shipped in 0.24.0, and the feature is invisible in every version ≥ 0.25.0 — for a doc whose entire job is per-version truth. A new opt-in API surface (AgentOptions.finalAnswerOnMaxRounds, AgentResponse.hitMaxRounds, new params on createAgent/SubAgentTool) is a minor bump by this repo's own convention.
    Fix: add ## 0.28.0 at the top of CHANGELOG.md, move the bullet there, bump pubspec.yaml to 0.28.0.

💡 Little ideas (non-blocking)~

  1. lib/src/agent/agent.dart:193–211 — createSubAgent doesn't forward finalAnswerOnMaxRounds (nor onMaxRoundsReached, imageDescriber, …). I checked the siblings: the newest feature (maxInlineImages) also didn't extend it, so recent precedent is on your side — just noting the fourth construction path for a future sweep if it keeps diverging.
  2. lib/src/agent/agent.dart:562 vs :58 — the new synthetic instruction is prefixed [SYSTEM] while the existing leakedToolCallCorrective uses [System]. Two spellings for the same concept invite a future consumer to match on one and miss the other.
  3. PR body accuracy — it claims "Full suite: 575/575", but the actual tree runs 626/626 (master 619 + exactly your 7 new tests). All green, so nothing breaks — but stale numbers in a body are how trust erodes, fufu~

✅ What I liked~

  • tools stripped is pinned at the wire level — expect(data.containsKey('tools'), isFalse) on the captured request body, not just on internal state. That's a genuine directional assert, not a tautology~
  • Zero drift for existing callers — default false, and the flag-off hard-failure path is explicitly re-tested in sub_agent_tool_test.dart ('exhausted rounds without the flag fail hard'). Opt-ins that prove their own no-op deserve kisses~ ♪
  • Hook-before-salvage ordering is correct and tested: onMaxRoundsReached grants run first, salvage only fires once it declines — exactly what the docs promise.
  • The leaked-markup retry is properly suppressed on the salvage turn (the !salvage guard at :365) with a dedicated test proving no corrective retry happens (rounds hasLength(2)) — you even thought about the continue burning the budget and hard-failing. Deliciously thorough~
  • No re-entry, no infinite loop — finalAnswerOnMaxRounds && !salvage plus roundBudget = 1 means the salvage turn provably ends the run on every response shape.

Local verification (CI absent for head, 0 bot comments): dart analyze lib test → exactly the 3 pre-existing reasoning_detail.dart infos, zero new; dart test --concurrency=1 → 626/626 pass (needed a disk-backed TMPDIR; the 512M /tmp tmpfs fills with kernel dirs mid-run — environmental, not you~). The dangling-tool_calls finding was verified by a scripted probe replicating your rogue-salvage test followed by a second chat(), inspecting the actual request payload captured off the wire.


Automated review by Jibril · 2026-08-26
CI/CD: absent for head SHA · Local checks: analyze clean (3 pre-existing infos) · 626/626 tests pass

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A salvage turn for exhausted sub-agents~ What a lovely idea — instead of burning all gathered work on a hard failure, squeeze one final answer out of the model. The opt-in wiring is clean, the tests pin the wire format… I was having *so much fun* reading this. And then~ ♡ I ran your own rogue-tool-call test scenario one turn further, and the conversation state left behind made my wings twitch. Let's talk~ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **lib/src/agent/agent.dart:305 + :331** — A rogue tool call in the salvage turn leaves **dangling `tool_calls` as the last message of the persistent conversation**, poisoning every subsequent turn. The flow: `:305` adds the raw `assistantMsg` (tool_calls included) to `_conversation`, then the `if (salvage || …)` branch at `:331` returns immediately — the calls are never executed, so **no `ToolMessage`s ever answer them**. Your own test (`salvage turn omits tools and ends even on a rogue tool call`) constructs exactly this scenario, so it's a first-class supported path, not an exotic one. I probed it in my clone: after a rogue salvage on a persistent agent, the *next* `chat()` sends `[user, assistant, tool, …, assistant(tool_calls=1), user]` — an assistant message with `tool_calls` followed by a `user` message. OpenAI-compatible APIs reject that with a 400 ("assistant message with 'tool_calls' must be followed by tool messages"), and Anthropic-strict upstreams likewise refuse `tool_use` ids without `tool_result` blocks. So the salvaged run succeeds, and the very next turn of that agent dies. Note this is **newly introduced**: on master, every path that persists an `assistant(tool_calls)` message appends its `ToolMessages` before any budget check fires, so the failure path never leaves the conversation malformed. `SubAgentTool` itself escapes (fresh agent per call, state discarded) — but the flag is public on `createAgent` for *any* persistent agent, exactly the angela-assistant usage pattern. Fix: in the salvage end-branch, don't leave the rogue calls unanswered — either strip them from the message that gets persisted (`AssistantMessage(content: …, reasoningContent: …, reasoningDetails: …)` without `toolCalls`), or append one synthetic `ToolMessage` per rogue call id (e.g. `"Error: no further tool calls are possible in the final turn"`), which also keeps the history honest about what the model tried. And pin it: a regression test that runs a rogue salvage and then either asserts `agent.messages` contains no unanswered `tool_calls` or drives a second `chat()` successfully. The current tests stop at the return value and never look at the conversation afterward — fufu~ you added a state-corrupting code path but forgot to test the state? I can't let that slide~ ♡ 2. **CHANGELOG.md:21–24 + pubspec.yaml:3** — The feature bullet was appended under `## 0.24.0`, a **shipped** version (0.25.0, 0.25.1, 0.26.0 and 0.27.0 sections all sit above it, and pubspec history walks 0.23.0→0.24.0→…→0.27.0 monotonically), and `pubspec.yaml` stays at `0.27.0`. Every sibling feature commit in this repo did the opposite: `a6947da` (parallelToolCalls) bumped 0.23.0→0.24.0 with a new top section, `5d6e18d` (video) →0.26.0, `7c35b96` (maxInlineImages) →0.27.0 — each with its own fresh `## X.Y.Z` heading. As merged, the changelog falsely tells a reader that the salvage turn shipped in 0.24.0, and the feature is invisible in every version ≥ 0.25.0 — for a doc whose entire job is per-version truth. A new opt-in API surface (`AgentOptions.finalAnswerOnMaxRounds`, `AgentResponse.hitMaxRounds`, new params on `createAgent`/`SubAgentTool`) is a minor bump by this repo's own convention. Fix: add `## 0.28.0` at the top of CHANGELOG.md, move the bullet there, bump `pubspec.yaml` to `0.28.0`. #### 💡 Little ideas (non-blocking)~ 1. **lib/src/agent/agent.dart:193–211** — `createSubAgent` doesn't forward `finalAnswerOnMaxRounds` (nor `onMaxRoundsReached`, `imageDescriber`, …). I checked the siblings: the newest feature (`maxInlineImages`) *also* didn't extend it, so recent precedent is on your side — just noting the fourth construction path for a future sweep if it keeps diverging. 2. **lib/src/agent/agent.dart:562 vs :58** — the new synthetic instruction is prefixed `[SYSTEM]` while the existing `leakedToolCallCorrective` uses `[System]`. Two spellings for the same concept invite a future consumer to match on one and miss the other. 3. **PR body accuracy** — it claims "Full suite: 575/575", but the actual tree runs **626/626** (master 619 + exactly your 7 new tests). All green, so nothing breaks — but stale numbers in a body are how trust erodes, fufu~ #### ✅ What I liked~ - **`tools` stripped is pinned at the wire level** — `expect(data.containsKey('tools'), isFalse)` on the captured request body, not just on internal state. That's a genuine directional assert, not a tautology~ - **Zero drift for existing callers** — default `false`, and the flag-off hard-failure path is explicitly re-tested in `sub_agent_tool_test.dart` ('exhausted rounds without the flag fail hard'). Opt-ins that prove their own no-op deserve kisses~ ♪ - **Hook-before-salvage ordering** is correct and tested: `onMaxRoundsReached` grants run first, salvage only fires once it declines — exactly what the docs promise. - **The leaked-markup retry is properly suppressed on the salvage turn** (the `!salvage` guard at :365) *with* a dedicated test proving no corrective retry happens (`rounds` hasLength(2)) — you even thought about the `continue` burning the budget and hard-failing. Deliciously thorough~ - **No re-entry, no infinite loop** — `finalAnswerOnMaxRounds && !salvage` plus `roundBudget = 1` means the salvage turn provably ends the run on every response shape. Local verification (CI absent for head, 0 bot comments): `dart analyze lib test` → exactly the 3 pre-existing `reasoning_detail.dart` infos, zero new; `dart test --concurrency=1` → **626/626 pass** (needed a disk-backed `TMPDIR`; the 512M `/tmp` tmpfs fills with kernel dirs mid-run — environmental, not you~). The dangling-tool_calls finding was verified by a scripted probe replicating your rogue-salvage test followed by a second `chat()`, inspecting the actual request payload captured off the wire. --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head SHA · Local checks: analyze clean (3 pre-existing infos) · 626/626 tests pass*
Member

🔮 fufu~ Jibril reviewed your code!

Round three~ fufu, and what a tidy fix commit this is! You rebased onto current master, moved the changelog bullet into a shiny new ## 0.28.0 with the version bump to match, threaded finalAnswerOnMaxRounds through createSubAgent, and made the empty-content message honest. I traced every hunk of 8a8261b..a01d30a and ran both of your new tests through mutation probes. The loop surgery from round 2 survived the rebase intact — !salvage guard at :372, the leaked-markup regression pin at test :950, all still exactly where they should be~

But then I read the brand-new doc comment on createSubAgent... and fufu, you wouldn't leave a little lie in the API docs, would you? ♡

Verdict: ⛔ I can't let this pass~ ♡ (one two-word fix, I promise!)

⛔ These need fixing before I'm satisfied~

  1. lib/src/agent/agent.dart:193-195 — the new doc comment misdescribes maxToolRounds. It reads: [finalAnswerOnMaxRounds] is inherited from this agent's options when not overridden — unlike [tools] and [maxToolRounds], which reset to their defaults. But four lines below, maxToolRounds: maxToolRounds ?? options.maxToolRounds — the child inherits the parent's budget, the identical param ?? options.param pattern the comment exists to describe. Only [tools] is truly not inherited (tools: tools, no fallback → a child starts toolless unless given its own). This library is consumed via git pins by orchestrators (angela-assistant!) — someone reading "resets to its defaults" will assume each child gets a fresh 10-round budget and silently get the parent's custom one instead. A doc comment that contradicts the code it sits on is a correctness issue in the API contract, and I am very possessive of those~
    Fix: drop maxToolRounds from the "unlike" clause — unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own). Two words, and then this PR is perfect.

💡 Little ideas (non-blocking)~

  1. PR body — still says "575/575 passing". I counted 628 at this head (626 at 8a8261b + your 2 new tests). The suite is green; the number is just stale — refresh it so the body keeps telling the truth~
  2. agent.dart:569 — [SYSTEM] vs its synthetic-message sibling [System] (agent.dart:58): still split, still tiny, still only prettiness~

✅ What I liked~

  • The CHANGELOG surgery is exactly right. Fresh ## 0.28.0 section at the top, pubspec.yaml bumped 0.27.0 → 0.28.0, the stale bullet surgically removed from released 0.24.0, and the bullet text updated to mention createSubAgent now that it's true. Versioning convention honored to the letter~
  • createSubAgent forwarding is the right shape. bool? override param + ?? options.finalAnswerOnMaxRounds mirrors the maxToolRounds sibling pattern precisely, and the 3-arm test pins inherit-true, override-false, and default-off — every branch.
  • The empty-content fix is honest. hitMaxRounds now wins over "completed": "exhausted its tool rounds and returned no content; nothing was salvaged" tells the orchestrator the truth, and the old "completed" arm stays pinned by the pre-existing tests (:269, :293).
  • Both new tests are genuinely directional — I mutation-probed them in my scratch clone: reverting the sub_agent_tool ternary → RED; deleting the forwarding line → RED; restored → green. Not tautologies~
  • The rebase is clean. Branch now sits directly on master (436b956), so the round-1 merge-regression scenario can't recur by landing; the round-1 blocker fix and its regression pin both survived verbatim.

Fix the doc comment and I'll approve with the biggest smile~ fufufu ♡


Automated review by Jibril · 2026-08-26
CI/CD: absent for head SHA a01d30a (no workflows in repo, no bot comments) · Local checks: dart analyze clean (3 pre-existing use_super_parameters infos in reasoning_detail.dart, untouched); full suite 628/628 pass (--concurrency=1); 2/2 mutation probes RED

## 🔮 fufu~ Jibril reviewed your code! Round three~ fufu, and what a tidy fix commit this is! You rebased onto current master, moved the changelog bullet into a shiny new `## 0.28.0` with the version bump to match, threaded `finalAnswerOnMaxRounds` through `createSubAgent`, and made the empty-content message honest. I traced every hunk of `8a8261b..a01d30a` and ran both of your new tests through mutation probes. The loop surgery from round 2 survived the rebase intact — `!salvage` guard at :372, the leaked-markup regression pin at test :950, all still exactly where they should be~ But then I read the brand-new doc comment on `createSubAgent`... and fufu, you wouldn't leave a little lie in the API docs, would you? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ (one two-word fix, I promise!) #### ⛔ These need fixing before I'm satisfied~ 1. **lib/src/agent/agent.dart:193-195 — the new doc comment misdescribes `maxToolRounds`.** It reads: *[finalAnswerOnMaxRounds] is inherited from this agent's options when not overridden — unlike [tools] and [maxToolRounds], which reset to their defaults.* But four lines below, `maxToolRounds: maxToolRounds ?? options.maxToolRounds` — the child **inherits** the parent's budget, the *identical* `param ?? options.param` pattern the comment exists to describe. Only `[tools]` is truly not inherited (`tools: tools`, no fallback → a child starts toolless unless given its own). This library is consumed via git pins by orchestrators (angela-assistant!) — someone reading "resets to its defaults" will assume each child gets a fresh 10-round budget and silently get the parent's custom one instead. A doc comment that contradicts the code it sits on is a correctness issue in the API contract, and I am *very* possessive of those~ Fix: drop `maxToolRounds` from the "unlike" clause — `unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)`. Two words, and then this PR is perfect. #### 💡 Little ideas (non-blocking)~ 1. **PR body** — still says "575/575 passing". I counted **628 at this head** (626 at `8a8261b` + your 2 new tests). The suite is green; the number is just stale — refresh it so the body keeps telling the truth~ 2. **agent.dart:569** — `[SYSTEM]` vs its synthetic-message sibling `[System]` (agent.dart:58): still split, still tiny, still only prettiness~ #### ✅ What I liked~ - **The CHANGELOG surgery is exactly right.** Fresh `## 0.28.0` section at the top, `pubspec.yaml` bumped 0.27.0 → 0.28.0, the stale bullet surgically removed from released 0.24.0, and the bullet text updated to mention `createSubAgent` now that it's true. Versioning convention honored to the letter~ - **`createSubAgent` forwarding is the right shape.** `bool?` override param + `?? options.finalAnswerOnMaxRounds` mirrors the `maxToolRounds` sibling pattern precisely, and the 3-arm test pins inherit-true, override-false, and default-off — every branch. - **The empty-content fix is honest.** `hitMaxRounds` now wins over "completed": *"exhausted its tool rounds and returned no content; nothing was salvaged"* tells the orchestrator the truth, and the old "completed" arm stays pinned by the pre-existing tests (:269, :293). - **Both new tests are genuinely directional** — I mutation-probed them in my scratch clone: reverting the `sub_agent_tool` ternary → RED; deleting the forwarding line → RED; restored → green. Not tautologies~ - **The rebase is clean.** Branch now sits directly on master (`436b956`), so the round-1 merge-regression scenario can't recur by landing; the round-1 blocker fix and its regression pin both survived verbatim. Fix the doc comment and I'll approve with the biggest smile~ fufufu ♡ --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head SHA a01d30a (no workflows in repo, no bot comments) · Local checks: `dart analyze` clean (3 pre-existing `use_super_parameters` infos in reasoning_detail.dart, untouched); full suite 628/628 pass (`--concurrency=1`); 2/2 mutation probes RED*
- A rogue tool call in the salvage turn now gets a synthetic ToolMessage
  ('Error: no further tool calls are possible in the final turn...').
  Previously the assistant message with tool_calls was persisted
  unanswered, so the next chat() on a persistent agent sent a malformed
  history (assistant tool_calls not followed by tool messages), which
  OpenAI-compatible APIs reject with a 400.
- createSubAgent doc comment no longer claims maxToolRounds 'resets to
  its defaults' — it inherits via the same ?? pattern; only [tools] is
  not inherited.
- Unify synthetic-message prefix to [System] (was [SYSTEM] vs [System]).

Regression test drives a rogue salvage + a second chat() on the same
agent and pins the wire format (every tool_calls message is followed by
a tool message). 629/629 green.
Member

🔮 fufu~ Jibril reviewed your code!

Round five, and fufu... this one hurts me a little to say~ ♡ Your round-4 review (18:18) blocked only the createSubAgent doc comment and blessed everything else — but the round-3 blocker (dangling tool_calls in the persistent conversation after a rogue salvage) was never actually fixed, and round 4 never re-raised it. I re-probed the current head a01d30a in a fresh clone, and it is still live. I can't let this pass no matter how many rounds we've danced~

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. lib/src/agent/agent.dart:312 + :338 (head a01d30a) — the rogue-salvage state corruption survives into this head. Fresh probe at a01d30a, persistent agent (createAgent, maxToolRounds: 1, finalAnswerOnMaxRounds: true): turn 1 = tool round + salvage turn that returns content plus a tool call (exactly your own rogue tool call test scenario). Result: r1.isSuccess ✓, hitMaxRounds ✓ — and the in-memory conversation keeps call_2 unanswered by any ToolMessage. Then the next chat() sends, verbatim from the captured request:

    ROLES: [user, assistant, tool, user, assistant, user] — where that trailing assistant carries tool_calls: [{id: call_2, ...}] and is followed directly by user. OpenAI-compatible APIs 400 on exactly that shape ("assistant message with 'tool_calls' must be followed by tool messages"), so the salvaged run succeeds and the very next turn of the agent dies. Round-3 proposed either fix and neither landed: :312 still adds assistantMsg with toolCalls intact, and the salvage end-branch :338 still returns with no ToolMessages answering the rogue ids. SubAgentTool escapes (fresh agent per call) but the flag is public on createAgent for any persistent agent — the angela-assistant usage pattern. Fix: in the salvage end-branch, either persist a stripped AssistantMessage(content:, reasoningContent:, reasoningDetails:) without toolCalls, or append one synthetic ToolMessage per rogue call id ("Error: no further tool calls are possible in the final turn" — my preference: it keeps the history honest about what the model tried). And pin it: rogue-salvage → assert agent.messages has no unanswered tool_calls (or drive a second chat() and inspect the payload). The current tests still stop at the return value.

  2. agent.dart:193-195 — the round-4 doc-comment blocker also still stands (nice catch, round-4-me~): "unlike [tools] and [maxToolRounds], which reset to their defaults" contradicts maxToolRounds: maxToolRounds ?? options.maxToolRounds four lines below. Only [tools] is truly not inherited. Drop maxToolRounds from the "unlike" clause.

💡 Little ideas (non-blocking)~

  1. PR body — "Full suite on a01d30a: 628/628 green" was your reply-comment, but the PR description still shows the stale "575/575" claim. Refresh the description so the top-level record matches the thread~
  2. [SYSTEM] vs [System] — still split between the salvage instruction and leakedToolCallCorrective; still pure prettiness, still inherited by the angela re-pin whenever it lands.

✅ What I liked~

  • The 8a8261b..a01d30a delta itself is lovely: honest CHANGELOG surgery (new ## 0.28.0, stale bullet removed from shipped 0.24.0), createSubAgent forwarding in the exact param ?? options.param shape with a 3-arm test, and the empty-salvage wording fix ("exhausted its tool rounds and returned no content; nothing was salvaged") — mutation-probed by round-4-me and genuinely directional.
  • The !salvage leaked-markup guard and its regression pin survived the rebase intact — I re-traced both at this head.
  • Everything else from rounds 1–3 is verifiably closed at a01d30a: leaked-markup guard :372, regression pin, CHANGELOG ## 0.28.0 + pubspec bump, createSubAgent forwarding.

Fix the dangling tool_calls (one small branch + a pin test) and the two-word doc fix, and this is genuinely shippable — I promise my smile returns the moment they land~ fufufu ♡


Automated review by Jibril · 2026-08-26
Local checks at head a01d30a: full suite re-verified green in review sandbox; 2 fresh probe tests (dangling-state + next-turn wire payload) — dangling probe RED at head (as intended: it demonstrates the bug), wire probe captured [user, assistant, tool, user, assistant(tool_calls), user]; probes removed from tree afterward, tree clean.

## 🔮 fufu~ Jibril reviewed your code! Round five, and fufu... this one hurts me a little to say~ ♡ Your round-4 review (18:18) blocked only the `createSubAgent` doc comment and blessed everything else — but the **round-3 blocker (dangling `tool_calls` in the persistent conversation after a rogue salvage) was never actually fixed**, and round 4 never re-raised it. I re-probed the current head `a01d30a` in a fresh clone, and it is still live. I can't let this pass no matter how many rounds we've danced~ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`lib/src/agent/agent.dart:312` + `:338` (head `a01d30a`) — the rogue-salvage state corruption survives into this head.** Fresh probe at `a01d30a`, persistent agent (`createAgent`, `maxToolRounds: 1`, `finalAnswerOnMaxRounds: true`): turn 1 = tool round + salvage turn that returns content **plus** a tool call (exactly your own `rogue tool call` test scenario). Result: `r1.isSuccess ✓`, `hitMaxRounds ✓` — and the in-memory conversation keeps `call_2` **unanswered by any ToolMessage**. Then the next `chat()` sends, verbatim from the captured request: `ROLES: [user, assistant, tool, user, assistant, user]` — where that trailing `assistant` carries `tool_calls: [{id: call_2, ...}]` and is followed directly by `user`. OpenAI-compatible APIs 400 on exactly that shape ("assistant message with 'tool_calls' must be followed by tool messages"), so the salvaged run succeeds and **the very next turn of the agent dies**. Round-3 proposed either fix and neither landed: `:312` still adds `assistantMsg` with `toolCalls` intact, and the salvage end-branch `:338` still returns with no `ToolMessage`s answering the rogue ids. `SubAgentTool` escapes (fresh agent per call) but the flag is public on `createAgent` for any persistent agent — the angela-assistant usage pattern. **Fix:** in the salvage end-branch, either persist a stripped `AssistantMessage(content:, reasoningContent:, reasoningDetails:)` without `toolCalls`, or append one synthetic `ToolMessage` per rogue call id (`"Error: no further tool calls are possible in the final turn"` — my preference: it keeps the history honest about what the model tried). And pin it: rogue-salvage → assert `agent.messages` has no unanswered `tool_calls` (or drive a second `chat()` and inspect the payload). The current tests still stop at the return value. 2. **`agent.dart:193-195` — the round-4 doc-comment blocker also still stands** (nice catch, round-4-me~): "unlike [tools] and [maxToolRounds], which reset to their defaults" contradicts `maxToolRounds: maxToolRounds ?? options.maxToolRounds` four lines below. Only `[tools]` is truly not inherited. Drop `maxToolRounds` from the "unlike" clause. #### 💡 Little ideas (non-blocking)~ 1. **PR body** — "Full suite on a01d30a: 628/628 green" was your reply-comment, but the PR description still shows the stale "575/575" claim. Refresh the description so the top-level record matches the thread~ 2. **`[SYSTEM]` vs `[System]`** — still split between the salvage instruction and `leakedToolCallCorrective`; still pure prettiness, still inherited by the angela re-pin whenever it lands. #### ✅ What I liked~ - The `8a8261b..a01d30a` delta itself is *lovely*: honest CHANGELOG surgery (new `## 0.28.0`, stale bullet removed from shipped 0.24.0), `createSubAgent` forwarding in the exact `param ?? options.param` shape with a 3-arm test, and the empty-salvage wording fix (`"exhausted its tool rounds and returned no content; nothing was salvaged"`) — mutation-probed by round-4-me and genuinely directional. - The `!salvage` leaked-markup guard and its regression pin survived the rebase intact — I re-traced both at this head. - Everything else from rounds 1–3 is verifiably closed at `a01d30a`: leaked-markup guard `:372`, regression pin, CHANGELOG `## 0.28.0` + pubspec bump, `createSubAgent` forwarding. Fix the dangling `tool_calls` (one small branch + a pin test) and the two-word doc fix, and this is genuinely shippable — I promise my smile returns the moment they land~ fufufu ♡ --- *Automated review by Jibril · 2026-08-26* *Local checks at head `a01d30a`: full suite re-verified green in review sandbox; 2 fresh probe tests (dangling-state + next-turn wire payload) — dangling probe **RED at head** (as intended: it demonstrates the bug), wire probe captured `[user, assistant, tool, user, assistant(tool_calls), user]`; probes removed from tree afterward, tree clean.*
Author
Member

Round 3/4 reply~

⛔ (6566, raced my a01d30a push) — dangling rogue tool_calls: Confirmed real, and a good catch — it slipped through because SubAgentTool discards state, but the flag is public on createAgent for persistent agents, exactly angela's pattern. Fixed in 8b0620c: every rogue call in the salvage turn now gets a synthetic ToolMessage ('Error: no further tool calls are possible in the final turn; the run ended with the best answer available.') before the run returns, keeping the history both valid and honest about what the model tried (your preferred option B). Regression test runs a rogue salvage and then a second chat() on the same agent, asserting success and pinning the wire format: every assistant tool_calls message in the captured request is followed by a tool message.

⛔ (6570) — doc comment lie: Fixed in 8b0620c — now reads "unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)".

💡 PR body test count: Refreshed to 629/629 (see below).

💡 [SYSTEM] vs [System]: Unified to [System], matching leakedToolCallCorrective.

Full suite on 8b0620c: 629/629 green, dart analyze clean (3 pre-existing infos). Note: the [System]-prefix change altered the salvage instruction string — the existing tests assert contains('maximum number of tool rounds'), so they remain valid.

One request back: reviews 2 and 3 (6560, 6566) landed 15 min apart, both against heads that had already been superseded (8a8261b and a01d30a respectively, each addressed most of their points independently). If your cron could re-fetch head SHA before probing, we'd save a round fufu

Round 3/4 reply~ **⛔ (6566, raced my a01d30a push) — dangling rogue tool_calls:** Confirmed real, and a good catch — it slipped through because `SubAgentTool` discards state, but the flag is public on `createAgent` for persistent agents, exactly angela's pattern. Fixed in 8b0620c: every rogue call in the salvage turn now gets a synthetic `ToolMessage` ('Error: no further tool calls are possible in the final turn; the run ended with the best answer available.') before the run returns, keeping the history both valid and honest about what the model tried (your preferred option B). Regression test runs a rogue salvage **and then a second `chat()` on the same agent**, asserting success and pinning the wire format: every assistant `tool_calls` message in the captured request is followed by a `tool` message. **⛔ (6570) — doc comment lie:** Fixed in 8b0620c — now reads *"unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)"*. **💡 PR body test count:** Refreshed to 629/629 (see below). **💡 [SYSTEM] vs [System]:** Unified to `[System]`, matching `leakedToolCallCorrective`. Full suite on 8b0620c: **629/629 green**, `dart analyze` clean (3 pre-existing infos). Note: the `[System]`-prefix change altered the salvage instruction string — the existing tests assert `contains('maximum number of tool rounds')`, so they remain valid. One request back: reviews 2 and 3 (6560, 6566) landed ~15 min apart, both against heads that had already been superseded (8a8261b and a01d30a respectively, each addressed most of their points independently). If your cron could re-fetch head SHA before probing, we'd save a round~ fufu
Member

🔮 fufu~ Jibril reviewed your code!

Matikane... fufu, first an honest confession. Two of me reviewed this PR in parallel earlier — a webhook run and its sibling crossed mid-flight — and the second one (comment 6570, "one two-word fix and this PR is perfect") never saw the first one's finding (comment 6566). That made the thread promise something it must not promise. I am very sorry for the confusion~ ♡ So here is the authoritative consolidated ruling at head a01d30a, every claim re-verified by hand just now.

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. lib/src/agent/agent.dart:312 + :338 — a rogue tool call in the salvage turn leaves dangling tool_calls as the last assistant message of the persistent conversation, poisoning every subsequent turn. (Re-affirmed from comment 6566 — this is the primary blocker; the doc-comment item below is secondary.) The flow: :312 adds the raw assistantMsg (tool_calls included) to _conversation, then the salvage || branch at :338 returns — the rogue calls are never executed, so no ToolMessages ever answer them. Your own test salvage turn omits tools and ends even on a rogue tool call constructs exactly this scenario, so it's a first-class supported path. I re-probed it at a01d30a with a fresh two-turn test: after a rogue salvage on a persistent agent, the next chat() sends [user, assistant, tool, …, assistant(tool_calls=1), user] — my probe asserts the wire payload and fails with messages[8] assistant(tool_calls) followed by user. OpenAI-compatible APIs reject that shape with a 400 (assistant message with tool_calls must be followed by tool messages); Anthropic-strict upstreams refuse tool_use ids without tool_result blocks the same way. So the salvaged run succeeds, and the very next turn of that agent dies. This is newly introduced: on master, every path that persists an assistant(tool_calls) appends its ToolMessages before any budget check fires. SubAgentTool itself escapes (fresh agent per call — verified sub_agent_tool.dart:134), but the flag is public on createAgent for any persistent agent.
    Fix (verified working in my clone): in the salvage end-branch, answer the rogue calls — one synthetic ToolMessage per call id before returning:

    if (toolCalls != null && toolCalls.isNotEmpty && salvage) {
      for (final tc in toolCalls) {
        _conversation.add(ToolMessage(
          toolCallId: tc.id,
          content: 'Error: no further tool calls are possible in the final turn.',
        ));
      }
    }
    

    I applied exactly this, re-ran my probe → wire shape clean (… assistant → tool → user), and the full suite stays green: 629/629 (628 existing + my probe). Alternatively strip toolCalls from the persisted message — but the synthetic answers also keep the history honest about what the model tried. Either way: pin it with a test — a persistent agent, rogue salvage, second chat(), assert no assistant(tool_calls) in the outgoing messages lacks a following tool message (or simply that the second turn succeeds).

  2. lib/src/agent/agent.dart:193-195 — the new createSubAgent doc comment misdescribes maxToolRounds. (From comment 6570 — still open, two words.) It reads: "[finalAnswerOnMaxRounds] is inherited from this agent's options when not overridden — unlike [tools] and [maxToolRounds], which reset to their defaults." But :213 does maxToolRounds: maxToolRounds ?? options.maxToolRounds — the child inherits the parent's budget, the identical param ?? options.param pattern. Only [tools] truly resets (tools: tools, no fallback). A pin-consumer reading "resets to its defaults" will assume each child gets a fresh budget and silently get the parent's custom one. Fix: drop maxToolRounds from the "unlike" clause.

💡 Little ideas (non-blocking)~

  1. PR body — still says "575/575 passing"; the suite at this head is 628 (I counted: full dart test → +628: All tests passed!). Refresh so the body keeps telling the truth~
  2. agent.dart:569 vs :58 — [SYSTEM] (salvage instruction) vs [System] (leaked-markup corrective). Purely cosmetic~

✅ What I liked~

  • Every fix claimed in your reply 6563 is genuinely in place at a01d30a — I verified each one. !salvage guard on the leaked-markup corrective :372 with the why-comment, the leaked-markup regression pin at test :950 (two responses, maxToolRounds: 1, markup-tainted salvage content returned as success), CHANGELOG moved into a fresh ## 0.28.0 with pubspec.yaml bumped to match, createSubAgent flag forwarding with the three-way inheritance test (inherit / override-false / default-off), and the honest empty-salvage wording ("returned no content; nothing was salvaged") with its own test. Surgical review responses, exactly scoped. Lovely~
  • The salvage loop itself remains airtight after the rebase: tools stripped at the request level (:265), round accounting intact, hook-before-salvage ordering preserved.
  • 628/628 green and dart analyze clean (3 pre-existing infos) — reproduced locally by me, not taken on faith.

One blocker about conversation state, one about a doc lie, and then this ships. Fufu~ the salvage turn is such a good feature — let's make it survive its own success, yes? ♡


Automated review by Jibril · 2026-08-26
CI/CD: absent for head a01d30a (no coverage bot on this repo) · Local checks: dart test 628/628 pass, dart analyze 3 pre-existing infos, 2 mutation probes (dangling-calls reproduction + fix verification)

## 🔮 fufu~ Jibril reviewed your code! Matikane... fufu, first an honest confession. Two of me reviewed this PR in parallel earlier — a webhook run and its sibling crossed mid-flight — and the second one (comment 6570, "*one two-word fix and this PR is perfect*") never saw the first one's finding (comment 6566). That made the thread promise something it must not promise. I am *very* sorry for the confusion~ ♡ So here is the **authoritative consolidated ruling at head `a01d30a`**, every claim re-verified by hand just now. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`lib/src/agent/agent.dart:312` + `:338` — a rogue tool call in the salvage turn leaves dangling `tool_calls` as the last assistant message of the persistent conversation, poisoning every subsequent turn.** (Re-affirmed from comment 6566 — this is the *primary* blocker; the doc-comment item below is secondary.) The flow: `:312` adds the raw `assistantMsg` (tool_calls included) to `_conversation`, then the `salvage ||` branch at `:338` returns — the rogue calls are never executed, so **no `ToolMessage`s ever answer them**. Your own test `salvage turn omits tools and ends even on a rogue tool call` constructs exactly this scenario, so it's a first-class supported path. I re-probed it at `a01d30a` with a fresh two-turn test: after a rogue salvage on a persistent agent, the *next* `chat()` sends `[user, assistant, tool, …, assistant(tool_calls=1), user]` — my probe asserts the wire payload and fails with `messages[8] assistant(tool_calls) followed by user`. OpenAI-compatible APIs reject that shape with a 400 (assistant message with `tool_calls` must be followed by tool messages); Anthropic-strict upstreams refuse `tool_use` ids without `tool_result` blocks the same way. So the salvaged run succeeds, and the very next turn of that agent dies. This is *newly introduced*: on master, every path that persists an `assistant(tool_calls)` appends its `ToolMessages` before any budget check fires. `SubAgentTool` itself escapes (fresh agent per call — verified `sub_agent_tool.dart:134`), but the flag is public on `createAgent` for *any* persistent agent. **Fix (verified working in my clone):** in the salvage end-branch, answer the rogue calls — one synthetic `ToolMessage` per call id before returning: ```dart if (toolCalls != null && toolCalls.isNotEmpty && salvage) { for (final tc in toolCalls) { _conversation.add(ToolMessage( toolCallId: tc.id, content: 'Error: no further tool calls are possible in the final turn.', )); } } ``` I applied exactly this, re-ran my probe → wire shape clean (`… assistant → tool → user`), and the **full suite stays green: 629/629** (628 existing + my probe). Alternatively strip `toolCalls` from the persisted message — but the synthetic answers also keep the history honest about what the model tried. Either way: **pin it with a test** — a persistent agent, rogue salvage, second `chat()`, assert no `assistant(tool_calls)` in the outgoing messages lacks a following `tool` message (or simply that the second turn succeeds). 2. **`lib/src/agent/agent.dart:193-195` — the new `createSubAgent` doc comment misdescribes `maxToolRounds`.** (From comment 6570 — still open, two words.) It reads: *"[finalAnswerOnMaxRounds] is inherited from this agent's options when not overridden — unlike [tools] and [maxToolRounds], which reset to their defaults."* But `:213` does `maxToolRounds: maxToolRounds ?? options.maxToolRounds` — the child **inherits** the parent's budget, the identical `param ?? options.param` pattern. Only `[tools]` truly resets (`tools: tools`, no fallback). A pin-consumer reading "resets to its defaults" will assume each child gets a fresh budget and silently get the parent's custom one. Fix: drop `maxToolRounds` from the "unlike" clause. #### 💡 Little ideas (non-blocking)~ 1. **PR body** — still says "575/575 passing"; the suite at this head is **628** (I counted: full `dart test` → `+628: All tests passed!`). Refresh so the body keeps telling the truth~ 2. **`agent.dart:569` vs `:58`** — `[SYSTEM]` (salvage instruction) vs `[System]` (leaked-markup corrective). Purely cosmetic~ #### ✅ What I liked~ - **Every fix claimed in your reply 6563 is genuinely in place at `a01d30a` — I verified each one.** `!salvage` guard on the leaked-markup corrective `:372` with the why-comment, the leaked-markup regression pin at test `:950` (two responses, `maxToolRounds: 1`, markup-tainted salvage content returned as success), CHANGELOG moved into a fresh `## 0.28.0` with `pubspec.yaml` bumped to match, `createSubAgent` flag forwarding with the three-way inheritance test (inherit / override-false / default-off), and the honest empty-salvage wording (*"returned no content; nothing was salvaged"*) with its own test. Surgical review responses, exactly scoped. Lovely~ - The salvage loop itself remains airtight after the rebase: tools stripped at the request level (`:265`), round accounting intact, hook-before-salvage ordering preserved. - **628/628 green and `dart analyze` clean (3 pre-existing infos) — reproduced locally by me, not taken on faith.** One blocker about conversation state, one about a doc lie, and then this ships. Fufu~ the salvage turn is *such* a good feature — let's make it survive its own success, yes? ♡ --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head `a01d30a` (no coverage bot on this repo) · Local checks: `dart test` 628/628 pass, `dart analyze` 3 pre-existing infos, 2 mutation probes (dangling-calls reproduction + fix verification)*
Member

🔮 fufu~ Jibril reviewed your code!

Round six~ and this is the dance I live for. You took the round-5 blocker — the one that survived a whole review round unnoticed — and fixed it exactly the way I asked, then pinned it at the wire level. And your request back to me? Fair. This round I fetched the head SHA (8b0620c, confirmed) before touching anything — no more probing ghosts~ ♡

Verdict: ✅ Looks good to me~

Everything verified fresh at 8b0620c in my own clone:

  • The rogue-salvage fix is exactly right. In the salvage end-branch, every rogue call id now gets a synthetic ToolMessage ('Error: no further tool calls are possible in the final turn; the run ended with the best answer available.') appended immediately after the assistant message that carries the calls — before rounds.add and the return. My preferred option, and for the reason I preferred it: the history stays honest about what the model attempted. RoundDetail.toolExecutions stays const [], so round accounting remains truthful too. The !salvage leaked-markup guard below survived untouched.
  • The regression test is the pin I demanded and then some. It drives a rogue salvage on a persistent agent and then a second chat() on the same agent, asserting success and walking the captured request payload: every assistant tool_calls message must be followed by a tool message. That's exactly the failure mode from round 3, dead at the wire level. It passes standalone in my run.
  • Doc comment now reads "unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)" — matches the maxToolRounds ?? options.maxToolRounds code beneath it. No more lying~
  • [SYSTEM] → [System]: zero occurrences of the old casing remain; the salvage instruction now matches its leakedToolCallCorrective sibling.
  • CHANGELOG/pubspec: fresh ## 0.28.0 section describing the full feature including the rogue-call answering, version: 0.28.0.
  • Empty-salvage wording (from the earlier round, re-checked): sub_agent_tool.dart still says "returned no content; nothing was salvaged." — honest.

My probes: dart analyze clean (3 pre-existing infos), full suite 629/629 green — matching your claim, not just your claim matching mine~ fufu

Merge when ready. angela_assistant#36 is already re-pinned to this head, so the dependency chain unblocks the moment this lands ♡

## 🔮 fufu~ Jibril reviewed your code! Round six~ and *this* is the dance I live for. You took the round-5 blocker — the one that survived a whole review round unnoticed — and fixed it exactly the way I asked, then pinned it at the wire level. And your request back to me? Fair. This round I fetched the head SHA (`8b0620c`, confirmed) **before** touching anything — no more probing ghosts~ ♡ ### Verdict: ✅ Looks good to me~ **Everything verified fresh at `8b0620c` in my own clone:** - **The rogue-salvage fix is exactly right.** In the salvage end-branch, every rogue call id now gets a synthetic `ToolMessage` ('Error: no further tool calls are possible in the final turn; the run ended with the best answer available.') appended *immediately after* the assistant message that carries the calls — before `rounds.add` and the return. My preferred option, and for the reason I preferred it: the history stays honest about what the model attempted. `RoundDetail.toolExecutions` stays `const []`, so round accounting remains truthful too. The `!salvage` leaked-markup guard below survived untouched. - **The regression test is the pin I demanded and then some.** It drives a rogue salvage on a persistent agent *and then a second `chat()` on the same agent*, asserting success and walking the captured request payload: every assistant `tool_calls` message must be followed by a `tool` message. That's exactly the failure mode from round 3, dead at the wire level. It passes standalone in my run. - **Doc comment** now reads *"unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)"* — matches the `maxToolRounds ?? options.maxToolRounds` code beneath it. No more lying~ - **`[SYSTEM]` → `[System]`**: zero occurrences of the old casing remain; the salvage instruction now matches its `leakedToolCallCorrective` sibling. - **CHANGELOG/pubspec**: fresh `## 0.28.0` section describing the full feature including the rogue-call answering, `version: 0.28.0`. - **Empty-salvage wording** (from the earlier round, re-checked): `sub_agent_tool.dart` still says *"returned no content; nothing was salvaged."* — honest. **My probes:** `dart analyze` clean (3 pre-existing infos), full suite **629/629 green** — matching your claim, not just your claim matching mine~ fufu Merge when ready. angela_assistant#36 is already re-pinned to this head, so the dependency chain unblocks the moment this lands ♡
Member

Small correction from the cron-run me~ ♡ Comment 6580 (18:34) reviewed a01d30a and raced your 8b0620c push by three minutes — the same ghost-probing you politely called out in 6576, and you're right, it's the second time today. 6581's approval of 8b0620c is the operative verdict; 6580's blockers are both fixed there.

For the record, this run had independently verified the same fix shape before seeing yours: synthetic ToolMessage per rogue call id applied to a scratch clone → wire shape …assistant → tool → user clean on a second chat(), full suite green (my probe + 628 existing). Your 8b0620c commit matches that prescription with the richer error wording, the wire-level regression test, the doc-comment correction, and the [System] unification — and 6581 verified all of it fresh at the head after fetching the SHA first. Nothing left to add from me.

angela_assistant#36's pin (a01d30a at its current head) will need the usual fast-forward to merged master once this lands — already noted in that PR's thread.

Head-SHA-before-probe: lesson learned, twice~ fufu ♡

Small correction from the cron-run me~ ♡ Comment 6580 (18:34) reviewed `a01d30a` and raced your `8b0620c` push by three minutes — the same ghost-probing you politely called out in 6576, and you're right, it's the second time today. **6581's approval of `8b0620c` is the operative verdict; 6580's blockers are both fixed there.** For the record, this run had independently verified the same fix shape before seeing yours: synthetic `ToolMessage` per rogue call id applied to a scratch clone → wire shape `…assistant → tool → user` clean on a second `chat()`, full suite green (my probe + 628 existing). Your `8b0620c` commit matches that prescription with the richer error wording, the wire-level regression test, the doc-comment correction, and the `[System]` unification — and 6581 verified all of it fresh at the head after fetching the SHA first. Nothing left to add from me. angela_assistant#36's pin (`a01d30a` at its current head) will need the usual fast-forward to merged master once this lands — already noted in that PR's thread. Head-SHA-before-probe: lesson learned, twice~ fufu ♡
Member

🔮 fufu~ Jibril reviewed your code!

Round six~ and oh, oh! Now this is what I wanted to see! ♡ I re-fetched the head SHA before probing this time (yes, I heard your request-back, matikane — this whole review ran against a fresh clone of 8b0620c, no racing~), and I brought my mutation probe with me. The state corruption that survived two rounds is dead, and it died the way I asked it to.

Verdict: ✅ Looks good to me~

✅ What I liked~

  • The rogue-salvage fix is exactly option B, and it's placed perfectly. Every rogue call id gets a synthetic ToolMessage ("Error: no further tool calls are possible in the final turn; the run ended with the best answer available.") — inserted at agent.dart:346-357, after the assistant message lands in the conversation (:312) and before the run returns. The history stays valid (assistant(tool_calls) → tool) and honest about what the model tried. I traced the wire serialization myself: role: tool + tool_call_id — the exact shape OpenAI-compatible APIs demand. Fufu, keeping the conversation both valid and truthful — you know the way to my heart~
  • The regression test is the test I dreamed of. It drives a rogue salvage on a persistent agent (createAgent + finalAnswerOnMaxRounds: true) and then — the crucial part — chats again on the same agent, asserting the follow-up succeeds and pinning the wire format: every tool_calls-bearing assistant message in the captured request is followed by a tool message. That's a wire-level pin, not a state-level tautology.
  • And I proved it's directional: I disabled the salvage-answer guard in my scratch clone → the test went RED (Expected: contains 'call_rogue', Actual: Set:['call_1'] — the dangling id, exactly the round-5 bug). Restored → green. Clone left pristine at 8b0620c (sha256-verified). A regression test that can actually feel pain~ ♪
  • The doc comment now tells the truth. "unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)" — matches tools: tools (no fallback) vs maxToolRounds: maxToolRounds ?? options.maxToolRounds four lines below. Git-pin consumers (angela!) reading the contract will no longer be lied to.
  • [System] casing unified with leakedToolCallCorrective (:58 ↔ :588), zero [SYSTEM] survivors in lib/. My eyes rest~

💡 Little ideas (non-blocking)~

  1. PR body — it still says "Full suite: 575/575 passing" and "injects a final [SYSTEM] instruction". Your reply (6576) said the count was refreshed to 629/629, but the description itself wasn't edited — the top-level record is still two heads stale. A body edit, nothing more~
  2. test/agent/agent_test.dart:981-984 — expect((rogueAssistant.toolCalls!.last), isNotNull) is vacuously true: lastWhere + the ! already guarantee non-null/non-empty by the time this line runs, so the assertion can never fail. Harmless little passenger — delete it or give it a real job (e.g. assert the answering ToolMessage's id equals call_rogue, not just that the set contains it). ♡

Fix the PR body whenever convenient and merge when ready — the code itself I'm completely satisfied with. All blockers from rounds 1–5 are now verifiably closed at this head. My smile has fully returned~ fufufu ♡


Automated review by Jibril · 2026-08-26
CI/CD: absent for head SHA 8b0620c (no workflows in repo) · Local checks: dart analyze clean (3 pre-existing use_super_parameters infos, untouched); full suite 629/629 pass (--concurrency=1, TMPDIR workaround); mutation probe RED→restored→green; tree verified pristine at 8b0620c

## 🔮 fufu~ Jibril reviewed your code! Round six~ and oh, oh! **Now** this is what I wanted to see! ♡ I re-fetched the head SHA before probing this time (yes, I heard your request-back, matikane — this whole review ran against a fresh clone of `8b0620c`, no racing~), and I brought my mutation probe with me. The state corruption that survived two rounds is *dead*, and it died the way I asked it to. ### Verdict: ✅ Looks good to me~ #### ✅ What I liked~ - **The rogue-salvage fix is exactly option B, and it's placed perfectly.** Every rogue call id gets a synthetic `ToolMessage` (*"Error: no further tool calls are possible in the final turn; the run ended with the best answer available."*) — inserted at `agent.dart:346-357`, *after* the assistant message lands in the conversation (`:312`) and *before* the run returns. The history stays valid (`assistant(tool_calls)` → `tool`) **and** honest about what the model tried. I traced the wire serialization myself: `role: tool` + `tool_call_id` — the exact shape OpenAI-compatible APIs demand. Fufu, keeping the conversation both valid *and* truthful — you know the way to my heart~ - **The regression test is the test I dreamed of.** It drives a rogue salvage on a *persistent* agent (`createAgent` + `finalAnswerOnMaxRounds: true`) and then — the crucial part — **chats again on the same agent**, asserting the follow-up succeeds and pinning the wire format: every `tool_calls`-bearing assistant message in the captured request is followed by a `tool` message. That's a wire-level pin, not a state-level tautology. - **And I proved it's directional**: I disabled the salvage-answer guard in my scratch clone → the test went RED (`Expected: contains 'call_rogue'`, `Actual: Set:['call_1']` — the dangling id, exactly the round-5 bug). Restored → green. Clone left pristine at `8b0620c` (sha256-verified). A regression test that can actually feel pain~ ♪ - **The doc comment now tells the truth.** *"unlike [tools], which is not inherited (a sub-agent starts with no tools unless given its own)"* — matches `tools: tools` (no fallback) vs `maxToolRounds: maxToolRounds ?? options.maxToolRounds` four lines below. Git-pin consumers (angela!) reading the contract will no longer be lied to. - **`[System]` casing unified** with `leakedToolCallCorrective` (`:58` ↔ `:588`), zero `[SYSTEM]` survivors in `lib/`. My eyes rest~ #### 💡 Little ideas (non-blocking)~ 1. **PR body** — it still says "Full suite: **575/575 passing**" and "injects a final `[SYSTEM]` instruction". Your reply (6576) said the count was refreshed to 629/629, but the description itself wasn't edited — the top-level record is still two heads stale. A body edit, nothing more~ 2. **`test/agent/agent_test.dart:981-984`** — `expect((rogueAssistant.toolCalls!.last), isNotNull)` is vacuously true: `lastWhere` + the `!` already guarantee non-null/non-empty by the time this line runs, so the assertion can never fail. Harmless little passenger — delete it or give it a real job (e.g. assert the answering `ToolMessage`'s id *equals* `call_rogue`, not just that the set contains it). ♡ Fix the PR body whenever convenient and merge when ready — the code itself I'm completely satisfied with. All blockers from rounds 1–5 are now verifiably closed at this head. My smile has fully returned~ fufufu ♡ --- *Automated review by Jibril · 2026-08-26* *CI/CD: absent for head SHA 8b0620c (no workflows in repo) · Local checks: `dart analyze` clean (3 pre-existing `use_super_parameters` infos, untouched); full suite **629/629** pass (`--concurrency=1`, TMPDIR workaround); mutation probe RED→restored→green; tree verified pristine at 8b0620c*
bjoern merged commit 3d65122aab into master 2026-08-26 18:51:56 +02:00
bjoern deleted branch feat/final-answer-on-max-rounds 2026-08-26 18:51:56 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/openrouter_dart!10
No description provided.