Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
20 changes: 3 additions & 17 deletions packages/core/src/agent/agent-loop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import type { ChatCompletionClient } from "../model-client.js";
import { isUnlimitedMaxSteps } from "../max-steps.js";
import type {
Expand Down Expand Up @@ -52,6 +52,7 @@ import {
type AgentWorkspaceMode,
} from "./harness-context.js";
import { AgentStateMachine, type AgentStateSnapshot } from "./state-machine.js";
import { createToolCallFingerprint } from "./tool-fingerprint.js";

export interface AgentLoopOptions {
model: string;
Expand Down Expand Up @@ -1200,7 +1201,7 @@ function blockedRepeatedToolCallResult(
summary: `Suppressed repeated tool call '${toolName}' after ${limit} identical attempts`,
error: {
code: "REPEATED_TOOL_CALL",
message: `Attempt ${attempts} exceeded repeatedToolCallLimit=${limit}. Adjust arguments or produce a final response.`,
message: `Attempt ${attempts} exceeded repeatedToolCallLimit=${limit}. This call keeps being suppressed even when only numeric arguments change (e.g. a larger timeout). Diagnose why the identical call keeps failing, switch to a different approach, or produce a final response.`,
},
data: {
toolName,
Expand Down Expand Up @@ -1260,21 +1261,6 @@ function toRunMetadata(
};
}

function createToolCallFingerprint(toolName: string, rawArgs: string): string {
const normalizedArgs = normalizeToolArguments(rawArgs);
const hash = createHash("sha1").update(normalizedArgs).digest("hex");
return `${toolName}:${hash}`;
}

function normalizeToolArguments(rawArgs: string): string {
try {
const parsed = JSON.parse(rawArgs) as unknown;
return stableStringify(parsed);
} catch {
return rawArgs.replace(/\s+/g, " ").trim();
}
}

function stableStringify(value: unknown): string {
return JSON.stringify(sortRecursively(value));
}
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/agent/tool-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
createToolCallFingerprint,
normalizeToolArguments,
} from "./tool-fingerprint.js";

describe("createToolCallFingerprint", () => {
it("collapses runaway numeric escalation into the same fingerprint", () => {
const base = { command: "cat missing.txt", timeout_ms: 20_000_000_000 };
const second = {
command: "cat missing.txt",
timeout_ms: 2.4e37,
};
const third = { timeout_ms: 3.6e76, command: "cat missing.txt" };
expect(createToolCallFingerprint("exec", JSON.stringify(base))).toBe(
createToolCallFingerprint("exec", JSON.stringify(second)),
);
expect(createToolCallFingerprint("exec", JSON.stringify(base))).toBe(
createToolCallFingerprint("exec", JSON.stringify(third)),
);
});

it("keeps distinct legitimate calls distinct", () => {
const first = { path: "a.txt", offset: 600_000 };
const second = { path: "a.txt", offset: 900_000 };
expect(
createToolCallFingerprint("read_file", JSON.stringify(first)),
).not.toBe(createToolCallFingerprint("read_file", JSON.stringify(second)));
});

it("still distinguishes different commands with identical huge timeouts", () => {
const first = { command: "ls", timeout_ms: 2e10 };
const second = { command: "pwd", timeout_ms: 5e11 };
expect(createToolCallFingerprint("exec", JSON.stringify(first))).not.toBe(
createToolCallFingerprint("exec", JSON.stringify(second)),
);
});

it("includes the tool name in the fingerprint", () => {
const args = JSON.stringify({ path: "a.txt" });
expect(createToolCallFingerprint("read_file", args)).not.toBe(
createToolCallFingerprint("edit_file", args),
);
});
});

describe("normalizeToolArguments", () => {
it("replaces huge finite numbers with a placeholder, key order independent", () => {
expect(normalizeToolArguments('{"b":2.4e37,"a":1}')).toBe(
normalizeToolArguments('{"a":1,"b":9.9e50}'),
);
expect(normalizeToolArguments('{"timeout_ms":2e10}')).toContain(
"__HUGE_NUMBER__",
);
});

it("keeps small and boundary numbers intact", () => {
expect(normalizeToolArguments('{"timeout_ms":600000,"n":-1.5}')).toBe(
'{"n":-1.5,"timeout_ms":600000}',
);
expect(normalizeToolArguments('{"v":1000000000}')).toBe('{"v":1000000000}');
expect(normalizeToolArguments('{"v":1000000001}')).toContain(
"__HUGE_NUMBER__",
);
});

it("normalizes numbers nested in arrays and objects", () => {
const normalized = normalizeToolArguments(
'{"rows":[{"offset":1e12,"limit":10}]}',
);
expect(normalized).toContain("__HUGE_NUMBER__");
expect(normalized).toContain('"limit":10');
});

it("falls back to whitespace collapsing for non-JSON arguments", () => {
expect(normalizeToolArguments(" run tests ")).toBe("run tests");
});
});
68 changes: 68 additions & 0 deletions packages/core/src/agent/tool-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHash } from "node:crypto";

// Numeric arguments above this threshold collapse to a single placeholder in
// the fingerprint, so runaway escalation loops (e.g. timeout_ms 2e10 -> 2e37 ->
// 2e76 after each failure) count as repeated calls instead of fresh ones.
// Legitimate numeric arguments (timeouts, offsets, limits) stay far below it.
const HUGE_NUMBER_THRESHOLD = 1_000_000_000;
const HUGE_NUMBER_PLACEHOLDER = "__HUGE_NUMBER__";

export function createToolCallFingerprint(
toolName: string,
rawArgs: string,
): string {
const normalizedArgs = normalizeToolArguments(rawArgs);
const hash = createHash("sha1").update(normalizedArgs).digest("hex");
return `${toolName}:${hash}`;
}

export function normalizeToolArguments(rawArgs: string): string {
try {
const parsed = JSON.parse(rawArgs) as unknown;
return stableStringify(normalizeHugeNumbers(parsed));
} catch {
return rawArgs.replace(/\s+/g, " ").trim();
}
}

function normalizeHugeNumbers(value: unknown): unknown {
if (typeof value === "number") {
return Number.isFinite(value) && Math.abs(value) > HUGE_NUMBER_THRESHOLD
? HUGE_NUMBER_PLACEHOLDER
: value;
}
Comment on lines +29 to +33
if (Array.isArray(value)) {
return value.map((entry) => normalizeHugeNumbers(entry));
}
if (value && typeof value === "object") {
const normalized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
normalized[key] = normalizeHugeNumbers(child);
}
return normalized;
}
return value;
}

