Skip to content

Commit a5f8905

Browse files
committed
fix(google-common): pass structured objects directly in FunctionResponse
Previously, toolMessageToContent always stringified the ToolMessage content and then attempted to parse it back with JSON.parse, wrapping the result in { content: parsed }. This caused structured tool results to be double-serialized when sent to the Gemini API. The Gemini API's FunctionResponse.response field accepts any JSON object directly (Struct format), so this change: - Passes parsed JSON objects directly as the response (no wrapping) - Wraps non-object values (strings, arrays) in { result: value } - Handles ContentBlock[] arrays by extracting text content - Handles plain object content passed via type coercion Fixes #10439
1 parent 0eed020 commit a5f8905

File tree

2 files changed

+162
-42
lines changed

2 files changed

+162
-42
lines changed

libs/providers/langchain-google-common/src/tests/utils.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,3 +1092,92 @@ describe("gemini empty text content handling", () => {
10921092
expect(textPart.text).toBe("I am doing well");
10931093
});
10941094
});
1095+
1096+
describe("gemini tool message formatting", () => {
1097+
test("passes JSON string content as structured object in FunctionResponse", async () => {
1098+
const api = getGeminiAPI();
1099+
const messages = [
1100+
new HumanMessage("What is the weather?"),
1101+
new AIMessage({
1102+
content: "",
1103+
tool_calls: [
1104+
{ id: "call_1", name: "get_weather", args: { city: "SF" } },
1105+
],
1106+
}),
1107+
new ToolMessage({
1108+
tool_call_id: "call_1",
1109+
content: JSON.stringify({ temperature: 72, unit: "F" }),
1110+
}),
1111+
];
1112+
1113+
const formatted = (await api.formatData(messages, {})) as GeminiRequest;
1114+
const functionContent = formatted.contents?.find(
1115+
(c) => c.role === "function"
1116+
);
1117+
expect(functionContent).toBeDefined();
1118+
1119+
const part = functionContent?.parts[0] as any;
1120+
expect(part.functionResponse).toBeDefined();
1121+
expect(part.functionResponse.name).toBe("get_weather");
1122+
// Response should be a parsed object, not a string
1123+
expect(typeof part.functionResponse.response).toBe("object");
1124+
expect(part.functionResponse.response.temperature).toBe(72);
1125+
expect(part.functionResponse.response.unit).toBe("F");
1126+
});
1127+
1128+
test("wraps plain string content in result field", async () => {
1129+
const api = getGeminiAPI();
1130+
const messages = [
1131+
new HumanMessage("What is the weather?"),
1132+
new AIMessage({
1133+
content: "",
1134+
tool_calls: [
1135+
{ id: "call_1", name: "get_weather", args: { city: "SF" } },
1136+
],
1137+
}),
1138+
new ToolMessage({
1139+
tool_call_id: "call_1",
1140+
content: "It is sunny and 72°F",
1141+
}),
1142+
];
1143+
1144+
const formatted = (await api.formatData(messages, {})) as GeminiRequest;
1145+
const functionContent = formatted.contents?.find(
1146+
(c) => c.role === "function"
1147+
);
1148+
const part = functionContent?.parts[0] as any;
1149+
expect(part.functionResponse.response).toEqual({
1150+
result: "It is sunny and 72°F",
1151+
});
1152+
});
1153+
1154+
test("passes JSON array content wrapped in result field", async () => {
1155+
const api = getGeminiAPI();
1156+
const messages = [
1157+
new HumanMessage("Search for restaurants"),
1158+
new AIMessage({
1159+
content: "",
1160+
tool_calls: [
1161+
{ id: "call_1", name: "search", args: { query: "restaurants" } },
1162+
],
1163+
}),
1164+
new ToolMessage({
1165+
tool_call_id: "call_1",
1166+
content: JSON.stringify([
1167+
{ name: "Restaurant A" },
1168+
{ name: "Restaurant B" },
1169+
]),
1170+
}),
1171+
];
1172+
1173+
const formatted = (await api.formatData(messages, {})) as GeminiRequest;
1174+
const functionContent = formatted.contents?.find(
1175+
(c) => c.role === "function"
1176+
);
1177+
const part = functionContent?.parts[0] as any;
1178+
expect(part.functionResponse.response.result).toEqual([
1179+
{ name: "Restaurant A" },
1180+
{ name: "Restaurant B" },
1181+
]);
1182+
});
1183+
});

