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
4 changes: 4 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added optional `terminate` support for tool results so deterministic tools can end the current turn without an automatic follow-up model call.

## [14.2.0] - 2026-04-23

### Changed
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ async function runLoop(
stream.push({ type: "turn_end", message, toolResults });

pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
if (hasMoreToolCalls && toolResults.length > 0 && toolResults.every(result => result.terminate === true)) {
if (pendingMessages.length === 0) {
hasMoreToolCalls = false;
}
}
}

// Agent would stop here. Check for follow-up messages.
Expand Down Expand Up @@ -528,6 +533,7 @@ async function executeToolCalls(
content: result.content,
details: result.details,
isError,
terminate: result.terminate,
timestamp: Date.now(),
};
record.result = result;
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
content: (TextContent | ImageContent)[];
// Details to be displayed in a UI or logged
details?: T;
// Whether this result should terminate the current agent turn without a follow-up model call
terminate?: boolean;
}

// Callback for streaming tool execution updates
Expand Down
182 changes: 182 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,188 @@ describe("Agent", () => {
]);
});

it("prompt() stops after a terminating tool result without a follow-up model call", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let streamCallCount = 0;

const alphaTool: AgentTool<typeof toolSchema> = {
name: "alpha",
label: "Alpha",
description: "Alpha tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `alpha:${params.value}` }], terminate: true };
},
};

const agent = new Agent({
initialState: { tools: [alphaTool] },
streamFn: () => {
const callIndex = streamCallCount;
streamCallCount += 1;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "alpha", arguments: { value: "hello" } }],
"toolUse",
);
stream.push({ type: "done", reason: "toolUse", message });
} else {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "follow-up" }]),
});
}
});
return stream;
},
});

await agent.prompt("run alpha");

expect(streamCallCount).toBe(1);
expect(agent.state.messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
const toolResult = agent.state.messages.find(message => message.role === "toolResult");
expect(toolResult?.terminate).toBe(true);
});

it("prompt() processes queued follow-up after a terminating tool result", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let streamCallCount = 0;
let agent: Agent;

const alphaTool: AgentTool<typeof toolSchema> = {
name: "alpha",
label: "Alpha",
description: "Alpha tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
agent.followUp({
role: "user",
content: [{ type: "text", text: "Queued follow-up" }],
timestamp: Date.now(),
});
return { content: [{ type: "text", text: `alpha:${params.value}` }], terminate: true };
},
};

agent = new Agent({
initialState: { tools: [alphaTool] },
streamFn: () => {
const callIndex = streamCallCount;
streamCallCount += 1;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "alpha", arguments: { value: "hello" } }],
"toolUse",
);
stream.push({ type: "done", reason: "toolUse", message });
} else {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "processed follow-up" }]),
});
}
});
return stream;
},
});

await agent.prompt("run alpha");

expect(streamCallCount).toBe(2);
expect(agent.hasQueuedMessages()).toBe(false);
expect(agent.state.messages.map(message => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"user",
"assistant",
]);
expect(agent.state.messages.at(-2)).toMatchObject({
role: "user",
content: [{ type: "text", text: "Queued follow-up" }],
});
expect(agent.state.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "processed follow-up" }],
});
});

it("prompt() follows up after a mixed terminating and non-terminating tool batch", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let streamCallCount = 0;

const alphaTool: AgentTool<typeof toolSchema> = {
name: "alpha",
label: "Alpha",
description: "Alpha tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `alpha:${params.value}` }], terminate: true };
},
};
const betaTool: AgentTool<typeof toolSchema> = {
name: "beta",
label: "Beta",
description: "Beta tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `beta:${params.value}` }] };
},
};

const agent = new Agent({
initialState: { tools: [alphaTool, betaTool] },
streamFn: () => {
const callIndex = streamCallCount;
streamCallCount += 1;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const message = createAssistantMessage(
[
{ type: "toolCall", id: "tool-1", name: "alpha", arguments: { value: "hello" } },
{ type: "toolCall", id: "tool-2", name: "beta", arguments: { value: "world" } },
],
"toolUse",
);
stream.push({ type: "done", reason: "toolUse", message });
} else {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "follow-up" }]),
});
}
});
return stream;
},
});

await agent.prompt("run tools");

expect(streamCallCount).toBe(2);
expect(agent.state.messages.map(message => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"toolResult",
"assistant",
]);
const toolResults = agent.state.messages.filter(message => message.role === "toolResult");
expect(toolResults.map(message => message.terminate)).toEqual([true, undefined]);
expect(agent.state.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "follow-up" }],
});
});

