Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1979183
fix(core): pre-validate bridged tool_call arguments against the targe…
yiliang114 Sep 28, 2026
9d6a39d
fix(core): exempt media-policy targets from the bridge argument pre-c…
yiliang114 Sep 28, 2026
2c91d42
fix(core): order the bridge argument pre-check behind truncation and …
yiliang114 Sep 28, 2026
191879b
style(core): apply prettier formatting flagged by the lint gate
yiliang114 Sep 28, 2026
4c7ebf9
fix(core): close R3 review gaps in bridge pre-check policy, retry acc…
yiliang114 Sep 29, 2026
bad821c
fix(core): clone the pre-check schema and log the bridge policy looku…
yiliang114 Sep 29, 2026
888727d
test(core): use index-signature access for the mutated schema property
yiliang114 Sep 29, 2026
55783c4
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
0fd2661
fix(core): report an in-flight subagent refresh rejection under its o…
yiliang114 Sep 29, 2026
4c101ec
fix(core): treat a target's direct and bridge retry channels as one f…
yiliang114 Sep 29, 2026
2059aea
fix(cli): let ACP's own L1 gate own a policy-denied bridge target
yiliang114 Sep 29, 2026
29d3080
fix(core): preserve target validation and contain bridge policy errors
yiliang114 Sep 29, 2026
6c9d701
test(cli): complete the pm stub in the ACP target-policy bridge tests
yiliang114 Sep 29, 2026
0ab0c14
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
a674e3a
fix(core): isolate deferred argument validation
yiliang114 Sep 30, 2026
bacca2b
fix(core): relax nested additionalProperties in the bridge argument p…
yiliang114 Sep 30, 2026
21c07c5
fix(core): preserve composition branches in the bridge argument relax…
yiliang114 Sep 30, 2026
f92adcf
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 1, 2026
df8c325
fix(core): close the additionalProperties relaxation at the $ref boun…
yiliang114 Oct 1, 2026
bd31eac
fix(core): preserve bridge policy denials and media defaults
yiliang114 Oct 1, 2026
28a787d
Merge remote-tracking branch 'origin/main' into fix/issue-12889-bridg…
yiliang114 Oct 1, 2026
38fb20a
refactor(core): label the target's own validation error instead of pr…
yiliang114 Oct 1, 2026
403da27
fix(core): let a Responses model fill tool_call arguments; declare an…
yiliang114 Oct 1, 2026
d7465d1
test(core): use non-empty arguments that still fail validation
yiliang114 Oct 1, 2026
8b1b247
fix(core): keep deferred tool recovery in the primary session
yiliang114 Oct 1, 2026
6eb25b2
Merge remote-tracking branch 'origin/main' into codex/token-governanc…
yiliang114 Oct 1, 2026
9af1dee
fix(core): Preserve eager tool demotion during bridge recovery
yiliang114 Oct 2, 2026
d179954
Merge commit 'de2612434a3800735dcabef48bc67efcbb0077b4' into codex/to…
yiliang114 Oct 2, 2026
20930cd
fix(core): guard bridged empty-arg recovery against cancellation and …
yiliang114 Oct 2, 2026
b4f63dd
fix(acp): Keep truncated bridged calls hidden
yiliang114 Oct 2, 2026
a69f8d4
revert(core): drop the empty-argument auto-reveal fallback from the b…
yiliang114 Oct 2, 2026
af4d01f
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
15aac03
test(acp): Verify deferred-tool validation diagnostics
yiliang114 Oct 2, 2026
8bbced9
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
7682e70
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
28af8e4
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
67ea7b6
test(core): preserve shared bridged validation retry budget
yiliang114 Oct 3, 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
Prev Previous commit
Next Next commit
fix(core): treat a target's direct and bridge retry channels as one f…
…amily

