feat(ai): added MCP server discovery properties and refactored tool call handling
- Added mcpServerName and mcpToolName optional properties to tool definitions for MCP server discovery and tool name tracking. - Changed aborted tool call handling to preserve existing tool results instead of replacing with synthetic 'aborted' results at turn boundaries. - Extracted flushPendingToolCalls() and flushPendingAbortedToolCalls() helpers to consolidate orphaned tool call handling logic. - Updated tool result handling to use message timestamps instead of Date.now() for synthetic results consistency.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Modified tool result handling for aborted assistant messages to preserve existing tool results when already recorded, instead of always replacing them with synthetic 'aborted' results
|
||||
|
||||
## [13.13.0] - 2026-03-18
|
||||
### Changed
|
||||
|
||||
@@ -124,101 +124,105 @@ export function transformMessages<TApi extends Api>(
|
||||
});
|
||||
|
||||
// Second pass: insert synthetic empty tool results for orphaned tool calls
|
||||
// This preserves thinking signatures and satisfies API requirements
|
||||
// and preserve aborted/errored tool results when they were already persisted.
|
||||
const result: Message[] = [];
|
||||
let pendingToolCalls: ToolCall[] = [];
|
||||
// Track tool call status: whether resolved (has result) or aborted (skip real results)
|
||||
let pendingAbortedToolCalls = new Map<string, ToolCall>();
|
||||
let pendingAbortedTimestamp: number | undefined;
|
||||
// Track tool call status: whether resolved (has result) or aborted (synthetic result injected, skip later real results)
|
||||
const toolCallStatus = new Map<string, ToolCallStatus>();
|
||||
|
||||
const flushPendingToolCalls = (timestamp: number): void => {
|
||||
if (pendingToolCalls.length === 0) return;
|
||||
for (const tc of pendingToolCalls) {
|
||||
if (!toolCallStatus.has(tc.id)) {
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "No result provided" }],
|
||||
isError: true,
|
||||
timestamp,
|
||||
} as ToolResultMessage);
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
|
||||
}
|
||||
}
|
||||
pendingToolCalls = [];
|
||||
};
|
||||
|
||||
const flushPendingAbortedToolCalls = (): void => {
|
||||
if (pendingAbortedTimestamp === undefined) return;
|
||||
for (const tc of pendingAbortedToolCalls.values()) {
|
||||
if (!toolCallStatus.has(tc.id)) {
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "aborted" }],
|
||||
isError: true,
|
||||
timestamp: pendingAbortedTimestamp,
|
||||
} as ToolResultMessage);
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
role: "developer",
|
||||
content: turnAbortedGuidance,
|
||||
timestamp: pendingAbortedTimestamp + 1,
|
||||
} as DeveloperMessage);
|
||||
pendingAbortedToolCalls = new Map();
|
||||
pendingAbortedTimestamp = undefined;
|
||||
};
|
||||
|
||||
for (let i = 0; i < transformed.length; i++) {
|
||||
const msg = transformed[i];
|
||||
const messageTimestamp = "timestamp" in msg && typeof msg.timestamp === "number" ? msg.timestamp : Date.now();
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
// If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
|
||||
if (pendingToolCalls.length > 0) {
|
||||
for (const tc of pendingToolCalls) {
|
||||
if (!toolCallStatus.has(tc.id)) {
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "No result provided" }],
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as ToolResultMessage);
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
|
||||
}
|
||||
}
|
||||
pendingToolCalls = [];
|
||||
}
|
||||
flushPendingToolCalls(messageTimestamp);
|
||||
flushPendingAbortedToolCalls();
|
||||
|
||||
// For errored/aborted assistant messages: keep tool calls intact,
|
||||
// inject synthetic "aborted" results, and add guidance marker.
|
||||
// This preserves structure so the model knows what was attempted.
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
|
||||
|
||||
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
|
||||
// Push the assistant message with tool calls intact
|
||||
// Keep the assistant message with tool calls intact. If real tool results follow, preserve them;
|
||||
// otherwise synthesize aborted results before the next turn boundary.
|
||||
result.push(msg);
|
||||
|
||||
// Inject synthetic "aborted" results for each tool call
|
||||
for (const tc of toolCalls) {
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "aborted" }],
|
||||
isError: true,
|
||||
timestamp: assistantMsg.timestamp,
|
||||
} as ToolResultMessage);
|
||||
}
|
||||
|
||||
// Inject turn-aborted guidance marker as developer message
|
||||
result.push({
|
||||
role: "developer",
|
||||
content: turnAbortedGuidance,
|
||||
timestamp: assistantMsg.timestamp + 1,
|
||||
} as DeveloperMessage);
|
||||
|
||||
pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const));
|
||||
pendingAbortedTimestamp = assistantMsg.timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track tool calls from this normal assistant message
|
||||
if (toolCalls.length > 0) {
|
||||
pendingToolCalls = toolCalls;
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
} else if (msg.role === "toolResult") {
|
||||
// Skip tool results for aborted tool calls (we already injected synthetic ones)
|
||||
if (pendingAbortedToolCalls.has(msg.toolCallId)) {
|
||||
pendingAbortedToolCalls.delete(msg.toolCallId);
|
||||
toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolCallStatus.get(msg.toolCallId) === ToolCallStatus.Aborted) continue;
|
||||
toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
|
||||
result.push(msg);
|
||||
} else if (msg.role === "user" || msg.role === "developer") {
|
||||
// User/developer message interrupts tool flow - insert synthetic results for orphaned calls
|
||||
if (pendingToolCalls.length > 0) {
|
||||
for (const tc of pendingToolCalls) {
|
||||
if (!toolCallStatus.has(tc.id)) {
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "No result provided" }],
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as ToolResultMessage);
|
||||
toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
|
||||
}
|
||||
}
|
||||
pendingToolCalls = [];
|
||||
}
|
||||
flushPendingToolCalls(messageTimestamp);
|
||||
flushPendingAbortedToolCalls();
|
||||
result.push(msg);
|
||||
} else {
|
||||
flushPendingToolCalls(messageTimestamp);
|
||||
flushPendingAbortedToolCalls();
|
||||
result.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingToolCalls(Date.now());
|
||||
flushPendingAbortedToolCalls();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ describe("Codex-style Abort Handling", () => {
|
||||
expect(toolResult.content).toEqual([{ type: "text", text: "aborted" }]);
|
||||
});
|
||||
|
||||
it("should skip existing tool results and use synthetic ones for aborted messages", () => {
|
||||
it("should preserve existing tool results for aborted messages when they were already recorded", () => {
|
||||
const toolCallId = "toolu_skip_existing";
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
@@ -407,7 +407,6 @@ describe("Codex-style Abort Handling", () => {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Existing result with different content (e.g., partial execution)
|
||||
const existingToolResult: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: toolCallId,
|
||||
@@ -425,14 +424,12 @@ describe("Codex-style Abort Handling", () => {
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
|
||||
// Should have exactly one tool result with "aborted" content
|
||||
const toolResults = transformed.filter(
|
||||
m => m.role === "toolResult" && (m as ToolResultMessage).toolCallId === toolCallId,
|
||||
) as ToolResultMessage[];
|
||||
|
||||
expect(toolResults.length).toBe(1);
|
||||
// The synthetic one should win, not the existing one
|
||||
expect(toolResults[0].content).toEqual([{ type: "text", text: "aborted" }]);
|
||||
expect(toolResults[0].isError).toBe(true);
|
||||
expect(toolResults[0].content).toEqual([{ type: "text", text: "Partial file content..." }]);
|
||||
expect(toolResults[0].isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `mcpServerName` and `mcpToolName` optional properties to custom tools for MCP server discovery and search metadata
|
||||
|
||||
## [13.13.1] - 2026-03-18
|
||||
### Added
|
||||
|
||||
@@ -184,6 +184,10 @@ export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
|
||||
hidden?: boolean;
|
||||
/** If true, tool may stage deferred changes that require explicit resolve/discard. */
|
||||
deferrable?: boolean;
|
||||
/** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
|
||||
mcpServerName?: string;
|
||||
/** Original MCP tool name for discovery/search metadata. */
|
||||
mcpToolName?: string;
|
||||
/**
|
||||
* Execute the tool.
|
||||
* @param toolCallId - Unique ID for this tool call
|
||||
|
||||
@@ -300,6 +300,10 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
||||
hidden?: boolean;
|
||||
/** If true, tool may stage deferred changes that require explicit resolve/discard. */
|
||||
deferrable?: boolean;
|
||||
/** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
|
||||
mcpServerName?: string;
|
||||
/** Original MCP tool name for discovery/search metadata. */
|
||||
mcpToolName?: string;
|
||||
/** Execute the tool. */
|
||||
execute(
|
||||
toolCallId: string,
|
||||
|
||||
@@ -422,6 +422,10 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
hidden: tool.hidden,
|
||||
deferrable: tool.deferrable,
|
||||
mcpServerName: tool.mcpServerName,
|
||||
mcpToolName: tool.mcpToolName,
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||
tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
|
||||
onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user