Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 10 additions & 17 deletions src/core-agent/src/agent/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,10 +445,11 @@ export const LOOP_HARD = 5;
* are identical except for volatile id/timestamp
* fields. Strictly above LOOP_HARD so the exact detector always acts first on
* byte-identical repeats; this tier catches the "same call, fresh
* request-id/uuid each time" spin that exact matching misses. A deliberately
* higher hard threshold bounds the run if the warning is ignored. */
* request-id/uuid each time" spin that exact matching misses. WARN-only by
* design: normalized matching is fuzzier than the exact tier, so a false
* positive must stay a benign one-time nudge, never a stop. An ignored nudge
* is bounded by the tool-round cap like any other unproductive work. */
export const NEAR_DUP_LOOP_WARN = 6;
export const NEAR_DUP_LOOP_HARD = 12;

/** Compaction skips a pass that would free less than this fraction of the context
* window — when the verbatim-kept tail dominates the window, summarising the
Expand Down Expand Up @@ -2186,9 +2187,10 @@ export class AgentRunner {
// modulo volatile id/timestamp fields. The exact streak above resets on
// any real arg change, so this is the only tier that catches a "same
// call, fresh request-id/uuid each time" spin. Threshold is above
// LOOP_HARD, so exact repeats are already stopped before this fires;
// the high hard threshold stops an ignored warning before this can
// consume the full 100+ round actor budget.
// LOOP_HARD, so exact repeats are already stopped before this fires.
// WARN-only: normalized matching is fuzzier than the exact tier, so a
// false positive must stay a benign nudge; an ignored nudge is bounded
// by the tool-round cap like any other unproductive work.
const nsig = normalizedToolCallSignature(call);
if (nsig === normSig) {
normRepeat += 1;
Expand All @@ -2204,21 +2206,12 @@ export class AgentRunner {
+ `(only volatile fields such as ids or timestamps differ). This is likely not making progress. `
+ `Change the target or your approach, or stop and report what you have so far.`;
}
if (normRepeat >= NEAR_DUP_LOOP_HARD) {
loopHardTripped = true;
break;
}
}
if (loopHardTripped) {
repetitiveToolCallsDetected = true;
const nearDuplicateHardStop = normRepeat >= NEAR_DUP_LOOP_HARD && loopRepeat < LOOP_HARD;
log.warn(nearDuplicateHardStop
? `loop_detection: effectively identical tool call repeated ${NEAR_DUP_LOOP_HARD}x — stopping run`
: `loop_detection: identical tool call repeated ${LOOP_HARD}x — stopping run`);
log.warn(`loop_detection: identical tool call repeated ${LOOP_HARD}x — stopping run`);
const final: AgentRunResult = {
text: turnText || (nearDuplicateHardStop
? "(Stopped: effectively the same tool call was repeated too many times without progress.)"
: "(Stopped: the same tool call was repeated too many times without progress.)"),
text: turnText || "(Stopped: the same tool call was repeated too many times without progress.)",
content: result.content,
meta: {
durationMs: Date.now() - startTime,
Expand Down
38 changes: 28 additions & 10 deletions src/core-agent/test/agent-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
CONTEXT_COMPACTION_TIMEOUT_MS,
LOOP_HARD,
NEAR_DUP_LOOP_WARN,
NEAR_DUP_LOOP_HARD,
RUN_DISCOVERY_NUDGE_ROUNDS,
RUN_DISCOVERY_STOP_ROUNDS,
RUN_NO_PROGRESS_NUDGE_ROUNDS,
Expand Down Expand Up @@ -4586,19 +4585,32 @@ describe("AgentRunner", () => {
expect(JSON.stringify(runner.getSession().getMessages())).not.toContain("effectively the same arguments");
});

it("loop_detection: hard-stops an ignored near-duplicate warning", async () => {
const toolRounds: CompletionResult[] = Array.from({ length: NEAR_DUP_LOOP_HARD }, (_, index) => ({
it("loop_detection: an ignored near-duplicate warning nudges once and never hard-stops", async () => {
// The normalized signature deliberately ignores volatile tracking fields,
// so this fuzzier tier must warn without terminating a potentially valid
// run. Exact repeats and the independent no-progress governor still stop.
const ROUNDS = NEAR_DUP_LOOP_WARN * 2;
const requests: CompletionParams[] = [];
const toolRounds: CompletionResult[] = Array.from({ length: ROUNDS }, (_, index) => ({
content: [{
type: "tool_use" as const,
id: `near-dup-hard-${index}`,
id: `near-dup-ignored-${index}`,
name: "web_fetch",
input: { url: "https://example.test/report", request_id: `request-${index}` },
}],
stopReason: "tool_use" as const,
usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 },
model: "mock-model",
}));
const provider = createMockProvider(toolRounds);
const provider = createMockProvider([
...toolRounds,
{
content: [{ type: "text", text: "Finished after the fetch spin." }],
stopReason: "end_turn",
usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 },
model: "mock-model",
},
], (params) => requests.push(params));
const registry = new ProviderRegistry();
registry.registerFactory("mock", () => provider);
let executions = 0;
Expand All @@ -4615,14 +4627,20 @@ describe("AgentRunner", () => {

const result = await runner.run({ message: "fetch the report without spinning" });

expect(result.text).toContain("Stopped");
expect(executions).toBe(NEAR_DUP_LOOP_HARD - 1);
expect(executions).toBe(ROUNDS);
expect(result.text).toBe("Finished after the fetch spin.");
expect(result.meta.toolLoops).toBe(ROUNDS);
const controls = requests.flatMap((request) => request.messages)
.flatMap((message) => message.content)
.filter((content) => content.type === "text" && content.text.includes("effectively the same arguments"));
expect(controls).toHaveLength(1);
expect(result.meta.convergenceSignals).toContain("repetitive_tool_calls");
});

it("loop_detection: legitimate pagination can cross the hard threshold without a false stop", async () => {
it("loop_detection: legitimate pagination stays distinct far past the warn threshold", async () => {
const PAGES = NEAR_DUP_LOOP_WARN * 2;
const requests: CompletionParams[] = [];
const toolRounds: CompletionResult[] = Array.from({ length: NEAR_DUP_LOOP_HARD }, (_, index) => ({
const toolRounds: CompletionResult[] = Array.from({ length: PAGES }, (_, index) => ({
content: [{
type: "tool_use" as const,
id: `page-${index}`,
Expand Down Expand Up @@ -4658,7 +4676,7 @@ describe("AgentRunner", () => {
const result = await runner.run({ message: "read every page" });

expect(result.text).toBe("All distinct pages were read.");
expect(result.meta.toolLoops).toBe(NEAR_DUP_LOOP_HARD);
expect(result.meta.toolLoops).toBe(PAGES);
expect(JSON.stringify(requests)).not.toContain("effectively the same arguments");
});

Expand Down
2 changes: 0 additions & 2 deletions src/core-agent/test/near-dup-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
normalizedToolCallSignature,
NEAR_DUP_LOOP_WARN,
NEAR_DUP_LOOP_HARD,
LOOP_HARD,
} from "../src/agent/runner.js";

Expand All @@ -11,7 +10,6 @@ const sig = (name: string, input: unknown) => normalizedToolCallSignature({ name
describe("normalizedToolCallSignature (near-duplicate loop detection)", () => {
it("fires only after the exact detector could (threshold above LOOP_HARD)", () => {
expect(NEAR_DUP_LOOP_WARN).toBeGreaterThan(LOOP_HARD);
expect(NEAR_DUP_LOOP_HARD).toBeGreaterThan(NEAR_DUP_LOOP_WARN);
});

describe("MATCH — collapses calls that differ only in volatile id/timestamp fields", () => {
Expand Down