The channel marker keeps a bridged pre-check refusal from prefix-pruning the
same target's direct validation failure WITHIN a batch, but the batch-start
presence prune widened the presence set only for requests carrying an
INVALID_TOOL_PARAMS bridge refusal. Each batch therefore preserved just the
channel it contained and deleted the other, so a model alternating between
bridging a deferred target and calling it directly reset both counters every
turn: neither ever reached VALIDATION_RETRY_LOOP_THRESHOLD and RETRY LOOP
DETECTED was never injected. That is a regression against the merge base,
where both channels produced the same message under the same key and accrued
together.

Fix both halves, since widening presence alone is not safe:
- presence: a request naming X now preserves both `X` and
  `bridgeRetryToolName(X)`, so alternating channels across batches accrue
  toward one threshold.
- clearing: `clearRetryCountsForTool` now also deletes the bridge-marked
  keys. A resolved bridge renames the request to the target, so the widened
  presence set keeps its stale bridge count alive across the successful
  execution; without the clearing half that resurrects the premature
  RETRY LOOP DETECTED the marker's prune was relied on to drop.

Unrelated tools are unaffected: the widening is keyed on names present in the
current batch, and an EXECUTION_DENIED refusal still keeps the wrapper name,
so it cannot retain the denied target's counters.

Tests: add 'accrues alternating bridged and direct failures of one target
across separate batches' (six single-request batches; the directive fires
once, on the fifth) and 'clears a target's bridge-marked counter when a
bridged call to it succeeds'; extend the existing mixed-channel test to also
assert the directive on the bridged half, which its name already claimed.
Dropping the presence widening turns the first red; reverting
clearRetryCountsForTool to the bare prefix turns the second red.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmumpkv7rf3
  • Loading branch information
yiliang114 and qwencoder committed Sep 29, 2026
commit 4c101ec361dbb2174776f3ca1c9a67ed681b0eb6
179 changes: 179 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2064,13 +2064,192 @@ describe('CoreToolScheduler', () => {
const [bridgeRefusal, directFailure] = third;
expect(bridgeRefusal.status).toBe('error');
expect(directFailure.status).toBe('error');
// Both halves of the claim this test's name makes: each channel reaches
// the threshold on its own key by the third batch.
if (bridgeRefusal.status === 'error') {
expect(bridgeRefusal.response.error?.message).toContain(
'RETRY LOOP DETECTED',
);
}
if (directFailure.status === 'error') {
expect(directFailure.response.error?.message).toContain(
'RETRY LOOP DETECTED',
);
}
});