it("forwards sessionId and thinkingBudgets to streamFn options", async () => {
let receivedSessionId: string | undefined;
let receivedBudgets: ThinkingBudgets | undefined;
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added optional `terminate` metadata to tool-result messages for agent runtimes that support terminal tool results.

## [14.5.3] - 2026-04-27
### Added

Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ export interface ToolResultMessage<TDetails = any> {
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
isError: boolean;
/** Whether this tool result should terminate the current agent turn without a follow-up model call. */
terminate?: boolean;
/** Who initiated this message for billing/attribution semantics. */
attribution?: MessageAttribution;
/** Timestamp when output was pruned (ms since epoch). Undefined if unpruned. */
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Added

- Added the `defineTool()` extension SDK helper and termination metadata propagation through extension and hook `tool_result` handlers.
- Added internal URL support to the `search` tool, allowing `artifact://`-style paths that resolve to local files to be searched directly
- Added IRC relay observation in the main agent UI so every IRC exchange between agents is rendered in the main transcript, even when the main agent is not a direct participant

Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,10 @@ export class ExtensionRunner {
currentEvent.isError = handlerResult.isError;
modified = true;
}
if (handlerResult.terminate !== undefined) {
currentEvent.terminate = handlerResult.terminate;
modified = true;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
Expand All @@ -515,6 +519,7 @@ export class ExtensionRunner {
content: currentEvent.content,
details: currentEvent.details,
isError: currentEvent.isError,
terminate: currentEvent.terminate,
};
}

Expand Down
13 changes: 11 additions & 2 deletions packages/coding-agent/src/extensibility/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ import type {
BashToolInput,
FindToolDetails,
FindToolInput,
SearchToolDetails,
SearchToolInput,
ReadToolDetails,
ReadToolInput,
SearchToolDetails,
SearchToolInput,
WriteToolInput,
} from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
Expand Down Expand Up @@ -347,6 +347,13 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
) => Component;
}

/** Typed identity helper for defining extension tools. */
export function defineTool<TParams extends TSchema, TDetails = unknown>(
definition: ToolDefinition<TParams, TDetails>,
): ToolDefinition<TParams, TDetails> {
return definition;
}

// ============================================================================
// Resource Events
// ============================================================================
Expand Down Expand Up @@ -714,6 +721,7 @@ interface ToolResultEventBase {
input: Record<string, unknown>;
content: (TextContent | ImageContent)[];
isError: boolean;
terminate?: boolean;
}

export interface BashToolResultEvent extends ToolResultEventBase {
Expand Down Expand Up @@ -865,6 +873,7 @@ export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
terminate?: boolean;
}

export interface BeforeAgentStartEventResult {
Expand Down
14 changes: 11 additions & 3 deletions packages/coding-agent/src/extensibility/extensions/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { applyToolProxy } from "../tool-proxy";
import type { ExtensionRunner } from "./runner";
import type { RegisteredTool, ToolCallEventResult } from "./types";

type ToolResultWithTerminate<TDetails> = {
content: (TextContent | ImageContent)[];
details?: TDetails;
terminate?: boolean;
};

/**
* Adapts a RegisteredTool into an AgentTool.
*/
Expand Down Expand Up @@ -132,7 +138,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
}

// Execute the actual tool
let result: { content: any; details?: TDetails };
let result: ToolResultWithTerminate<TDetails>;
let executionError: Error | undefined;

try {
Expand All @@ -155,11 +161,13 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
content: result.content,
details: result.details,
isError: !!executionError,
terminate: result.terminate,
});

if (resultResult) {
const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content;
const modifiedDetails = (resultResult.details ?? result.details) as TDetails;
const modifiedTerminate = resultResult.terminate ?? result.terminate;

// Extension can override error status
if (resultResult.isError === true && !executionError) {
Expand All @@ -170,14 +178,14 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
}
if (resultResult.isError === false && executionError) {
// Extension clears the error - return success
return { content: modifiedContent, details: modifiedDetails };
return { content: modifiedContent, details: modifiedDetails, terminate: modifiedTerminate };
}

// Error status unchanged, but content/details may be modified
if (executionError) {
throw executionError;
}
return { content: modifiedContent, details: modifiedDetails };
return { content: modifiedContent, details: modifiedDetails, terminate: modifiedTerminate };
}
}

Expand Down
Loading