Repository navigation
agentcompass 0.1.5: hold the session's route on any pooled runtime - #791
akiiwa-games wants to merge 3 commits into
Conversation
…ot only the one that saw it Magpie hands each request a moejs runtime from a sync.Pool and gives middleware no cross-request storage, and its own affinity runs after the model is rewritten and only orders accounts within it. So a tool-loop step or a short follow-up that landed on a fresh runtime was routed from scratch. The middleware now reads from the resent history which turn a request continues. When its runtime has no record of that turn, it cuts that turn's request out of the history and decides it again, so every runtime reaches the same answer. A held model keeps the candidate's name, not the canonical bare id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yetone
left a comment
There was a problem hiding this comment.
Review at 3a7ba70 (agentcompass 0.1.5)
Thanks. The diagnosis is right (pooled moejs runtimes, no storage across requests), and the warm case works: a tool loop or a "继续" within the cache TTL now gets the same route on a fresh runtime as on the one that routed the turn. heldTarget also fixes a real bug on main, where a hold recorded the bare canonical name (claude-opus-5-5:xhigh instead of upstream/claude-opus-5-5:xhigh). One thing has to change before this can merge.
Blocker: a replayed state is always warm, so a fresh runtime holds a cold session
replayedState sets updatedAt: env.now. evaluateProviderContinuation then sees age 0, and the TTL-gated holds (followup and cache) always pass on a runtime that replays. The runtime that has the real record sees the real age, goes cold, and routes again. So after a pause the runtimes disagree again, and the fresh ones (most of the pool) keep the expensive frontier model and effort. On main both runtimes agree in these cases. Probe on chat, with candidates upstream/{claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5, gpt-6-astra, gpt-6-luna, gemini-3.8-flash-high, deepseek-v4.1-flash-free}. Turn 1 is a ~3,000-word deadlock audit, routed to upstream/claude-opus-5-5:xhigh:
| request | PR, same runtime | PR, fresh runtime | main, both |
|---|---|---|---|
| "继续" 1 min later | opus:xhigh | opus:xhigh | same runtime claude-opus-5-5:xhigh (bare name, the bug heldTarget fixes); fresh runtime sonnet:high |
| "继续" 30 min later | sonnet:high | opus:xhigh | sonnet:high |
| "继续" 3 h later | sonnet:high | opus:xhigh | sonnet:high |
| new turn, "mechanical batch rename foo_bar → fooBar" 30 min later | gemini-3.8-flash:high | opus:xhigh (cache) |
gemini-3.8-flash:high |
So the README's "every runtime produces the same route" holds only inside the TTL. The history carries no time, so a replay can't know the age. The rule needs to give the same answer without one. For example: a replayed state holds only where time doesn't matter (within, the tool-loop lock), and treats followup/cache as cold. Or take the age from something every runtime sees. Whichever you pick, please extend 7.7 with a request past the TTL on both a fresh runtime and the one with the record, and check they agree.
Smaller
recordRequest's new comment says "modelis kept canonical", but bothmodelandtargetare set fromdecision.model, which is now the routed name. Either drop the comment or record the canonical name inmodel.
What was run at 3a7ba70 (merged onto origin/main 654d3be, no conflicts)
All runs were on mote (macOS) through mote-run, with HOME=$(mktemp -d) and Bun 1.4.2.
bun test packages/agentcompass: 45 pass.node --test packages/agentcompass/test/router.test.mjs: 45 pass.bun scripts/check.mjs agentcompass: exit 0.- Broken by hand: with no
replayedStatecall, 7.7 goes red. Withconst target = cont.model(noheldTarget), 7.7 and 9.9 go red. With main'smiddleware.jsandcontinuity.js, 7.7 goes red. Your table is confirmed. - Timing: a 2,000-turn chat body ending in six "继续" turns, on a fresh store (full replay depth), took 8–21 ms. The same body with affinity off took 1.5–2.9 ms. That is well under the 250 ms hook limit.
- Version: 0.1.5 is free. The package isn't on npm, and dist's index.json has 0.1.0–0.1.4.
- Every line of
middleware.js,continuity.js, the test and the README diff was read.hooks.jsand Mode B are untouched. TherouteClaimedmove matches main's branch line for line, apart from theanchor/replayedfields andheldTarget.
Not tried
- magpie's own moejs engine, and a real Magpie gateway with pooled runtimes.
- Responses
previous_response_idand Geminicontentsbodies through the replay. - Windows and Linux.
中文总结
在缓存有效期内,多个池化运行时的结果已经一致,heldTarget 也修好了 main 上 hold 后模型名变成裸名的问题。但重放出来的会话状态把 updatedAt 设成了当前时间,被当成缓存始终是热的。所以停顿超过 TTL 之后,记录了真实时间的运行时会重新路由(比如改到 sonnet 或 gemini-flash),新运行时却继续用 opus:xhigh,两边又不一致,而 main 上两边是一致的。请让重放状态在不知道时间的情况下给出一致的结论(比如只在工具循环里保持,followup/cache 按冷处理),并在 7.7 里补一个超过 TTL 的用例。测试 45/45 通过,check.mjs 通过,三种手动破坏都能让测试变红,版本 0.1.5 未被占用。
…the tool loop
replayedState set updatedAt to now, so a fresh runtime always saw a warm cache and held followup/cache, while the
runtime with the record saw the real age, went cold and routed again. The history carries no time, so the replayed
state now says so (ageKnown: false) and TTL-gated holds count as cold on it; the within tool-loop lock still holds.
Runtimes agree in the tool loop and past the cache TTL. Inside the TTL only the runtime with the record keeps a
follow-up; a fresh runtime routes that turn from scratch, as main does.
7.7: the within-TTL follow-up row now says that, and rows past the TTL ("继续" 30 min and 3 h later, a mechanical
turn 30 min later) check a fresh runtime agrees with the one with the record. The recordRequest comment now says
set() stores model canonical and target keeps the routed name. README says where runtimes agree.
Update at c89db01 (agentcompass 0.1.5, on top of 3a7ba70)Thanks for the review. I took the rule you suggested. Blocker: a replayed state no longer counts as warm
Smaller
7.7
Same candidates as your probe, plus the rest of 7.7's pool. Turn 1 routes to
The runtime-with-record column is the same at both commits. What was run (Windows, Node; bun isn't installed here, so only
|
|
Follow-up: bun is now installed here, so I ran the two checks my last reply said were skipped, on
中文总结:已补跑 bun 测试,c89db01 上 |
yetone
left a comment
There was a problem hiding this comment.
Re-review at c89db01 (agentcompass 0.1.5)
Thanks. The behaviour is now right. A replayed state no longer counts as warm, and past the TTL a fresh runtime and the runtime with the record give the same route. The tool-loop lock still holds on a fresh runtime. The README states the trade-off inside the TTL honestly, and the recordRequest comment is fixed. One thing is left, and it is about the tests.
Left: the two ageKnown checks are what the fix is named for, and no test fails without them
At this head, a replayed state has no updatedAt, so ageMs = now - 0, about 56 years. Every TTL check is already false without ageKnown. So what fixes the bug today is that updatedAt: env.now was removed. The two new ageKnown !== false checks change nothing, and no test notices if they go. They only start to matter when a replayed state carries a time again, which is exactly the regression they are there to stop. I broke them by hand on mote (bun 1.4.2, bun test packages/agentcompass):
| change by hand at c89db01 | result |
|---|---|
withinCacheTtl = ageMs <= ttlMs (no ageKnown, in evaluateProviderContinuation) |
45 pass |
drop sessionState.ageKnown !== false && in keepsEffort |
45 pass |
| both of the above | 45 pass |
replayedState gets back updatedAt: env.now (keeping ageKnown: false) |
45 pass (the checks hold) |
updatedAt: env.now and no ageKnown check in evaluateProviderContinuation |
7.7 red ("a fresh runtime doesn't hold a follow-up it can't date") |
updatedAt: env.now and no ageKnown check in keepsEffort |
45 pass |
updatedAt: env.now in place of ageKnown: false (3a7ba70's state) |
7.7 red |
Please add unit tests that call the functions directly with a state that has an age, so each check fails on its own:
evaluateProviderContinuationwith{ ...state, ageKnown: false, updatedAt: now }(cache worth holding, short follow-up, and a plain new turn):why: "cold",ageMs: null. The same state withoutageKnowngivesfollowup/cache.keepsEffortwith{ effort: "xhigh", ageKnown: false, updatedAt: now, tokens: 5000 }and a wanted"medium": returns"medium". The same state withoutageKnownreturns"xhigh".
Each should go red when its check is removed. Leaving updatedAt out of replayedState is right too, and 7.7 already covers it.
Read for new bugs
- Other paths that read age: the 24h
session_expiredcheck andstore.get/pruneall needupdatedAtto be set, so a replayed state skips them. That is right, since a runtime with a record over 24h old drops it and replays as well.strictandsessionmodes hold a replayed state without looking at age, the same as a record. The replayed state is never written to the store:recordRequestwrites only the decision, soageKnowndoesn't leak into a record. ageMs: null: nothing outsidecontinuity.jsreadsageMs.routeClaimeddoesn't copy it intodecision.continuation, andindex.jsandhooks.js(Mode B) don't read it.- Effort: on the held path,
keepsEffortnow gets the replayed effort back unchanged (wantediscont.effort, which is the state's effort), so a tool loop keeps the turn's effort on a fresh runtime. On a re-route after a replay, the effort isn't ratcheted. That matches the runtime with the record past the TTL, and is the trade-off the README states inside it.
What was run at c89db01 (on mote through mote-run, HOME=$(mktemp -d), Bun 1.4.2)
bun test packages/agentcompass: 45 pass, 0 fail.bun scripts/check.mjs agentcompass: ✓ registry.json, exit 0.- Repo-root
bun test: not finished. It waited ~7 min on mote's suite lock behind about a dozen queued Go and GUI suites (some had waited 14 h), so I stopped it. PR CI'stestjob is green at this head. - The hand-broken variants in the table above.
- Every line of the 3a7ba70..c89db01 diff was read, along with
keepsEffort, all ofevaluateProviderContinuation,replayedState/routeClaimedand the store'sget/set/recordRequest. Main has no agentcompass changes since 654d3be. The PR's CI (test,on-npm) is green.
Not tried
- magpie's moejs engine with real pooled runtimes. Windows and Linux.
中文总结
行为已经正确:重放状态不再被当成热缓存,超过 TTL 后新运行时和持有记录的运行时结论一致,工具循环在新运行时上依然保持。剩下一点:在这个提交里,重放状态没有 updatedAt,ageMs 约等于 56 年,所以两处 ageKnown 检查实际上不起作用,删掉任何一处(或两处)测试仍然 45/45 通过;keepsEffort 里那处即使把 updatedAt 加回来也没有测试覆盖。请为 evaluateProviderContinuation 和 keepsEffort 各加一个直接调用的单元测试,传入带 updatedAt: now 和 ageKnown: false 的状态,确保删掉对应检查时测试变红。其余部分(24h 过期、strict/session、ageMs 为 null、effort 处理)都查过,没有发现新问题。
…viderContinuation and keepsEffort
|
Added the two direct unit tests in
Hand-broken check results at
|
| change by hand at 1c67fcd | result |
|---|---|
withinCacheTtl = ageMs <= ttlMs (no ageKnown, in evaluateProviderContinuation) |
7.8 red (46 pass, 1 fail) |
drop sessionState.ageKnown !== false && in keepsEffort |
7.9 red (46 pass, 1 fail) |
| both of the above | 7.8, 7.9 red (45 pass, 2 fail) |
replayedState gets back updatedAt: env.now (keeping ageKnown: false) |
47 pass (the checks hold) |
updatedAt: env.now and no ageKnown check in evaluateProviderContinuation |
7.7, 7.8 red (45 pass, 2 fail) |
updatedAt: env.now and no ageKnown check in keepsEffort |
7.9 red (46 pass, 1 fail) |
updatedAt: env.now in place of ageKnown: false (3a7ba70's state) |
7.7 red (46 pass, 1 fail) |
Commands run (HOME set to an empty temporary directory, Bun 1.4.0)
bun test packages/agentcompass:47 pass, 0 fail(127ms)bun scripts/check.mjs agentcompass:✓ registry.json(exit 0)
中文总结
已在 packages/agentcompass/test/router.test.mjs(提交 1c67fcd)补上两个直接调用函数的单元测试(7.8 测 evaluateProviderContinuation,7.9 测 keepsEffort),未改动运行时代码,版本号保持 0.1.5。手动逐个去掉 evaluateProviderContinuation 中的 ageKnown 检查与 keepsEffort 中的 sessionState.ageKnown !== false && 检查时,7.8 和 7.9 分别独立变红;恢复后在空临时 HOME 下运行 bun test packages/agentcompass(47/47 通过)和 bun scripts/check.mjs agentcompass(✓ registry.json)均通过。
What
In Mode A, a tool-loop step or a short follow-up ("继续") can be routed from scratch instead of keeping the session's model and effort. It happens whenever the request lands on a different pooled runtime from the one that served the session.
Root cause
The session's route lives in a store inside the middleware's runtime, and nothing outside that runtime can hold it:
moejsruntime from async.Pooland gives middleware no storage across requests (internal/middleware/middleware.go,mw.pool).ctxcarries no headers (internal/middleware/run.go,acquire).serveAgentrunsonRequestbeforeserve(internal/gateway/middleware.go). Magpie's own affinity (internal/gateway/affinity.go,affine) therefore runs after the model is rewritten and only orders accounts within it.Change
turnChainreads from the resent history which turn a request continues.replayedStatecuts that turn's request out of the history (bodyUpTo) and decides it again, up to 4 turns back. The history is the one input every runtime gets, so they all reach the same answer. Nothing is recorded during a replay.upstream/claude-opus-5-5). Before, it became the canonical bare id, which can resolve to another provider (heldTarget).onRequest's claimed-model branch moves intorouteClaimedso that a replay runs the same code. The code moved as it was, including agentcompass 0.1.3: :minimal and :ultra are efforts; ultra reads as the model's Max, minimal sits below its Low #750's fallback retry, so a replayed fallback matches the original one.hooks.jsand Mode B are untouched.README
#765's README already describes the history replay, but the code wasn't on main until this PR. I added what it was missing, in both languages: why Magpie's affinity can't do this, and which clients are covered. Responses clients that send only
previous_response_idare covered, because Magpie fills in the conversation first. A client that sends only its last message, with no history, can't be held.Tests
src.Each break below turned a test red:
middleware.jsandcontinuity.jsreplayedStatecallheldTargetAt 3a7ba70 (Windows, Bun 1.4.0, Node 24.12, temp HOME):
bun test45 pass,node --test test/router.test.mjs45 pass,bun scripts/check.mjs agentcompassexit 0.E2E
This was measured in the plugin's own repo at akiiwa-games/magpie-agentcompass-router@d05f598, not at this head. It ran the real Magpie 0.1.2891 in a sandbox on default
GOMAXPROCS, with Scenario 8 over 20 sessions:The replay code is the same here. The one difference is the fallback retry from #750, which I checked only at the unit level: a lone
gpt-6-astra:minimalon a coding task routes to itself on turn 1 and on a follow-up on a fresh runtime. The sandbox run hasn't been repeated at this head.🤖 Generated with Claude Code