function stableStringify(value: unknown): string {
return JSON.stringify(sortRecursively(value));
}

function sortRecursively(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => sortRecursively(entry));
}

if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([left], [right]) => left.localeCompare(right),
);
const sorted: Record<string, unknown> = {};
for (const [key, child] of entries) {
sorted[key] = sortRecursively(child);
}
return sorted;
}

return value;
}
121 changes: 121 additions & 0 deletions packages/core/src/tools/code-mode/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import {
renderCellResult,
type CellStatus,
type NestedToolCall,
type RunningCell,
} from "./service.js";

function makeCall(overrides?: Partial<NestedToolCall>): NestedToolCall {
return {
toolName: "run_command",
identifier: "run_command",
ok: true,
summary: "Command completed",
...overrides,
};
}

function makeCell(overrides?: Partial<RunningCell>): RunningCell {
return {
id: "cell-test-1",
startedAt: Date.now(),
code: "const r = await tools.run_command({command: 'ls'});",
abortController: new AbortController(),
consoleLines: [],
status: "completed",
nestedCalls: [],
completion: Promise.resolve(),
...overrides,
};
}

function render(status: CellStatus, cell: RunningCell) {
return renderCellResult({ cell, status, commandOutputLimit: 8000 });
}

describe("renderCellResult structured feedback", () => {
it("reports all-successful completions without failure noise", () => {
const cell = makeCell({
nestedCalls: [makeCall(), makeCall({ toolName: "read_file" })],
});
const result = render("completed", cell);
expect(result.ok).toBe(true);
expect(result.summary).toBe("Script completed · 2 tool calls");
expect(result.data?.failedToolCalls).toBe(0);
expect(result.content).not.toContain("failed");
});

it("surfaces per-call failure reasons that were previously hidden", () => {
const cell = makeCell({
nestedCalls: [
makeCall(),
makeCall({
ok: false,
summary:
"Command failed with exit code 1: cat: missing.txt: No such file",
inputHint: "cat missing.txt",
}),
],
});
const result = render("completed", cell);
expect(result.ok).toBe(true);
expect(result.summary).toBe(
"Script completed · 2 tool calls, 1 failed tool call",
);
expect(result.data?.failedToolCalls).toBe(1);
expect(result.content).toContain(
"✗ run_command cat missing.txt — Command failed with exit code 1",
);
});

it("disambiguates no-return diagnostics when tool calls failed", () => {
const cell = makeCell({
nestedCalls: [
makeCall({ ok: false, summary: "Command failed with exit code 2" }),
],
});
const result = render("completed", cell);
expect(result.content).toContain("tool-level failures, not timeouts");
expect(result.content).toContain(
"Script completed without a returned result.",
);
});

it("keeps the plain no-return diagnostic when nothing failed", () => {
const cell = makeCell();
const result = render("completed", cell);
expect(result.content).toContain(
"Script completed without a returned result.",
);
expect(result.content).not.toContain("not timeouts");
});

it("shows grouped failure reasons when calls exceed the per-call limit", () => {
const calls: NestedToolCall[] = [];
for (let index = 0; index < 6; index += 1) {
calls.push(makeCall({ inputHint: `attempt-${index}` }));
}
for (let index = 0; index < 4; index += 1) {
calls.push(
makeCall({
ok: false,
summary: "Command failed with exit code 1",
inputHint: `failing-${index}`,
}),
);
}
const cell = makeCell({ nestedCalls: calls });
const result = render("completed", cell);
expect(result.content).toMatch(/6\/10 run_command ×10/);
expect(result.content).toContain("✗ Command failed with exit code 1");
});

it("keeps failed script status reporting intact", () => {
const cell = makeCell({ errorText: "TypeError: boom" });
const result = render("failed", cell);
expect(result.ok).toBe(false);
expect(result.summary).toBe("Script failed");
expect(result.content).toContain("TypeError: boom");
});
});
Loading
Loading