Skip to content

Commit 7eba8e3

Browse files
author
刘宇阳
committed
fix(agent-sdk): support Gemini multi-turn tool calling via thought_signature
Preserve thought_signature across streaming and history replay, normalize AIGW tool names, prevent unknown placeholder tool loops, and add AIGW trace headers for gemini-3.5-flash.
1 parent 7b96c8e commit 7eba8e3

15 files changed

Lines changed: 560 additions & 55 deletions

packages/agent-sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "wave-agent-sdk",
3-
"version": "0.0.20",
3+
"version": "0.0.21",
44
"description": "SDK for building AI-powered development tools and agents",
55
"keywords": [
66
"ai",

packages/agent-sdk/src/managers/aiManager.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {
55
} from "../services/aiService.js";
66
import { getMessagesToCompress } from "../utils/messageOperations.js";
77
import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
8+
import {
9+
extractToolCallMetadata,
10+
isValidToolCallName,
11+
normalizeToolCallName,
12+
} from "../utils/toolCallMetadata.js";
813
import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
914
import * as memory from "../services/memory.js";
1015
import * as fs from "node:fs/promises";
@@ -583,6 +588,19 @@ export class AIManager {
583588
}
584589
}
585590

591+
for (const toolCall of toolCalls) {
592+
const metadata = extractToolCallMetadata(
593+
toolCall as unknown as Record<string, unknown>,
594+
);
595+
if (metadata && toolCall.id) {
596+
this.messageManager.updateToolBlock({
597+
id: toolCall.id,
598+
parameters: toolCall.function.arguments || "{}",
599+
toolCallMetadata: metadata,
600+
});
601+
}
602+
}
603+
586604
if (result.finish_reason === "length" && toolCalls.length === 0) {
587605
this.messageManager.addErrorBlock(
588606
"AI response was truncated due to length limit. Please try to reduce the complexity of your request or split it into smaller parts.",
@@ -603,7 +621,23 @@ export class AIManager {
603621
return;
604622
}
605623

606-
const toolName = functionToolCall.function?.name || "";
624+
const toolName = normalizeToolCallName(
625+
functionToolCall.function?.name,
626+
);
627+
if (!isValidToolCallName(toolName)) {
628+
const skipMessage =
629+
"Skipped tool execution: model returned an empty or invalid tool name.";
630+
this.logger?.warn(skipMessage, { toolId });
631+
this.messageManager.updateToolBlock({
632+
id: toolId,
633+
parameters: functionToolCall.function?.arguments || "{}",
634+
result: skipMessage,
635+
success: false,
636+
error: skipMessage,
637+
stage: "end",
638+
});
639+
return;
640+
}
607641
// Safely parse tool parameters, handle tools without parameters
608642
let toolArgs: Record<string, unknown> = {};
609643
const argsString = functionToolCall.function?.arguments?.trim();
@@ -678,7 +712,7 @@ export class AIManager {
678712

679713
// Execute tool
680714
const toolResult = await this.toolManager.execute(
681-
functionToolCall.function?.name || "",
715+
toolName,
682716
toolArgs,
683717
context,
684718
);

packages/agent-sdk/src/managers/liveConfigManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type { PermissionManager } from "./permissionManager.js";
1919
import {
2020
getProjectConfigPaths,
2121
getUserConfigPaths,
22-
} from "@/utils/configPaths.js";
22+
} from "../utils/configPaths.js";
2323
import type { HookValidationResult } from "../types/hooks.js";
2424
import { isValidHookEvent, isValidHookEventConfig } from "../types/hooks.js";
2525
import { ConfigurationService } from "../services/configurationService.js";

packages/agent-sdk/src/managers/mcpManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { join } from "path";
33
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
55
import { ChatCompletionFunctionTool } from "openai/resources.js";
6-
import { createMcpToolPlugin, findToolServer } from "@/utils/mcpUtils.js";
6+
import { createMcpToolPlugin, findToolServer } from "../utils/mcpUtils.js";
77
import type { ToolPlugin, ToolResult, ToolContext } from "../tools/types.js";
88
import type {
99
Logger,

packages/agent-sdk/src/managers/messageManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export class MessageManager {
358358
images: params.images,
359359
compactParams: params.compactParams,
360360
parametersChunk: params.parametersChunk,
361+
toolCallMetadata: params.toolCallMetadata,
361362
});
362363
this.setMessages(newMessages);
363364
this.callbacks.onToolBlockUpdated?.(params);

packages/agent-sdk/src/services/aiService.ts

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {
1616
extendUsageWithCacheMetrics,
1717
type ClaudeUsage,
1818
} from "../utils/cacheControlUtils.js";
19+
import {
20+
extractToolCallMetadata,
21+
mergeToolCallMetadata,
22+
applyToolCallMetadataToResult,
23+
normalizeToolCallName,
24+
} from "../utils/toolCallMetadata.js";
1925

2026
import * as os from "os";
2127
import * as fs from "fs";
@@ -94,13 +100,7 @@ function isGitRepository(dirPath: string): string {
94100
type OpenAIModelConfig = Omit<
95101
ChatCompletionCreateParamsNonStreaming,
96102
"messages"
97-
> & {
98-
vertexai?: {
99-
thinking_config: {
100-
thinking_level: string;
101-
};
102-
};
103-
};
103+
>;
104104

105105
/**
106106
* Get specific configuration parameters based on model name
@@ -124,14 +124,6 @@ function getModelConfig(
124124
config.temperature = undefined;
125125
}
126126

127-
if (modelName.startsWith("gemini-3")) {
128-
config.vertexai = {
129-
thinking_config: {
130-
thinking_level: "minimal",
131-
},
132-
};
133-
}
134-
135127
return config;
136128
}
137129

@@ -337,9 +329,9 @@ Today's date: ${new Date().toISOString().split("T")[0]}
337329
const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens;
338330

339331
processedTools = tools;
340-
console.log("当前采用的模型为", currentModel);
332+
// console.log("当前采用的模型为", currentModel);
341333
if (isClaudeModel(currentModel)) {
342-
console.log("走到了claude的处理", currentModel);
334+
// console.log("走到了claude的处理", currentModel);
343335
openaiMessages = transformMessagesForClaudeCache(
344336
openaiMessages,
345337
currentModel,
@@ -423,7 +415,7 @@ Today's date: ${new Date().toISOString().split("T")[0]}
423415

424416
if (!response?.choices?.[0]?.message) {
425417
console.error("callAgent 返回为空-原始 responese", response);
426-
console.error("callAgent 返回为空-请求 messages", openaiMessages);
418+
// console.error("callAgent 返回为空-请求 messages", openaiMessages);
427419
const errorMessage =
428420
(response as unknown as { error?: { message?: string } })?.error
429421
?.message || "No response from AI";
@@ -659,6 +651,7 @@ async function processStreamingResponse(
659651
arguments: string;
660652
};
661653
}[] = [];
654+
const toolCallMetadataById = new Map<string, Record<string, unknown>>();
662655
const additionalDeltaFields: Record<string, unknown> = {};
663656
let usage: CallAgentResult["usage"] = undefined;
664657
let finishReason: CallAgentResult["finish_reason"] = null;
@@ -740,11 +733,19 @@ async function processStreamingResponse(
740733
const toolCallDelta =
741734
rawToolCall as ChatCompletionChunk.Choice.Delta.ToolCall;
742735

743-
if (!toolCallDelta.function) {
736+
const deltaMetadata = extractToolCallMetadata(
737+
rawToolCall as unknown as Record<string, unknown>,
738+
);
739+
740+
const rawRecord = rawToolCall as unknown as Record<string, unknown>;
741+
const topLevelName =
742+
typeof rawRecord.name === "string" ? rawRecord.name : undefined;
743+
744+
if (!toolCallDelta.function && !topLevelName) {
744745
continue;
745746
}
746747

747-
const functionDelta = toolCallDelta.function;
748+
const functionDelta = toolCallDelta.function ?? {};
748749

749750
let existingCall;
750751
let isNew = false;
@@ -756,7 +757,9 @@ async function processStreamingResponse(
756757
id: toolCallDelta.id,
757758
type: "function" as const,
758759
function: {
759-
name: functionDelta.name || "",
760+
name: normalizeToolCallName(
761+
functionDelta.name || topLevelName,
762+
),
760763
arguments: "",
761764
},
762765
};
@@ -771,32 +774,56 @@ async function processStreamingResponse(
771774
continue;
772775
}
773776

774-
if (functionDelta.name) {
775-
existingCall.function.name = functionDelta.name;
777+
if (deltaMetadata) {
778+
const callId = existingCall.id || toolCallDelta.id;
779+
if (callId) {
780+
toolCallMetadataById.set(
781+
callId,
782+
mergeToolCallMetadata(
783+
toolCallMetadataById.get(callId),
784+
deltaMetadata,
785+
)!,
786+
);
787+
}
776788
}
777789

778-
// Emit start stage when a new tool call is created and we have the tool name
779-
if (onToolUpdate && isNew && existingCall.function.name) {
790+
const previousName = existingCall.function.name;
791+
const resolvedName = normalizeToolCallName(
792+
functionDelta.name || topLevelName || previousName,
793+
);
794+
if (resolvedName) {
795+
existingCall.function.name = resolvedName;
796+
}
797+
798+
const nameJustResolved =
799+
!normalizeToolCallName(previousName) &&
800+
Boolean(existingCall.function.name);
801+
802+
// Emit start when a tool call first gets a resolvable name
803+
if (
804+
onToolUpdate &&
805+
existingCall.function.name &&
806+
(isNew || nameJustResolved)
807+
) {
780808
onToolUpdate({
781809
id: existingCall.id,
782810
name: existingCall.function.name,
783-
parameters: "", // Empty parameters for start stage
784-
parametersChunk: "", // Empty chunk for start stage
785-
stage: "start", // New tool call triggers start stage
811+
parameters: "",
812+
parametersChunk: "",
813+
stage: "start",
786814
});
787-
isNew = false; // Prevent duplicate start emissions
815+
isNew = false;
788816
}
789817

790818
if (functionDelta.arguments) {
791819
existingCall.function.arguments += functionDelta.arguments;
792820
}
793821

794-
// Emit streaming updates for all chunks with actual content (including first chunk)
795822
if (
796823
onToolUpdate &&
797824
existingCall.function.name &&
798825
functionDelta.arguments &&
799-
functionDelta.arguments.length > 0 // Only emit streaming for chunks with actual content
826+
functionDelta.arguments.length > 0
800827
) {
801828
onToolUpdate({
802829
id: existingCall.id,
@@ -828,7 +855,14 @@ async function processStreamingResponse(
828855
}
829856

830857
if (toolCalls.length > 0) {
831-
result.tool_calls = toolCalls;
858+
result.tool_calls = toolCalls
859+
.filter((toolCall) => normalizeToolCallName(toolCall.function.name))
860+
.map((toolCall) =>
861+
applyToolCallMetadataToResult(
862+
toolCall,
863+
toolCallMetadataById.get(toolCall.id),
864+
),
865+
);
832866
}
833867

834868
if (usage) {

packages/agent-sdk/src/tools/lsTool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as fs from "fs";
22
import * as path from "path";
33
import { minimatch } from "minimatch";
44
import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
5-
import { isBinary, getDisplayPath } from "@/utils/path.js";
5+
import { isBinary, getDisplayPath } from "../utils/path.js";
66
import {
77
LS_TOOL_NAME,
88
GLOB_TOOL_NAME,

packages/agent-sdk/src/types/messaging.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export interface ToolBlock {
6565
error?: string | Error;
6666
compactParams?: string; // Compact parameter display
6767
parametersChunk?: string; // Incremental parameter updates for streaming
68+
/** Extra fields from API tool_calls (e.g. thought_signature for Gemini 3) */
69+
toolCallMetadata?: Record<string, unknown>;
6870
}
6971

7072
export interface ImageBlock {

0 commit comments

Comments
 (0)