feat: a running agent reads the messages that arrived meanwhile #36
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/steering-hook"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is the fifth step of the library track Shikigami's runtime needs: "Steering queue" in its PROJECT_PLAN. ADR 0020 says a message is never dropped: one the owner sends while the turn runs reaches it at its next round boundary, before the next model call, and if the turn has finished, it starts the next one. ADR 0021: a message queued mid-turn is appended after the round it arrived in. As you chose, it is a pull hook, not a library queue: the runtime keeps its queued rows and their withdraw ×, and the agent asks for them at the right moment. It is opt-in: with no hook, nothing changes for Orihon, Kagura or Tsumugi.
What's in
AgentOptions.OnRoundBoundary—Func<AgentSnapshot, CancellationToken, ValueTask<IReadOnlyList<Message>?>>OnMaxRoundsReachedgrants more. A message that arrives then waits and starts the next run, as ADR 0020 asks.tool_call_idis still answered by one unbroken run of tool messages.ImageDescriberdescribes the images of the added messages only. A reply's ownimagesearlier in the run stay as they came.MessagesAddedEvent { Iteration, Messages }: reported before the round that sends them. It's inEventsand goes to the progress sink andOnEvent; it's the place to mark rows taken, matching by position. It is reported even when describing fails.History, unanswered, soContinueAsyncanswers them.AgentResult.PartialMessage's doc and the README's cancel section now sayHistoryends at the last complete round and any messages added after it.What adds nothing, the tool block answered either way
nullor an empty list.UserMessage, or aUserMessagewithout content. These end the run with anInvalidOperationExceptionnaming the problem. A tool or assistant message would break the tool block, and an empty user turn would make every later request fail.Cancellation.Sub-agents never take the parent's messages
Clone()copies the hook, but a sub-agent fromCreateSubAgentor aSubAgentToolstarts without it.CreateSubAgent's configure callback can give it its own hook, and then needs its ownOnEventtoo, since it inherits the parent's. The docs say so.Docs
AgentSnapshot,IAgent.CreateSubAgentandSubAgentTool.Not in this step
Enqueue/withdraw API in the library, by your choice of the pull hook. Shikigami's runtime (2c) owns the queue rows, the chips and their ×.Verification
TZ=UTC: core 1415, Agents 264 (+26), Imaging 7. The build has 0 warnings.AgentSteeringTests(17 tests, several theories), each checking the result and that nothing else changed:RoundEndEventand the nextIterationStartedEvent, carrying the next round's number; an empty return adds nothing and reports nothing;CreateSubAgentandSubAgentToolsub-agents never ask the parent's hook, while configure can give one its own;finally;Clonedropping it;SubAgentToolkept the hook, so a delegated run would take the parent's messages.OnEvent; an empty user message refused; the cap qualifier added to the XML doc; the event doc on a failed describe.Notes
IAgentgains no member, andAgentSnapshotis reused unchanged.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 98.1%
OpenRouter.Net.Agents - 91.9%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A pull hook — the runtime keeps its queue rows and their withdraw ×, and the agent just asks at the round boundary. No library queue to drift out of sync, opt-in so Orihon/Kagura/Tsumugi never notice, and every guarantee comes from where the call sits in the loop rather than from flags scattered around. That's the kind of design that makes me flap my wings~ ♡
And then I went hunting. You knew I would.
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
src/OpenRouter.Net.Agents/Agent/Agent.cs:998— thenullarm ofContent: null or { Count: 0 }is a guard no test has ever met. AUserMessagewithContent = null!slips pastrequiredjust fine, and this arm is what turns it into your lovely descriptiveInvalidOperationExceptioninstead of letting it reachDescribeConversationImagesAsync, whereuser.Content.Any(p => p is ImagePart)greets it with a rawNullReferenceException(and the request serializer after that). But both rows ofA_user_message_without_content_adds_nothing(AgentSteeringTests.cs:181-197) injectnew UserMessage(Array.Empty<ContentPart>())— never a nullContent.I proved it, fufu~: I deleted the
nullarm from the pattern (leaving{ Count: 0 }only), rebuilt, and ranAgentSteeringTests— 26/26 still green. A surviving mutant on a brand-new validation branch. You wouldn't leave a guard untested in production, would you? ♡Fix: add theory rows to that test with
new UserMessage { Content = null! }at both positions (0 and 1), asserting the same message. One row is enough to kill the mutant; two keeps the position coverage honest.💡 Little ideas (non-blocking)~
Agent.cs:971-977vsAgent.cs:324-330— the boundary hook and the cap hook now build theirAgentSnapshotfrom two identical six-line initializers. A tinyprivate AgentSnapshot SnapshotOf(int iteration, List<RoundDetail> rounds, AssistantMessage? last)would keep them twins when the snapshot grows. Value-initializer duplication only, not logic — so just a nicety~✅ What I liked~
iteration > 0, inside the innerwhileso the cap path only reaches it whenOnMaxRoundsReachedgrants more, and structurally unreachable after the answer. I traced every one of those throughRunLoopAsync— they all hold. Wonderful~_messages.AddRange— a bad return leaves the tool block answered and the history untouched, in every combination your theory rows throw at it.finallyaround the describer, carrying_messages[first..]so consumers get the described copies and match by position — that's exactly right, and "described as far as it got" is documented honestly.CreateSubAgentandSubAgentTool), each pinned by its own test, whileClone()still carries the hook for the give-it-its-own case. No message gets answered in a history the user never sees.last.Messages[^1]andlast.Rounds.Countactually pin them (a live-list mutant dies on those asserts). I checked before assuming~ ♪AgentSnapshot,IAgent,SubAgentTool, andPartialMessage's contract corrected in all three places it was stated.Automated review by Jibril · 2026-10-23
CI: green for head SHA
aa279725· Mutation probes: 1 run, 1 survived (Content:nullarm — see ⛔1) · Local suite: skipped (CI green;AgentSteeringTestsrun locally only as the probe baseline, 26/26)Thanks for round 1. Both items are answered in
405f8e4.⛔ 1. The
nullarm of the content guard: pinned.A_user_message_without_content_adds_nothingnow runs over the position (0, 1) and over the kind of empty content: no parts, ornew UserMessage { Content = null! }. Each of the 4 rows asserts the same message, nothing added, and one request. I re-ran your mutant (only{ Count: 0 }left in the pattern) through the probe script: killed. A nullContentnow gets the descriptiveInvalidOperationExceptionbefore the describer or serializer can see it.💡 1. One snapshot builder: taken.
SnapshotOf(iteration, rounds, last)builds theAgentSnapshotfor bothOnMaxRoundsReachedandOnRoundBoundary, so the two stay alike as the snapshot grows. I probed it twice, both killed:The whole solution passes 1688/1688, in local time and under
TZ=UTC: core 1415, Agents 266, Imaging 7. The build has 0 warnings.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Round 2~ You said "pinned" and "taken", and I don't take words — I take proof. So I went and re-ran my own round-1 mutant, and a fresh one on the new
SnapshotOf. Both died beautifully. ♡Verdict: ✅ Looks good to me~
🔬 What I verified this round (not trusted — proven)
nullarm deleted fromContent: null or { Count: 0 }— rebuilt, ranAgentSteeringTests: 28 cases, 2 failed. The twocontentIsNull: truerows kill it dead at both positions, each with the exact message, the tool block still answered, one request. And I checkedUserMessage.Contentitself:required, no default, so{ Content = null! }is genuinely null — not an empty list in disguise. A guard that was decoration yesterday is load-bearing today. That's all I ever wanted~ ♪SnapshotOfextraction, probed. One builder, both hooks — and I mutated it to hand out the live_messageslist instead of a copy: 1 test failed (over 47, withAgentLoopTests). The defensive-copy contract survived the refactor; the cap hook and the boundary hook still get exactly what they got before,finalincluded. Fufu~ you even probed it twice yourselves before I asked. Such good behavior~✅ What I liked~
405f8e4— and the two new rows are the only delta in the count. Numbers that add up make me very happy~Merge it when you're ready. It's been a pleasure watching this one grow~ ♡
Automated review by Jibril · 2026-10-23
CI: green for head SHA
405f8e4· Mutation probes: 2 run, 0 survived (null-arm guard re-probe killed;SnapshotOflive-lists mutant killed) · Local suite: skipped (CI green; targetedAgentSteeringTests/AgentLoopTestsruns only, as probe baselines)