Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
c48b84e
fix(core): make loop detection result-aware for task_list polls (#9450)
yiliang114 Aug 19, 2026
91256db
fix(core): report result-aware loops to arena
yiliang114 Aug 19, 2026
83d2bbb
fix(core): complete loop attribution plumbing for #9450
yiliang114 Aug 19, 2026
b0db430
test(core): pin result-aware loop guards against silent regressions (…
yiliang114 Aug 20, 2026
2ea0eea
test(core): pin loop_type journal mapping and stateful callId guard (…
yiliang114 Aug 21, 2026
8b5b610
fix(core): keep result-aware loop guards alive for persisted oversize…
yiliang114 Aug 22, 2026
96eeb5c
refactor(core): keep loop-guard stub parsing in lockstep with the stu…
yiliang114 Aug 22, 2026
164a9a8
fix(core): fingerprint the full output in persistence stubs (#9450)
yiliang114 Aug 22, 2026
0b619f5
test(core): cover unwrapped and truncated oversized stub shapes (#9450)
yiliang114 Aug 22, 2026
e1e61d7
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 22, 2026
f4e85c7
fix(core): fingerprint the full output in truncated stubs (#9450)
yiliang114 Aug 22, 2026
167b0af
fix(core): fingerprint batch-budget fits for the loop guards (#9450)
yiliang114 Aug 22, 2026
77a4c88
fix(core): make stateful pair counts order-aware (#9450)
yiliang114 Aug 22, 2026
76621a1
fix(core): make the alternating-pattern guard result-aware (#9450)
yiliang114 Aug 22, 2026
b0b2558
Merge remote-tracking branch 'origin/main' into fix/9450-task-list-lo…
yiliang114 Aug 22, 2026
61dd8c5
fix(core): disarm the cap on thawed boards and anchor stub digests (#…
yiliang114 Aug 22, 2026
1ac2a81
fix(core): count deduped provider call ids once in the subagent loop …
yiliang114 Aug 22, 2026
e7085ec
fix(core): surface subagent loop attribution in the FINISH display (#…
yiliang114 Aug 22, 2026
dd2510d
fix(cli): make the daemon loop guard result-aware for task_list polls…
yiliang114 Aug 22, 2026
7cc2bff
fix(core): count deduped provider call ids once in the main-session l…
yiliang114 Aug 22, 2026
20ce795
fix(core): carry the inner stub digest through batch-budget fitting (…
yiliang114 Aug 23, 2026
edb1f30
fix(core): decay abandoned stateful streaks at round-trip boundaries …
yiliang114 Aug 23, 2026
dd309b3
fix(cli): decay abandoned stateful streaks at daemon batch boundaries…
yiliang114 Aug 23, 2026
00aa71b
fix(core): make the alternating-pattern carve-out in-flight aware (#9…
yiliang114 Aug 23, 2026
71630d8
fix(core): keep degenerate batch-budget fits content-dependent (#9450)
yiliang114 Aug 23, 2026
660cc46
fix(core): exclude never-executed synthetic results from the subagent…
yiliang114 Aug 23, 2026
0f8abb8
fix(core): exclude synthetic duplicate responses from the main-sessio…
yiliang114 Aug 23, 2026
a08fac3
fix(cli): skip daemon abandonment decay for batches that execute noth…
yiliang114 Aug 23, 2026
0ca1aa3
fix(core): canonicalize loop-guard fingerprints across the batch-budg…
yiliang114 Aug 23, 2026
da1d1c3
fix(core): unwind suppressed replays and skip decay for requested key…
yiliang114 Aug 23, 2026
395420a
fix(cli): skip daemon batch decay for re-requested stateful keys (#9450)
yiliang114 Aug 23, 2026
b7d8386
fix(core): keep sub-label batch-budget fits content-dependent (#9450)
yiliang114 Aug 23, 2026
e754027
test(core): align the replay-in-streak halt with the suppression unwi…
yiliang114 Aug 23, 2026
95f2447
Merge remote-tracking branch 'origin/main' into fix/9450-task-list-lo…
yiliang114 Aug 24, 2026
b1a8e66
fix(core): make the always-on consecutive guard in-flight-aware for p…
yiliang114 Aug 24, 2026
5746008
fix(core): reset the consecutive streak when its result evidence deca…
yiliang114 Aug 24, 2026
c4db73a
fix(cli): mark suppressed stateful replays for the daemon batch decay…
yiliang114 Aug 24, 2026
a91b6b2
fix(cli): exclude never-executed skipped-output synthetics from the l…
yiliang114 Aug 24, 2026
4201cd7
fix(core): reduce digest-carrying stub shapes the prefix list does no…
yiliang114 Aug 24, 2026
88edb22
fix(cli): unwind the loop-guard reservations for plan-mode sibling sk…
yiliang114 Aug 24, 2026
3ca2fdd
fix(cli): protect the suppressed replay key across the next daemon ba…
yiliang114 Aug 24, 2026
429881f
fix(core): keep the loop attribution on the final agent task card (#9…
yiliang114 Aug 24, 2026
f6199ae
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 24, 2026
342ea34
fix(core): return the full digest line from degenerate batch-budget f…
yiliang114 Aug 24, 2026
7f49db3
fix(core): carry stateful streak marks across replay-suppressed round…
yiliang114 Aug 24, 2026
372e4b7
fix(core): keep rejected stateful calls counting toward the consecuti…
yiliang114 Aug 24, 2026
27f12a2
fix(cli): unwind never-executed not_started synthetics before the int…
yiliang114 Aug 24, 2026
9ee8300
fix(core): clear stateful loop-guard trackers on model fallback (#9450)
yiliang114 Aug 25, 2026
23b6f8a
fix(core): keep suppressed request counts when decay keeps the streak…
yiliang114 Aug 25, 2026
c30eadb
fix(core): share one stub grammar between fitText and the loop guards…
yiliang114 Aug 25, 2026
d603092
test(core): realign the replay-in-streak halt pin with the kept suppr…
yiliang114 Aug 25, 2026
9b5089f
fix(cli): carry live stateful streak marks across non-stateful replay…
yiliang114 Aug 25, 2026
ccdb89d
Merge branch 'main' into fix/9450-task-list-loop-false-positive
wenshao Aug 26, 2026
dfadc01
refactor(core): trim PR back to the core result-aware loop-guard fix …
yiliang114 Aug 26, 2026
f34098e
fix(core): count consecutive results for stateful poll loop guards (#…
yiliang114 Aug 26, 2026
4bbf8ea
fix(core): restore the trimmed verification findings for stateful pol…
yiliang114 Aug 26, 2026
fc76bc2
Merge branch 'main' into fix/9450-task-list-loop-false-positive
yiliang114 Aug 28, 2026
2351563
Merge remote-tracking branch 'origin/main' into resolve-9492
yiliang114 Aug 28, 2026
1d515ff
Merge branch 'main' into fix/9450-task-list-loop-false-positive
yiliang114 Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type ToolCallRequestInfo,
} from '../../core/turn.js';
import { LoopDetectionService } from '../../services/loopDetectionService.js';
import type { LoopType } from '../../telemetry/types.js';
import {
CoreToolScheduler,
type ToolCall,
Expand Down Expand Up @@ -317,6 +318,12 @@ export interface ReasoningLoopResult {
terminateMode: AgentTerminateMode | null;
/** Number of model round-trips completed. */
turnsUsed: number;
/**
* Which loop detector fired, when terminateMode is LOOP_DETECTED (issue
* #9450 — attribution for stops that all render as one generic message
* otherwise). null otherwise.
*/
loopType?: LoopType | null;
}

/**
Expand Down Expand Up @@ -993,6 +1000,13 @@ export class AgentCore {
} as AgentRoundEvent);

const functionCalls: FunctionCall[] = [];
// callIds already streamed to the loop guard this attempt. Mirrors
// dedupeToolCallsById (which collapses execution to one call per
// id): a provider can emit the same call id twice in one response,
// and counting both emissions would leave the request counters one
// ahead of the executed result evidence (one recordToolResult per
// executed call), fail-safe-halting a productive stateful poller.
const loopGuardStreamedCallIds = new Set<string>();
let roundText = '';
let roundThoughtText = '';
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
Expand Down Expand Up @@ -1030,6 +1044,7 @@ export class AgentCore {
stickyMaxOutputTokens = streamEvent.maxOutputTokensEscalated;
}
functionCalls.length = 0;
loopGuardStreamedCallIds.clear();
roundText = '';
roundThoughtText = '';
lastUsage = undefined;
Expand Down Expand Up @@ -1111,6 +1126,17 @@ export class AgentCore {

for (const fc of chunkFunctionCalls) {
const toolName = String(fc.name);
// Provider-duplicate emissions of an already-streamed call id
// execute once (dedupeToolCallsById collapses them), so feed
// the loop guard once — request counts and result evidence
// must stay the same population. Id-less calls are never
// deduped, mirroring dedupeToolCallsById.
if (fc.id) {
if (loopGuardStreamedCallIds.has(fc.id)) {
continue;
}
loopGuardStreamedCallIds.add(fc.id);
}
if (
checkSubagentLoop({
type: LlmEventType.ToolCallRequest,
Expand Down Expand Up @@ -1200,6 +1226,24 @@ export class AgentCore {
terminateMode = AgentTerminateMode.LOOP_DETECTED;
break;
}
// Result-aware loop guards (issue #9450): stateful reads like
// task_list may legitimately repeat with identical arguments while
// the shared task board changes, so the detector must see each
// executed result before the next round re-emits the call.
for (const toolResult of toolCallResult.results) {
Comment thread
yiliang114 marked this conversation as resolved.
if (
loopDetector.recordToolResult(
Comment on lines +1233 to +1235

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R19-1: The result-aware exemption assumes "by the Nth identical request, the prior N−1 results have been recorded", but agent-core feeds results only after the whole batch executes (this loop runs after processFunctionCalls). Whenever the threshold-crossing request is not the first request of its round, its in-round predecessor's result does not exist yet at fire time (recordToolResult increments resultsObserved only for the current key, so an in-flight predecessor cannot count). Concretely: a teammate subagent batching 3 identical task_list({}) polls in round 1 and 2 in round 2 — 5 consecutive identical requests with the board changing between every poll — gets counts 1→3 in round 1 (3 changed results fed post-batch), then round 2's second request reaches count 5: expectedResults = 4 but resultsObserved = 3, the exemption is unreachable, and the fail-safe halts a productive poller with consecutive_identical_tool_calls — the exact #9450 false positive this PR removes. The 2+3 and 1+4 splits reach it too; only splits where the 5th request opens its round (4+1, 2+2+1) don't. Every service test interleaves a result after each request, an order production never presents for a round with ≥2 task_list calls, so the suite is green.

Witness — probe driving the real LoopDetectionService in agent-core's production order:

BASE: AssertionError — halt fires on 'round2-req2' despite 3 changed boards already recorded
controls on the same run: interleaved-order changed boards → no halt; frozen boards production order → halts; no-results fail-safe → halts
FIX arm (defer the stateful halt to the result feed): all 4 contract cases pass — probe flips

Fix: account for in-flight requests in the exemption — track outstanding requests per streak (requests streamed − results recorded) and evaluate resultsObserved >= toolCallRepetitionCount - 1 - outstanding — or defer the stateful-streak halt decision to the result feed / round boundary where all prior results have been fed. Verifier measurement: deferring the halt to recordToolResult preserved the #5019 no-evidence fail-safe in the probe, while folding outstanding into the exemption relaxed it — prefer the former.

Fix witness: add a production-order test — send 3 task_list requests, feed 3 changed results, send 2 more requests, assert the 5th checkAlwaysOnSafeties returns false; remove the fix and it must go red (please run that mutation to confirm).

中文说明

结果感知豁免依赖不变量"第 N 次相同请求时前 N−1 个结果已记录",但 agent-core 在整批执行完毕后才喂送结果(此循环位于 processFunctionCalls 之后)。当触发阈值的请求不是其所在轮的第一个请求时,同轮前序请求的结果在判定时必然缺失(recordToolResult 只对当前键累加 resultsObserved,在途前序无法计入)。具体场景:teammate 子 agent 第 1 轮批内轮询 3 次 task_list({})、第 2 轮批内轮询 2 次——共 5 次连续相同请求且任务板每次都在变化:第 1 轮计数 1→3(批后喂入 3 个已变化结果),第 2 轮第 2 次请求计数到 5:expectedResults = 4resultsObserved = 3,豁免不可达,失败安全分支以 consecutive_identical_tool_calls 终止生产性轮询者——正是本 PR 要消除的 #9450 误杀。2+3、1+4 分布同样可触发;仅第 5 次请求开轮的分布(4+1、2+2+1)不会。所有服务层测试在每个请求后交替喂结果——生产环境在一轮 ≥2 次 task_list 时从不呈现该顺序,因此套件全绿。

修复:让豁免计入在途请求(按连续段跟踪未决请求数,判定 resultsObserved >= toolCallRepetitionCount - 1 - outstanding),或将状态性连续段的终止决定推迟到结果喂入/轮边界。验证者测得:把终止推迟到 recordToolResult 保留了 #5019 无证据失败安全,而把 outstanding 折进豁免会放松它——建议采用前者。

修复见证:新增生产顺序测试——连发 3 次 task_list 请求、喂入 3 个已变化结果、再连发 2 次请求,断言第 5 次 checkAlwaysOnSafeties 返回 false;移除修复必须变红(请运行该突变确认)。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified REAL at head 2351563 (read-only code trace; no code executed or changed):

  • Request counting happens at stream time: every batched ToolCallRequest is fed to checkAlwaysOnSafeties (agent-core.ts:1142, deduped only by call id), incrementing toolCallRepetitionCount (loopDetectionService.ts:816).
  • Results are fed only after the whole batch executes: processFunctionCalls (agent-core.ts:1214) runs first, then the recordToolResult loop (agent-core.ts:1233-1235). Nothing is fed mid-round.
  • The exemption requires state.resultsObserved >= toolCallRepetitionCount - 1 (loopDetectionService.ts:846-847), and recordToolResult only increments resultsObserved for the current streak key (:465-466).

Trace: round 1 = 3 identical task_list({}) requests -> counts 1-3 (< TOOL_CALL_LOOP_THRESHOLD=5, :41), then 3 changed boards fed post-batch -> resultsObserved=3, unchangedStreak=0. Round 2: request 4 -> count 4; request 5 -> count 5 >= threshold: expectedResults=4 but resultsObserved=3 (round 2's batch has not executed yet), exemption unreachable -> halt with consecutive_identical_tool_calls despite every recorded board having changed. Any split where the 5th request does not open its round (3+2, 2+3, 1+4) hits this.

This PR is scope-fused and has been non-converging for 19 patrol rounds; recorded as needs-human-decision. No code changes this round. Leaving unresolved.

Comment thread
yiliang114 marked this conversation as resolved.
{ name: toolResult.toolName, args: toolResult.args },
toolResult.responseParts,
)
) {
Comment thread
yiliang114 marked this conversation as resolved.
terminateMode = AgentTerminateMode.LOOP_DETECTED;
break;
}
}
if (terminateMode === AgentTerminateMode.LOOP_DETECTED) {
break;
}
currentMessages = toolCallResult.messages;

const externalInputs = this.drainExternalInputs(options);
Expand Down Expand Up @@ -1317,6 +1361,9 @@ export class AgentCore {
text: finalText,
terminateMode,
turnsUsed: turnCounter,
...(terminateMode === AgentTerminateMode.LOOP_DETECTED
? { loopType: loopDetector.getLastLoopType() }
: {}),
};
}

Expand Down Expand Up @@ -1625,6 +1672,14 @@ export class AgentCore {
): Promise<{
messages: Content[];
repeatedDuplicateProviderToolCall: boolean;
/** Executed calls with their model-visible results, in call order.
* Consumed by the loop detector for result-aware stateful-read guards
* (issue #9450). */
results: Array<{
toolName: string;
args: Record<string, unknown>;
responseParts: Part[];
}>;
}> {
const responseByCallId = new Map<
string,
Expand Down Expand Up @@ -1678,6 +1733,7 @@ export class AgentCore {
return {
messages: [{ role: 'user', parts: [] }],
repeatedDuplicateProviderToolCall: true,
results: [],
};
}

Expand Down Expand Up @@ -2267,9 +2323,31 @@ export class AgentCore {
timestamp: Date.now(),
});

// Pair each executed call with its model-visible (finalized) result so
// the reasoning loop can feed the loop detector's result-aware guards.
const finalizedByCallId = new Map(
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
finalizedResponses.map((response) => [response.callId, response]),
);
const results: Array<{
toolName: string;
args: Record<string, unknown>;
responseParts: Part[];
}> = [];
for (const fc of uniqueFunctionCalls) {
const callId = callIdByFunctionCall.get(fc) ?? fc.id ?? '';
const finalized = finalizedByCallId.get(callId);
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
if (!finalized) continue;
Comment thread
yiliang114 marked this conversation as resolved.
results.push({
toolName: String(fc.name ?? ''),
args: (fc.args ?? {}) as Record<string, unknown>,
responseParts: finalized.responseParts,
});
Comment thread
yiliang114 marked this conversation as resolved.
Comment on lines +2340 to +2344

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-5: Still stands — re-proven at this head (also carries the round-7 R7-1 entrance). The guards fingerprint these post-finalization parts, and the batch-budget finalizer's fitText replacement carries no content digest: its header embeds a per-call unique artifact path and matches no STUB_PRODUCER_PREFIXES member, so the whole value fingerprints verbatim. Two directions, both probe-verified at this head: (1) missed halt — a FROZEN board whose round exceeds the default-on 200,000-char batch budget fingerprints uniquely on every poll, so unchangedStreak never grows and a genuinely stuck poller escapes every result-aware guard, running to the ~1000-call hard backstop; (2) false halt — two boards differing only beyond the retained head/tail produce byte-identical replacements, so a productive poller halts at the 5th identical request. The round-5/7 fixes (fitText digest + shared recognizer) were reverted by the trim. Witness (probe through the real enforceFunctionResponseBudget + real service at this head):

false-halt arm: boards differing only in the truncated middle ->
  byte-identical replacements (t1 === t2, no digest label), halt at request 5
  (full-board control never halts)
missed-halt arm: FROZEN board's fitted replacements all differ
  (per-call unique artifact path) -> guard never accumulates, never halts

Fix: embed the FULL_OUTPUT_DIGEST_LABEL digest of the full pre-fit content in fitText's replacement (consistent with this PR's own buildStub change) so the guard reduces the shape to the content digest regardless of the per-call artifact path. Fix witness: a frozen-board-under-fit test that halts within the threshold (red when fitText carries no digest), plus a board mutating only past the fit window that must NOT halt.

中文说明

[Critical] R5-5:仍然成立 —— 已在本 head 上重新证实(同时承载第 7 轮 R7-1 入口)。守卫对这些 finalize 后的部件做指纹,而批预算终结器的 fitText 替换不携带内容摘要:其头部内嵌每次调用唯一的产物路径,且不匹配任何 STUB_PRODUCER_PREFIXES 成员,于是整个值被逐字指纹化。两个方向均已在本 head 上用探针证实:(1) 漏检 —— 任务板冻结且某轮输出超过默认开启的 200,000 字符批预算时,每次轮询的指纹都因唯一路径而不同,unchangedStreak 永远不增长,真正卡死的轮询者逃逸所有结果感知守卫、一路跑到约 1000 次硬兜底;(2) 误杀 —— 仅在保留头/尾之外有差异的两块任务板产生字节完全相同的替换,正常轮询者在第 5 次相同请求被终止。第 5/7 轮的修复(fitText 摘要 + 共享识别器)已被瘦身回退。证据(经真实 enforceFunctionResponseBudget + 真实服务的探针):误杀臂 —— 仅被截中段有差异的任务板产生字节相同的替换(t1 === t2,无摘要行),第 5 次请求即终止(整板对照不终止);漏检臂 —— 冻结任务板的拟合替换因逐次调用唯一路径而全部不同,守卫永远无法累积、永不终止。

修复:在 fitText 的替换中嵌入拟合前完整内容的 FULL_OUTPUT_DIGEST_LABEL 摘要(与本 PR 自己对 buildStub 的改动一致),使守卫无论逐次路径如何都把该形态归约为内容摘要。修复见证:补一个批预算拟合下冻结任务板在阈值内终止的测试(fitText 不带摘要时须变红),以及一块仅在拟合窗口之外变化的任务板不得终止的测试。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Patrol note (automated sweep): this PR has hit the scope fuse — cumulative additions are +1853 (>1500-line cap) and this finding shape has recurred across five-plus review rounds without converging. Per patrol policy no more code is added from the sweep; this thread stays open for a maintainer decision on direction (the digest/fingerprint gaps are real, but each round mints new entrance variants faster than they close).

}

return {
messages: [{ role: 'user', parts: toolResponseParts }],
repeatedDuplicateProviderToolCall: false,
results,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/agents/runtime/agent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ export interface AgentExternalMessageEvent {
export interface AgentFinishEvent {
subagentId: string;
terminateReason: string;
/**
* Which loop detector fired when terminateReason is LOOP_DETECTED
* (issue #9450), so stops are attributable in journals/telemetry instead
* of collapsing into one generic label.
*/
loopType?: string;
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
timestamp: number;
rounds?: number;
totalDurationMs?: number;
Expand Down
Loading
Loading