feat(agent,coding-agent): per-tool executionMode override for sequential tool execution (#3345)

* feat(agent,coding-agent): add per-tool executionMode field to AgentTool and ToolDefinition

Add optional executionMode?: ToolExecutionMode to AgentTool and
ToolDefinition interfaces. Propagate through wrapToolDefinition and
createToolDefinitionFromAgentTool. No behavioral change yet — agent
loop will read this field in a follow-up.

* feat(agent): support per-tool executionMode override for sequential execution

When a tool defines executionMode='sequential', the agent loop
forces sequential execution of all tool calls in that batch,
even if the global config is parallel.

* feat(coding-agent): re-export ToolExecutionMode from @mariozechner/pi-agent-core

Makes the type available to extensions that want to set
executionMode on tool definitions.

* feat(coding-agent): add tic-tac-toe extension example with executionMode: sequential

Demonstrates per-tool executionMode: the agent plays via move/play
tool calls that share a cursor. Without sequential execution, play
can resolve before earlier moves finish, landing on the wrong cell.
This commit is contained in:
Aliou Diallo
2026-04-18 00:45:57 +02:00
committed by GitHub
parent cbf0d1fbfa
commit bfa11a50e4
10 changed files with 1289 additions and 3 deletions

View File

@@ -635,6 +635,250 @@ describe("agentLoop with AgentMessage", () => {
// Interrupt message should be in context when second LLM call is made
expect(sawInterruptInContext).toBe(true);
});
it("should force sequential execution when a tool has executionMode=sequential even with default parallel config", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
const firstDone = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const slowTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "slow",
label: "Slow",
description: "Slow tool",
parameters: toolSchema,
executionMode: "sequential",
async execute(_toolCallId, params) {
if (params.value === "first") {
await firstDone;
firstResolved = true;
}
if (params.value === "second" && !firstResolved) {
parallelObserved = true;
}
return {
content: [{ type: "text", text: `slow: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [slowTool],
};
const userPrompt: AgentMessage = createUserMessage("run both");
// config is parallel (default), but tool forces sequential
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
let callIndex = 0;
const stream = agentLoop([userPrompt], context, config, undefined, () => {
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[
{ type: "toolCall", id: "tool-1", name: "slow", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "slow", arguments: { value: "second" } },
],
"toolUse",
);
mockStream.push({ type: "done", reason: "toolUse", message });
setTimeout(() => releaseFirst?.(), 20);
} else {
const message = createAssistantMessage([{ type: "text", text: "done" }]);
mockStream.push({ type: "done", reason: "stop", message });
}
callIndex++;
});
return mockStream;
});
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// With sequential execution, second tool should NOT start before first finishes
expect(parallelObserved).toBe(false);
const toolResultIds = events.flatMap((event) => {
if (event.type !== "message_end" || event.message.role !== "toolResult") {
return [];
}
return [event.message.toolCallId];
});
expect(toolResultIds).toEqual(["tool-1", "tool-2"]);
});
it("should force sequential execution when one of multiple tools has executionMode=sequential", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executionOrder: string[] = [];
let releaseSlow: (() => void) | undefined;
const slowDone = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const slowTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "slow",
label: "Slow",
description: "Slow tool",
parameters: toolSchema,
executionMode: "sequential",
async execute(_toolCallId, params) {
executionOrder.push(`slow:${params.value}`);
if (params.value === "a") {
await slowDone;
}
return {
content: [{ type: "text", text: `slow: ${params.value}` }],
details: { value: params.value },
};
},
};
const fastTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "fast",
label: "Fast",
description: "Fast tool",
parameters: toolSchema,
// no executionMode = defaults to parallel
async execute(_toolCallId, params) {
executionOrder.push(`fast:${params.value}`);
return {
content: [{ type: "text", text: `fast: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [slowTool, fastTool],
};
const userPrompt: AgentMessage = createUserMessage("run both");
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
// parallel by default, but slowTool forces sequential
};
let callIndex = 0;
const stream = agentLoop([userPrompt], context, config, undefined, () => {
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[
{ type: "toolCall", id: "tool-1", name: "slow", arguments: { value: "a" } },
{ type: "toolCall", id: "tool-2", name: "fast", arguments: { value: "b" } },
],
"toolUse",
);
mockStream.push({ type: "done", reason: "toolUse", message });
setTimeout(() => releaseSlow?.(), 20);
} else {
const message = createAssistantMessage([{ type: "text", text: "done" }]);
mockStream.push({ type: "done", reason: "stop", message });
}
callIndex++;
});
return mockStream;
});
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// Fast tool should NOT run before slow tool finishes
expect(executionOrder[0]).toBe("slow:a");
expect(executionOrder).toContain("fast:b");
});
it("should allow parallel execution when all tools have executionMode=parallel", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
const firstDone = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
executionMode: "parallel",
async execute(_toolCallId, params) {
if (params.value === "first") {
await firstDone;
firstResolved = true;
}
if (params.value === "second" && !firstResolved) {
parallelObserved = true;
}
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
const userPrompt: AgentMessage = createUserMessage("echo both");
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
let callIndex = 0;
const stream = agentLoop([userPrompt], context, config, undefined, () => {
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
"toolUse",
);
mockStream.push({ type: "done", reason: "toolUse", message });
setTimeout(() => releaseFirst?.(), 20);
} else {
const message = createAssistantMessage([{ type: "text", text: "done" }]);
mockStream.push({ type: "done", reason: "stop", message });
}
callIndex++;
});
return mockStream;
});
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// With executionMode=parallel, second tool should start before first finishes
expect(parallelObserved).toBe(true);
});
});
describe("agentLoopContinue with AgentMessage", () => {