it('accrues alternating bridged and direct failures of one target across separate batches', async () => {
// The channel marker alone is not enough: the batch-start presence prune
// runs per batch, so if each batch preserves only the channel it contains,
// a model that alternates between bridging a deferred target and calling
// it directly deletes the OTHER channel's counter every turn. Neither
// counter then exceeds 1, RETRY LOOP DETECTED is never injected, and the
// mixed-channel loop runs indefinitely — the stagnation this PR exists to
// stop. Mutation check: dropping `bridgeRetryToolName(r.name)` from the
// presence set in _schedule turns this red (no batch ever fires).
const bridge = new MockTool({ name: ToolNames.TOOL_CALL });
const writeFile = new MockTool({
name: 'write_file',
shouldDefer: true,
params: {
type: 'object',
properties: {
file_path: { type: 'string' },
content: { type: 'string' },
},
required: ['file_path', 'content'],
additionalProperties: false,
},
});
const { scheduler, onAllToolCallsComplete } =
createSchedulerForLegacyToolTests({
toolsByName: new Map([
[bridge.name, bridge],
[writeFile.name, writeFile],
]),
deferredHiddenNames: new Set([writeFile.name]),
});

// One request per batch, so each batch's presence set holds exactly one
// channel plus whatever the widening adds for it.
const runSingleBatch = async (
callId: string,
request: { name: string; args: Record<string, unknown> },
) => {
onAllToolCallsComplete.mockClear();
await scheduler.schedule(
{
callId,
name: request.name,
args: request.args,
isClientInitiated: false,
prompt_id: 'prompt-bridge-alternating',
},
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
return onAllToolCallsComplete.mock.calls[0][0][0] as ToolCall;
};
const bridged = {
name: ToolNames.TOOL_CALL,
args: { name: 'write_file', arguments: {} },
};
const direct = { name: 'write_file', args: {} };

const messages: string[] = [];
for (const batchId of [1, 2, 3, 4, 5, 6]) {
const completed = await runSingleBatch(
`alternating-${batchId}`,
batchId % 2 === 1 ? bridged : direct,
);
expect(completed.status).toBe('error');
if (completed.status === 'error') {
expect(completed.response.errorType).toBe(
ToolErrorType.INVALID_TOOL_PARAMS,
);
messages.push(completed.response.error?.message ?? '');
}
}

// The bridged channel climbs 1, 2, 3 across the odd batches while the
// even batches restart the direct channel, so the directive fires exactly
// once — on the fifth batch — and never prematurely.
expect(messages).toHaveLength(6);
for (const early of messages.slice(0, 4)) {
expect(early).not.toContain('RETRY LOOP DETECTED');
}
expect(messages[4]).toContain('RETRY LOOP DETECTED');
expect(messages[5]).not.toContain('RETRY LOOP DETECTED');
});

it('clears a target’s bridge-marked counter when a bridged call to it succeeds', async () => {
// The clearing half of the presence widening. A resolved bridge renames
// the request to the TARGET, so the batch-start presence set now keeps
// both of that target's channels and the prune no longer drops the stale
// bridge-marked count. clearRetryCountsForTool must cover the marked
// channel too, or the surviving count of 2 plus two later refusals would
// fire RETRY LOOP DETECTED prematurely. Mutation check: reverting
// clearRetryCountsForTool to the bare `${toolName}:` prefix turns this red.
const execute = vi.fn().mockResolvedValue({
llmContent: [{ text: 'published' }],
returnDisplay: 'published',
});
const bridge = new MockTool({ name: ToolNames.TOOL_CALL });
// A neutral target: PATH_ARG_KEYS (file_path/path/...) are rewritten on
// request.args during execution, which is not what this case measures.
const publishNote = new MockTool({
name: 'publish_note',
shouldDefer: true,
execute,
params: {
type: 'object',
properties: {
note_id: { type: 'string' },
body: { type: 'string' },
},
required: ['note_id', 'body'],
additionalProperties: false,
},
});
const { scheduler, onAllToolCallsComplete } =
createSchedulerForLegacyToolTests({
toolsByName: new Map([
[bridge.name, bridge],
[publishNote.name, publishNote],
]),
deferredHiddenNames: new Set([publishNote.name]),
});

const runBridged = async (
callId: string,
args: Record<string, unknown>,
) => {
onAllToolCallsComplete.mockClear();
await scheduler.schedule(
{
callId,
name: ToolNames.TOOL_CALL,
args: { name: 'publish_note', arguments: args },
isClientInitiated: false,
prompt_id: 'prompt-bridge-clear',
},
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
return onAllToolCallsComplete.mock.calls[0][0][0] as ToolCall;
};

// Two bridged refusals take the marked channel to 2.
for (const batchId of [1, 2]) {
const completed = await runBridged(`clear-${batchId}`, {});
expect(completed.status).toBe('error');
if (completed.status === 'error') {
expect(completed.response.error?.message).not.toContain(
'RETRY LOOP DETECTED',
);
}
}

// A successful bridged execution of the SAME target clears both channels.
const succeeded = await runBridged('clear-success', {
note_id: 'n-1',
body: 'ok',
});
expect(succeeded.status).toBe('success');
expect(execute).toHaveBeenCalledTimes(1);

// Two more refusals restart at 1 instead of inheriting the stale count.
for (const batchId of [3, 4]) {
const completed = await runBridged(`clear-after-${batchId}`, {});
expect(completed.status).toBe('error');
if (completed.status === 'error') {
expect(completed.response.error?.message).not.toContain(
'RETRY LOOP DETECTED',
);
}
}
});

it('applies the retry-loop directive to repeated invalid tool_call envelopes', async () => {
const bridge = new MockTool({ name: ToolNames.TOOL_CALL });
const { scheduler, onAllToolCallsComplete } =
Expand Down
57 changes: 38 additions & 19 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2882,12 +2882,15 @@ export class CoreToolScheduler {
/**
* Removes all validation retry counters for the given tool. Keys are
* "<toolName>:<errorMessage>", so a plain `Map.delete(toolName)` would not
* match anything.
* match anything. The bridge-marked channel is cleared too: the two channels
* are one family for presence (see the prune in _schedule), so clearing must
* cover both or a successful execution of the target would leave its stale
* bridge-channel count behind to fire RETRY LOOP DETECTED prematurely.
*/
private clearRetryCountsForTool(toolName: string): void {
const prefix = `${toolName}:`;
const prefixes = [`${toolName}:`, `${bridgeRetryToolName(toolName)}:`];
for (const key of this.validationRetryCounts.keys()) {
if (key.startsWith(prefix)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
this.validationRetryCounts.delete(key);
}
}
Expand Down Expand Up @@ -2972,24 +2975,40 @@ export class CoreToolScheduler {
// present in the current batch. Keeping every tracked tool's counters
// whenever any current request matched caused stale counts for
// unrelated tools to survive and fire RETRY LOOP DETECTED prematurely
// the next time those tools were used. A refused bridge request keeps
// the wrapper name (`tool_call`), so the channel-marked name of the
// validated target must join the presence set alongside it — but only
// for INVALID_TOOL_PARAMS refusals, the one error type that accrues
// below: an EXECUTION_DENIED (policy) refusal records nothing, so it
// must not keep the denied target's stale counters alive either.
// the next time those tools were used.
//
// A target's direct and bridge-marked channels are ONE family for
// presence: a request naming X preserves both `X` and
// `bridgeRetryToolName(X)`. Widening presence only for the bridged
// channel let alternating channels ACROSS batches prune each other — a
// bridged batch kept just the marked key and a direct batch just the
// bare one, so neither counter ever reached
// VALIDATION_RETRY_LOOP_THRESHOLD and a mixed-channel loop rode on
// without the stop directive. clearRetryCountsForTool clears both
// channels, so this widening cannot resurrect the stale bridge count
// that a resolved-and-executed target leaves behind.
//
// A refused bridge request keeps the wrapper name (`tool_call`), so the
// channel-marked name of the validated target must join the presence set
// alongside it — but only for INVALID_TOOL_PARAMS refusals, the one error
// type that accrues below: an EXECUTION_DENIED (policy) refusal records
// nothing, so it must not keep the denied target's stale counters alive
// either.
if (this.validationRetryCounts.size > 0) {
const currentToolNames = new Set(
requestsToProcess.flatMap((r) =>
r.bridgeResolutionError?.type ===
ToolErrorType.INVALID_TOOL_PARAMS &&
r.bridgeResolutionError.targetName !== undefined
? [
r.name,
bridgeRetryToolName(r.bridgeResolutionError.targetName),
]
: [r.name],
),
requestsToProcess.flatMap((r) => {
const names = [r.name, bridgeRetryToolName(r.name)];
if (
r.bridgeResolutionError?.type ===
ToolErrorType.INVALID_TOOL_PARAMS &&
r.bridgeResolutionError.targetName !== undefined
) {
names.push(
bridgeRetryToolName(r.bridgeResolutionError.targetName),
);
}
return names;
}),
);
for (const key of [...this.validationRetryCounts.keys()]) {
const sep = key.indexOf(':');
Expand Down