Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a7ada62
fix(ai): bound Anthropic thinking-replay repairs across a session
probepark Aug 7, 2026
d3c6b42
fix(tui): land deferred shell-mode output in the transcript
probepark Aug 7, 2026
fadca7a
fix(sdk): reap stale broker lock artifacts and make a silent exit deb…
probepark Aug 7, 2026
feefc46
fix(sdk): give chat daemon attached sessions the long-lived reconnect…
probepark Aug 7, 2026
56c717f
fix(sdk): start the memory backend after readiness, not inside it
probepark Aug 7, 2026
bf4bc99
fix(acp): skip unreplayable transcript entries instead of failing the…
probepark Aug 7, 2026
6d2a7b2
fix(sdk): reap session hosts idle under a healthy broker
probepark Aug 7, 2026
7cea3b0
fix(agent): dispatch a stale tool call name to the one tool it denotes
probepark Aug 7, 2026
738f65b
fix(acp): settle a prompt whose producer went silent
probepark Aug 7, 2026
102e58e
fix(sdk): read back a durable terminal_uncertain record as persisted
probepark Aug 7, 2026
bb67b8b
chore(sdk): regenerate the telegram generation manifest for attach
probepark Aug 7, 2026
9f7fc39
fix(test): stop exhausting the real ACP reconnect budget in a unit test
probepark Aug 7, 2026
ea49cd9
fix(sdk): bump chat daemon generations for the attach reconnect budget
probepark Aug 7, 2026
764b019
feat(gc): add an opt-in disk-retention pass
probepark Aug 7, 2026
9a21b2c
fix(sdk): resume an established chat attachment after its socket drops
probepark Aug 8, 2026
d55aabe
chore(sdk): regenerate the telegram manifest for the attach resume path
probepark Aug 8, 2026
cfe2b43
fix(tools): name the rejected key when todo_write refuses a call
probepark Aug 8, 2026
7994153
fix(acp): bound the prompt watchdog on evidence, not the worst-case tool
probepark Aug 8, 2026
f770bb9
fix(acp): settle a finished turn before its end-of-turn metadata
probepark Aug 8, 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
62 changes: 54 additions & 8 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
neutralizeReservedControlTokens,
stripUnusableReasoningItems,
} from "@gajae-code/ai/utils";
import { sanitizeText } from "@gajae-code/utils";
import { logger, sanitizeText } from "@gajae-code/utils";
import type { AttemptScope } from "./attempt-scope";
import {
createHarmonyAuditEvent,
Expand Down Expand Up @@ -2551,6 +2551,40 @@ function findToolCallNameAliases(
return aliases;
}

/**
* Active tool a call name dispatches to. Tools emitted via OpenAI's custom-tool
* path (e.g. `apply_patch` on GPT-5) come back under their wire-level name,
* which may differ from the harness-internal `name`. Match on either, preferring
* `name` for determinism if both somehow collide.
*/
function findActiveTool<T extends { name: string; customWireName?: string }>(
tools: ReadonlyArray<T> | undefined,
callName: string,
): T | undefined {
return (
tools?.find(tool => tool.name === callName) ??
tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
);
}

/**
* The single active tool an unresolvable call name denotes, when there is
* exactly one. Such a name is not a hallucination: proxied bridges rotate the
* per-session instance segment, so a name replayed from earlier context differs
* from the live registry only there. One candidate is a rename and is safe to
* dispatch; zero or several stay a not-found error, because routing the model at
* the wrong server's tool is worse than a dead end.
*/
function resolveSoleToolCallNameAlias<T extends { name: string; customWireName?: string }>(
callName: string,
tools: ReadonlyArray<T> | undefined,
): { tool: T; callName: string } | undefined {
const aliases = findToolCallNameAliases(callName, tools, 2);
if (aliases.length !== 1) return undefined;
const tool = findActiveTool(tools, aliases[0]);
return tool === undefined ? undefined : { tool, callName: aliases[0] };
}

/**
* Resolve how tool discovery is actually callable in this session. Assuming the
* bare `search_tool_bm25` literal both drops the hint when the discovery tool is
Expand Down Expand Up @@ -2598,6 +2632,24 @@ async function executeToolCalls(
} = config;
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
// A call name the registry does not expose can still denote exactly one
// active tool — bridges rotate their per-session instance segment, so names
// the model replayed from earlier context go stale in that segment alone.
// Rename the call to the tool it unambiguously means, before anything else
// reads the name, so argument validation, hooks, permissions and telemetry
// all run against the resolved tool as if it had been called correctly.
// Ambiguous and unmatched names fall through to the not-found error below.
for (const toolCall of toolCalls) {
if (findActiveTool(tools, toolCall.name) !== undefined) continue;
const resolved = resolveSoleToolCallNameAlias(toolCall.name, tools);
if (resolved === undefined) continue;
logger.info("Tool call renamed to its single active alias", {
toolCallId: toolCall.id,
requestedName: toolCall.name,
resolvedName: resolved.callName,
});
toolCall.name = resolved.callName;
}
const emittedToolResults: ToolResultMessage[] = [];
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
Expand All @@ -2615,13 +2667,7 @@ async function executeToolCalls(

const records = toolCalls.map(toolCall => ({
toolCall,
// Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
// come back under their wire-level name, which may differ from the
// harness-internal `name`. Match on either, preferring `name` for
// determinism if both somehow collide.
tool:
tools?.find(t => t.name === toolCall.name) ??
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name),
tool: findActiveTool(tools, toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
Expand Down
205 changes: 205 additions & 0 deletions packages/agent/test/agent-loop-tool-call-alias-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { agentLoop } from "@gajae-code/agent-core/agent-loop";
import type {
AgentContext,
AgentLoopConfig,
AgentMessage,
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
} from "@gajae-code/agent-core/types";
import type { Message } from "@gajae-code/ai";
import { createMockModel } from "@gajae-code/ai/providers/mock";
import { logger } from "@gajae-code/utils";
import * as z from "zod/v4";
import { createUserMessage } from "./helpers";

type QuerySchema = z.ZodObject<{ query: z.ZodString }>;
type QueryTool = AgentTool<QuerySchema, Record<string, never>>;

/** Stale name a model replays after the bridge minted a new instance segment. */
const STALE_SEARCH_CALL = "mcp__jzi2uzmxd57z__mr6er53iidr3_search";

function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
}