libs/providers/langchain-google-common/src/utils/gemini.ts

Lines changed: 73 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -880,54 +880,85 @@ export function getGeminiAPI(config?: GeminiAPIConfig): GoogleAIAPI {
880880
message: ToolMessage,
881881
prevMessage: BaseMessage
882882
): GeminiContent[] {
883-
const contentStr =
884-
typeof message.content === "string"
885-
? message.content
886-
: (message.content as ContentBlock[]).reduce(
887-
(acc: string, content: ContentBlock) => {
888-
if (content.type === "text") {
889-
return acc + content.text;
890-
} else {
891-
return acc;
892-
}
893-
},
894-
""
895-
);
896883
// Hacky :(
897884
const responseName =
898885
(isAIMessage(prevMessage) && !!prevMessage.tool_calls?.length
899886
? prevMessage.tool_calls[0].name
900887
: prevMessage.name) ?? message.tool_call_id;
901-
try {
902-
const content = JSON.parse(contentStr);
903-
return [
904-
{
905-
role: "function",
906-
parts: [
907-
{
908-
functionResponse: {
909-
name: responseName,
910-
response: { content },
911-
},
912-
},
913-
],
914-
},
915-
];
916-
} catch (_) {
917-
return [
918-
{
919-
role: "function",
920-
parts: [
921-
{
922-
functionResponse: {
923-
name: responseName,
924-
response: { content: contentStr },
925-
},
926-
},
927-
],
928-
},
929-
];
888+
889+
// Build the response object for Gemini's FunctionResponse.
890+
// The Gemini API accepts any JSON object as the response field,
891+
// so we pass structured content through as-is when possible
892+
// instead of serializing it to a string.
893+
let response: Record<string, unknown>;
894+
895+
if (typeof message.content === "string") {
896+
// String content: try to parse as JSON to recover structured data
897+
try {
898+
const parsed = JSON.parse(message.content);
899+
response =
900+
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
901+
? parsed
902+
: { result: parsed };
903+
} catch (_) {
904+
response = { result: message.content };
905+
}
906+
} else if (Array.isArray(message.content)) {
907+
// Content block array: check if it looks like ContentBlock[] or a
908+
// plain data array the caller passed via type coercion.
909+
const hasContentBlocks = message.content.some(
910+
(item: unknown) =>
911+
typeof item === "object" &&
912+
item !== null &&
913+
"type" in item &&
914+
typeof (item as Record<string, unknown>).type === "string"
915+
);
916+
917+
if (hasContentBlocks) {
918+
// Standard ContentBlock[] — extract text blocks
919+
const text = (message.content as ContentBlock[]).reduce(
920+
(acc: string, block: ContentBlock) =>
921+
block.type === "text" ? acc + block.text : acc,
922+
""
923+
);
924+
try {
925+
const parsed = JSON.parse(text);
926+
response =
927+
typeof parsed === "object" &&
928+
parsed !== null &&
929+
!Array.isArray(parsed)
930+
? parsed
931+
: { result: parsed };
932+
} catch (_) {
933+
response = { result: text };
934+
}
935+
} else {
936+
// Plain array passed as content (e.g. tool returning a list)
937+
response = { result: message.content };
938+
}
939+
} else if (
940+
typeof message.content === "object" &&
941+
message.content !== null
942+
) {
943+
// Already a structured object — pass through directly
944+
response = message.content as Record<string, unknown>;
945+
} else {
946+
response = { result: String(message.content) };
930947
}
948+
949+
return [
950+
{
951+
role: "function",
952+
parts: [
953+
{
954+
functionResponse: {
955+
name: responseName,
956+
response,
957+
},
958+
},
959+
],
960+
},
961+
];
931962
}
932963

933964
async function baseMessageToContent(

0 commit comments

Comments
 (0)