Skip to content
Merged
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
62 changes: 7 additions & 55 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ import {
usageToPi,
} from "./agent-messages.js";
import { buildSessionContext } from "./session-context.js";
import {
dedupeToolCallMessages,
reportDuplicateToolCallDrop,
} from "./tool-call-dedupe.js";
import {
apiBindingForProviderModel,
buildProviderModel,
Expand Down Expand Up @@ -602,8 +606,6 @@ const AGENT_CORE_TOOL_NAMES = new Set([
const MAX_ON_DEMAND_TOOL_PROMPT_ENTRIES = 64;
const MAX_TOOL_SEARCH_RESULT_NAMES = 24;

/** Ids named in the one log line a duplicate-collision rebuild writes. */
const MAX_LOGGED_DUPLICATE_TOOL_CALLS = 8;

/** Tools that ask the host to switch this session into a contract mode (D198). */
const ENTER_TOOL_NAMES: Record<ProposalKind, string> = {
Expand Down Expand Up @@ -2715,59 +2717,9 @@ Delegation rules:
* sentence.
*/
private dropDuplicateToolCalls(messages: AgentMessage[]): AgentMessage[] {
const claimedCalls = new Set<string>();
const claimedResults = new Set<string>();
const droppedIds = new Set<string>();
let droppedCount = 0;
const note = (id: string) => {
droppedCount += 1;
if (droppedIds.size < MAX_LOGGED_DUPLICATE_TOOL_CALLS) droppedIds.add(id);
};
const next: AgentMessage[] = [];
for (const message of messages) {
if (message.role === "assistant") {
const content = (message as AssistantMessage).content;
if (!Array.isArray(content)) {
next.push(message);
continue;
}
const kept = content.filter((block) => {
if (!isRecord(block) || block.type !== "toolCall") return true;
const id = typeof block.id === "string" ? block.id : undefined;
if (!id) return true;
if (claimedCalls.has(id)) {
note(id);
return false;
}
claimedCalls.add(id);
return true;
});
next.push(
kept.length === content.length
? message
: { ...(message as AssistantMessage), content: kept },
);
continue;
}
if (message.role === "toolResult") {
const id = (message as { toolCallId?: unknown }).toolCallId;
if (typeof id === "string" && id) {
if (claimedResults.has(id)) {
note(id);
continue;
}
claimedResults.add(id);
}
}
next.push(message);
}
if (droppedCount === 0) return messages;
process.stderr.write(
`[agent-runtime] dropped ${droppedCount} duplicate tool call ${
droppedCount === 1 ? "entry" : "entries"
} before the request (session=${this.sessionId} ids=${[...droppedIds].join(",")})\n`,
);
return next;
const drop = dedupeToolCallMessages(messages);
reportDuplicateToolCallDrop(this.sessionId, drop);
return drop.messages;
}

private entriesWithCompaction(
Expand Down
54 changes: 54 additions & 0 deletions packages/agent-runtime/src/subagent.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentEventEnvelope, SubagentDefinition } from "@pi-desktop/shared";
import type { Message } from "@earendil-works/pi-ai";
import {
composeSubagentSystemPrompt,
MAX_SUBAGENT_REPORT_CHARS,
Expand Down Expand Up @@ -136,6 +137,59 @@ describe("SubagentRun event forwarding", () => {
"models.stream(omitThinkingModel, context, retryOptions)",
);
});
it("deduplicates repeated tool calls before a subagent request", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const { run } = createRun({
initialMessages: [
assistantMessage({
content: [
{ type: "toolCall", id: "child-read", name: "Read", arguments: {} },
],
}),
{
role: "toolResult",
toolCallId: "child-read",
toolName: "Read",
content: [{ type: "text", text: "first" }],
isError: false,
timestamp: 2,
},
assistantMessage({
content: [
{ type: "toolCall", id: "child-read", name: "Read", arguments: {} },
],
}),
{
role: "toolResult",
toolCallId: "child-read",
toolName: "Read",
content: [{ type: "text", text: "retry" }],
isError: false,
timestamp: 3,
},
] as unknown as NonNullable<SubagentRunOptions["initialMessages"]>,
});
const outgoing = run.agent.convertToLlm(run.agent.state.messages) as Message[];
const toolCalls = outgoing.flatMap((message) =>
message.role === "assistant"
? message.content.filter((block) => block.type === "toolCall")
: [],
);

expect(toolCalls.map((call) => call.id)).toEqual(["child-read"]);
expect(outgoing.filter((message) => message.role === "toolResult")).toHaveLength(1);
expect(
outgoing.filter(
(message) => message.role === "assistant" && message.content.length === 0,
),
).toHaveLength(0);
expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join(""))
.toContain("session=session-1");
} finally {
stderr.mockRestore();
}
});

it("does not synthesize a Responses reasoning setting when omitted", async () => {
const responseProvider: RuntimeProviderConfig = {
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-runtime/src/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import {
prepareDelegateTurnContext,
subagentContextOverflowError,
} from "./subagent-context.js";
import {
dedupeToolCallMessages,
reportDuplicateToolCallDrop,
} from "./tool-call-dedupe.js";
import {
classifyProviderError,
delayWithAbort,
Expand Down Expand Up @@ -242,7 +246,8 @@ export class SubagentRun {
this.agent = new Agent({
streamFn: binding.streamFn,
getApiKey: binding.getApiKey,
convertToLlm,
convertToLlm: (messages) =>
convertToLlm(this.dedupeToolCalls(messages)),
// The same turn-boundary context protection the session has (ADR 0299):
// re-estimate at each boundary, compact before the next request, degrade
// before failing. The budget derives from this run's resolved model.
Expand Down Expand Up @@ -316,6 +321,11 @@ export class SubagentRun {
private modelBinding() {
return this.bindingFor(this.provider, this.thinkingLevel);
}
private dedupeToolCalls(messages: AgentMessage[]): AgentMessage[] {
const drop = dedupeToolCallMessages(messages);
reportDuplicateToolCallDrop(this.opts.sessionId, drop);
return drop.messages;
}

private bindingFor(
provider: RuntimeProviderConfig,
Expand Down
92 changes: 92 additions & 0 deletions packages/agent-runtime/src/tool-call-dedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";

type RecordLike = Record<string, unknown>;

const MAX_LOGGED_DUPLICATE_TOOL_CALLS = 8;

export type DuplicateToolCallDrop = {
messages: AgentMessage[];
droppedCount: number;
droppedIds: string[];
};

function isRecord(value: unknown): value is RecordLike {
return typeof value === "object" && value !== null;
}

/**
* Keep the first call and result for every tool-call id in a provider request.
*
* The transcript can contain repeated snapshots of one call. Returning the
* original array when nothing changes preserves the common path's identity and
* avoids copying messages that are already valid.
*/
export function dedupeToolCallMessages(
messages: AgentMessage[],
): DuplicateToolCallDrop {
const claimedCalls = new Set<string>();
const claimedResults = new Set<string>();
const droppedIds = new Set<string>();
let droppedCount = 0;
const note = (id: string) => {
droppedCount += 1;
if (droppedIds.size < MAX_LOGGED_DUPLICATE_TOOL_CALLS) {
droppedIds.add(id);
}
};
const next: AgentMessage[] = [];

for (const message of messages) {
if (message.role === "assistant") {
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
next.push(message);
continue;
}
const kept = content.filter((block) => {
if (!isRecord(block) || block.type !== "toolCall") return true;
const id = typeof block.id === "string" ? block.id : undefined;
if (id === undefined) return true;
if (claimedCalls.has(id)) {
note(id);
return false;
}
claimedCalls.add(id);
return true;
});
if (kept.length === 0 && content.length > 0) continue;
next.push(kept.length === content.length ? message : ({ ...message, content: kept } as AgentMessage));
continue;
}

if (message.role === "toolResult") {
const id = (message as { toolCallId?: unknown }).toolCallId;
if (typeof id === "string") {
if (claimedResults.has(id)) {
note(id);
continue;
}
claimedResults.add(id);
}
}
next.push(message);
}

return {
messages: droppedCount === 0 ? messages : next,
droppedCount,
droppedIds: [...droppedIds],
};
}

export function reportDuplicateToolCallDrop(
sessionId: string,
drop: DuplicateToolCallDrop,
): void {
if (drop.droppedCount === 0) return;
process.stderr.write(
`[agent-runtime] dropped ${drop.droppedCount} duplicate tool call ${
drop.droppedCount === 1 ? "entry" : "entries"
} before the request (session=${sessionId} ids=${drop.droppedIds.join(",")})\n`,
);
}
Loading