function makeQueryTool(
name: string,
options: { customWireName?: string; onExecute?: (args: { query: string }) => void } = {},
): QueryTool {
return {
name,
label: name,
description: `The ${name} tool`,
parameters: z.object({ query: z.string() }),
...(options.customWireName === undefined ? {} : { customWireName: options.customWireName }),
async execute(_toolCallId, args) {
options.onExecute?.(args);
return { content: [{ type: "text", text: `${name}:${args.query}` }], details: {} };
},
};
}

async function runToolCall(
tools: QueryTool[],
toolCall: { name: string; arguments: Record<string, unknown> },
beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => BeforeToolCallResult | undefined | Promise<BeforeToolCallResult | undefined>,
): Promise<Array<{ toolName: string; isError?: boolean; text: string }>> {
const context: AgentContext = { systemPrompt: [""], messages: [], tools };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tc-1", name: toolCall.name, arguments: toolCall.arguments }] },
{ content: ["recovered"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
...(beforeToolCall === undefined ? {} : { beforeToolCall }),
};
const results: Array<{ toolName: string; isError?: boolean; text: string }> = [];
const stream = agentLoop([createUserMessage("do the thing")], context, config, undefined, mock.stream);
for await (const event of stream) {
if (event.type === "tool_execution_end") {
const first = event.result.content?.[0];
results.push({
toolName: event.toolName,
isError: event.isError,
text: first?.type === "text" ? first.text : "",
});
}
}
return results;
}

describe("agentLoop: unresolvable tool call names with exactly one active match", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("executes the resolved tool instead of rejecting the call", async () => {
const executed: Array<{ query: string }> = [];
const results = await runToolCall(
[makeQueryTool("search", { onExecute: args => executed.push(args) }), makeQueryTool("read")],
{ name: STALE_SEARCH_CALL, arguments: { query: "alpha" } },
);

expect(executed).toEqual([{ query: "alpha" }]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(false);
expect(results[0].text).toBe("search:alpha");
expect(results[0].toolName).toBe("search");
});

// The reported failure named `todo_write`: the base name itself contains the
// separator, so only the bridge prefix may be stripped.
it("resolves a base name that contains underscores", async () => {
const executed: Array<{ query: string }> = [];
const results = await runToolCall([makeQueryTool("todo_write", { onExecute: args => executed.push(args) })], {
name: "mcp__jzi2uzmxd57z__mr6er53iidr3_todo_write",
arguments: { query: "init" },
});

expect(executed).toEqual([{ query: "init" }]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(false);
expect(results[0].text).toBe("todo_write:init");
expect(results[0].toolName).toBe("todo_write");
});

it("logs the redirect with the requested and resolved names", async () => {
const info = vi.spyOn(logger, "info").mockImplementation(() => {});

await runToolCall([makeQueryTool("search")], { name: STALE_SEARCH_CALL, arguments: { query: "beta" } });

const redirects = info.mock.calls.filter(call => call[0] === "Tool call renamed to its single active alias");
expect(redirects).toHaveLength(1);
expect(redirects[0][1]).toEqual({
toolCallId: "tc-1",
requestedName: STALE_SEARCH_CALL,
resolvedName: "search",
});
});

it("does not log a redirect for a call name that already resolves", async () => {
const info = vi.spyOn(logger, "info").mockImplementation(() => {});

const results = await runToolCall([makeQueryTool("search")], {
name: "search",
arguments: { query: "gamma" },
});

expect(results[0].text).toBe("search:gamma");
expect(info.mock.calls.filter(call => call[0] === "Tool call renamed to its single active alias")).toHaveLength(
0,
);
});

it("validates arguments against the resolved tool's schema", async () => {
const executed: Array<{ query: string }> = [];
const results = await runToolCall([makeQueryTool("search", { onExecute: args => executed.push(args) })], {
name: STALE_SEARCH_CALL,
arguments: { query: 42 },
});

expect(executed).toEqual([]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(true);
expect(results[0].text).toContain('Validation failed for tool "search"');
expect(results[0].text).not.toContain("not found");
});

it("runs beforeToolCall against the resolved tool and honours a block", async () => {
const executed: Array<{ query: string }> = [];
const seen: Array<{ name: string; args: unknown }> = [];
const results = await runToolCall(
[makeQueryTool("search", { onExecute: args => executed.push(args) })],
{ name: STALE_SEARCH_CALL, arguments: { query: "delta" } },
context => {
seen.push({ name: context.toolCall.name, args: context.args });
return { block: true, reason: "denied by policy" };
},
);

expect(seen).toEqual([{ name: "search", args: { query: "delta" } }]);
expect(executed).toEqual([]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(true);
expect(results[0].text).toContain("denied by policy");
});

it("dispatches a stale call name to a tool reachable only via customWireName", async () => {
const executed: Array<{ query: string }> = [];
const results = await runToolCall(
[makeQueryTool("internal_edit", { customWireName: "apply_patch", onExecute: args => executed.push(args) })],
{ name: "mcp__srv__stale_apply_patch", arguments: { query: "epsilon" } },
);

expect(executed).toEqual([{ query: "epsilon" }]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(false);
expect(results[0].text).toBe("internal_edit:epsilon");
});

// Two candidates are a guess, and guessing routes the model at the wrong
// server's tool — strictly worse than the dead end it replaces.
it("never guesses between two candidates", async () => {
const executed: string[] = [];
const results = await runToolCall(
[
makeQueryTool("mcp__srv__abc_search", { onExecute: () => executed.push("abc") }),
makeQueryTool("mcp__srv__xyz_search", { onExecute: () => executed.push("xyz") }),
],
{ name: "mcp__srv__stale_search", arguments: { query: "zeta" } },
);

expect(executed).toEqual([]);
expect(results).toHaveLength(1);
expect(results[0].isError).toBe(true);
expect(results[0].text).toContain("Tool mcp__srv__stale_search not found");
});
});
67 changes: 50 additions & 17 deletions packages/agent/test/agent-loop-tool-not-found-red-team.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,35 +137,68 @@ describe("agentLoop: tool-not-found discovery hint red team", () => {

// Issue #3917, captured sessions 019fd580/019fd583/019fd595: the model called
// `mcp__<server>__<instance>_search` while plain `search` was active, five
// times across three sessions, and the bare not-found named no way back.
it("names the active tool when the call carries an MCP bridge namespace", async () => {
const toolName = "mcp__jzi2uzmxd57z__wbg7pcrl46bd_search";
const toolResults = await collectToolResults([makeTool("search"), makeTool("read")], toolName);
// times across three sessions, and each bare not-found burned a whole turn.
// One active match is a rename, not a dead end, so the call is dispatched.
it("dispatches a call carrying a stale MCP bridge namespace to the one tool it denotes", async () => {
let searchRuns = 0;
let readRuns = 0;
const toolResults = await collectToolResults(
[makeTool("search", { onExecute: () => searchRuns++ }), makeTool("read", { onExecute: () => readRuns++ })],
"mcp__jzi2uzmxd57z__wbg7pcrl46bd_search",
);

expect(searchRuns).toBe(1);
expect(readRuns).toBe(0);
expect(toolResults).toHaveLength(1);
expectBaseNotFound(toolResults[0], toolName);
expect(toolResults[0].text).toContain("It is active as `search`");
expect(toolResults[0].text).not.toContain("`read`");
expect(toolResults[0].isError).toBe(false);
expect(toolResults[0].text).toBe("executed");
});

// Bridges mint the instance segment per session, so a name replayed from
// Bridges mint a fresh instance segment per session, so a name replayed from
// earlier context differs from the live registry only in that segment.
it("names the live alias when only the bridge instance segment went stale", async () => {
const toolName = "mcp__jzi2uzmxd57z__jgspauo3hmi5_subagent";
const toolResults = await collectToolResults([makeTool("mcp__jzi2uzmxd57z__gbbgnmhc3qkt_subagent")], toolName);
it("dispatches when only the bridge instance segment went stale", async () => {
let runs = 0;
const toolResults = await collectToolResults(
[makeTool("mcp__jzi2uzmxd57z__gbbgnmhc3qkt_subagent", { onExecute: () => runs++ })],
"mcp__jzi2uzmxd57z__jgspauo3hmi5_subagent",
);

expect(runs).toBe(1);
expect(toolResults).toHaveLength(1);
expectBaseNotFound(toolResults[0], toolName);
expect(toolResults[0].text).toContain("It is active as `mcp__jzi2uzmxd57z__gbbgnmhc3qkt_subagent`");
expect(toolResults[0].isError).toBe(false);
expect(toolResults[0].text).toBe("executed");
});

it("resolves an alias reachable only through customWireName", async () => {
const toolName = "mcp__srv__stale_apply_patch";
const toolResults = await collectToolResults([makeTool("edit", { customWireName: "apply_patch" })], toolName);
it("dispatches to an alias reachable only through customWireName", async () => {
let runs = 0;
const toolResults = await collectToolResults(
[makeTool("edit", { customWireName: "apply_patch", onExecute: () => runs++ })],
"mcp__srv__stale_apply_patch",
);

expect(runs).toBe(1);
expect(toolResults).toHaveLength(1);
expect(toolResults[0].isError).toBe(false);
expect(toolResults[0].text).toBe("executed");
});

// Two candidates stay a not-found error naming both: picking one would route
// the model at a tool it did not ask for.
it("keeps the not-found error and lists both names when two tools match", async () => {
let runs = 0;
const toolName = "mcp__srv__stale_search";
const toolResults = await collectToolResults(
[
makeTool("mcp__srv__abc_search", { onExecute: () => runs++ }),
makeTool("mcp__srv__xyz_search", { onExecute: () => runs++ }),
],
toolName,
);

expect(runs).toBe(0);
expect(toolResults).toHaveLength(1);
expectBaseNotFound(toolResults[0], toolName);
expect(toolResults[0].text).toContain("It is active as `apply_patch`");
expect(toolResults[0].text).toContain("It is active as `mcp__srv__abc_search` or `mcp__srv__xyz_search`");
});

it("does not invent an alias when no active tool shares the base name", async () => {
Expand Down
Loading
Loading