Skip to content

Commit 1c1b5a0

Browse files
committed
modularize parseToolCalls to be used by toolCallService
1 parent eea0805 commit 1c1b5a0

3 files changed

Lines changed: 143 additions & 148 deletions

File tree

packages/core/src/agents/executor.ts

Lines changed: 2 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
// SINGLE_INTERRUPT,
7474
// DOUBLE_INTERRUPT,
7575
} from '../common/abort-signal-manager.js';
76+
import { parseToolCalls } from '../utils/toolCallParser.js';
7677

7778
import { SummarizationService } from '../services/summarizer.js';
7879
import { ToolCallService } from '../services/toolCallService.js';
@@ -857,135 +858,6 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
857858
return { functionCalls, textResponse };
858859
}
859860

860-
// TODO: test this.
861-
/**
862-
* Parses a string containing Ollama tool calls into an array of FunctionCall objects.
863-
* @param text The string to parse.
864-
* @returns An array of FunctionCall objects.
865-
*/
866-
private _parseOllamaToolCalls(
867-
text: string,
868-
promptId: string,
869-
): FunctionCall[] {
870-
const strippedText = stripJsonMarkdown(text);
871-
// const strippedText = extractValidJson(text);
872-
debugLogger.log(
873-
`[Debug] Parsing Ollama tool calls from text: ${strippedText}`,
874-
);
875-
const functionCalls: FunctionCall[] = [];
876-
877-
try {
878-
const parsedJson = JSON.parse(strippedText);
879-
880-
const processJsonToolCall = (
881-
toolCall: unknown,
882-
index: number,
883-
): FunctionCall | null => {
884-
if (
885-
typeof toolCall === 'object' &&
886-
toolCall !== null &&
887-
'name' in toolCall
888-
) {
889-
const tc = toolCall as {
890-
name: string;
891-
parameters?: Record<string, unknown>;
892-
};
893-
if (typeof tc.name === 'string') {
894-
return {
895-
// id: `${promptId}-ollama-${index}`,
896-
name: tc.name,
897-
args: tc.parameters ?? {},
898-
};
899-
}
900-
}
901-
return null;
902-
};
903-
904-
if (Array.isArray(parsedJson)) {
905-
for (const [index, item] of parsedJson.entries()) {
906-
const functionCall = processJsonToolCall(item, index);
907-
if (functionCall) {
908-
functionCalls.push(functionCall);
909-
}
910-
}
911-
} else {
912-
const functionCall = processJsonToolCall(parsedJson, 0);
913-
if (functionCall) {
914-
functionCalls.push(functionCall);
915-
}
916-
}
917-
918-
if (functionCalls.length > 0) {
919-
// debugLogger.log(
920-
// `[Debug] Parsed Ollama tool calls from JSON: ${JSON.stringify(
921-
// functionCalls,
922-
// )}`,
923-
// );
924-
return functionCalls;
925-
}
926-
} catch (e) {
927-
// Not a valid JSON, proceed with regex parsing
928-
debugLogger.log(
929-
'[Debug] Failed to parse tool calls as JSON, falling back to regex.',
930-
);
931-
}
932-
933-
// This regex finds patterns like `function_name(anything_inside)`.
934-
const toolCallRegex = /(\w+)\((.*?)\)/g;
935-
let match;
936-
937-
// The model might return tool calls wrapped in [].
938-
const content =
939-
strippedText.trim().startsWith('[') && strippedText.trim().endsWith(']')
940-
? strippedText.trim().slice(1, -1)
941-
: strippedText.trim();
942-
943-
while ((match = toolCallRegex.exec(content)) !== null) {
944-
const name = match[1];
945-
const argsString = match[2];
946-
debugLogger.log(
947-
`[Debug] Found tool call: ${name} with args: ${argsString}`,
948-
);
949-
const args: { [key: string]: unknown } = {};
950-
951-
if (argsString) {
952-
// This regex handles key-value pairs, including quoted values that may contain commas.
953-
const argRegex = /(\w+)=(".*?"|'.*?'|[^,]+)/g;
954-
let argMatch;
955-
while ((argMatch = argRegex.exec(argsString)) !== null) {
956-
const key = argMatch[1];
957-
let value: unknown = argMatch[2].trim();
958-
959-
// Basic type inference
960-
if (typeof value === 'string') {
961-
if (
962-
(value.startsWith('"') && value.endsWith('"')) ||
963-
(value.startsWith(`'`) && value.endsWith(`'`))
964-
) {
965-
value = value.slice(1, -1);
966-
} else if (!isNaN(Number(value)) && value.trim() !== '') {
967-
value = Number(value);
968-
} else if (value === 'true') {
969-
value = true;
970-
} else if (value === 'false') {
971-
value = false;
972-
}
973-
}
974-
args[key] = value;
975-
}
976-
}
977-
functionCalls.push({
978-
id: `${promptId}-ollama-${functionCalls.length}`,
979-
name,
980-
args,
981-
});
982-
}
983-
// debugLogger.log(
984-
// `[Debug] Parsed Ollama tool calls: ${JSON.stringify(functionCalls)}`,
985-
// );
986-
return functionCalls;
987-
}
988-
989861
/**
990862
* Calls the Ollama model with the given message.
991863
*/
@@ -1047,7 +919,7 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
1047919
}
1048920
}
1049921

1050-
let functionCalls = this._parseOllamaToolCalls(textResponse, promptId);
922+
let functionCalls = parseToolCalls(textResponse, promptId);
1051923

1052924
// If there is no function call, it implies complete_task call.
1053925
if (functionCalls.length === 0) {

packages/core/src/services/toolCallService.ts

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,14 @@
55
*/
66

77
import type { FunctionCall, FunctionDeclaration, Schema } from '@google/genai';
8-
import { z } from 'zod';
98

109
import type { GeminiChat } from '../core/geminiChat.js';
1110
import { OllamaChat, StreamEventType } from '../core/ollamaChat.js';
1211
import { debugLogger } from '../utils/debugLogger.js';
13-
import { stripJsonMarkdown } from '../utils/json.js';
1412
import type { ModelConfig, OllamaModelConfig } from '../agents/types.js';
1513
import type { ToolRegistry } from '../tools/tool-registry.js';
1614
import * as fs from 'node:fs/promises';
17-
18-
const ToolCallResponseSchema = z.object({
19-
name: z.string(),
20-
parameters: z.record(z.unknown()),
21-
});
15+
import { parseToolCalls } from '../utils/toolCallParser.js';
2216

2317
/**
2418
* A service that takes a tool name and chat history and generates a complete
@@ -151,24 +145,23 @@ Respond with only the JSON for the tool call. Do not include any other text or m
151145
}
152146

153147
const responseText = textResponse;
154-
const jsonText = stripJsonMarkdown(responseText);
155-
const parsed = ToolCallResponseSchema.safeParse(JSON.parse(jsonText));
148+
const functionCalls = parseToolCalls(responseText, 'tool-call-service');
156149

157-
if (!parsed.success) {
158-
debugLogger.log('Failed to parse tool call response', parsed.error);
150+
if (functionCalls.length === 0) {
151+
debugLogger.log(
152+
'[ToolCallService] Failed to parse tool call from model response.',
153+
);
159154
throw new Error('Failed to generate tool call.');
160155
}
161156

157+
const parsedFunctionCall = functionCalls[0];
158+
162159
try {
163160
await this._writeChatHistoryToFile(
164161
systemPrompt,
165162
lastMessageText,
166163
textResponse,
167164
);
168-
await fs.writeFile('tool_call_chat_history.txt', textResponse);
169-
debugLogger.log(
170-
'[DEBUG] Summarized tool output saved to tool_call_chat_history.txt',
171-
);
172165
} catch (error) {
173166
debugLogger.error(
174167
'[DEBUG] Failed to save summarized tool output to tool_call_chat_history.txt:',
@@ -177,8 +170,8 @@ Respond with only the JSON for the tool call. Do not include any other text or m
177170
}
178171

179172
return {
180-
name: parsed.data.name,
181-
args: parsed.data.parameters,
173+
name: parsedFunctionCall.name,
174+
args: parsedFunctionCall.args,
182175
};
183176
}
184177

@@ -191,6 +184,6 @@ Respond with only the JSON for the tool call. Do not include any other text or m
191184
fileContent += `--- ROLE: system ---\n${systemPrompt}\n---\n\n`;
192185
fileContent += `--- ROLE: user ---\n${userMessage}\n---\n\n`;
193186
fileContent += `--- ROLE: model ---\n${modelResponse}\n---\n\n`;
194-
await fs.writeFile(`tool_call_service_chat_history.txt`, fileContent);
187+
await fs.writeFile(`tool_call_chat_history.txt`, fileContent);
195188
}
196189
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/* eslint-disable @typescript-eslint/no-unused-vars */
8+
9+
import type { FunctionCall } from '@google/genai';
10+
import { stripJsonMarkdown } from './json.js';
11+
import { debugLogger } from './debugLogger.js';
12+
13+
/**
14+
* Parses a string containing Ollama tool calls into an array of FunctionCall objects.
15+
* @param text The string to parse.
16+
* @returns An array of FunctionCall objects.
17+
*/
18+
export function parseToolCalls(text: string, promptId: string): FunctionCall[] {
19+
const strippedText = stripJsonMarkdown(text);
20+
debugLogger.log(
21+
`[Debug] Parsing Ollama tool calls from text: ${strippedText}`,
22+
);
23+
const functionCalls: FunctionCall[] = [];
24+
25+
try {
26+
const parsedJson = JSON.parse(strippedText);
27+
28+
const processJsonToolCall = (
29+
toolCall: unknown,
30+
index: number,
31+
): FunctionCall | null => {
32+
if (
33+
typeof toolCall === 'object' &&
34+
toolCall !== null &&
35+
'name' in toolCall
36+
) {
37+
const tc = toolCall as {
38+
name: string;
39+
parameters?: Record<string, unknown>;
40+
};
41+
if (typeof tc.name === 'string') {
42+
return {
43+
// id: `${promptId}-ollama-${index}`,
44+
name: tc.name,
45+
args: tc.parameters ?? {},
46+
};
47+
}
48+
}
49+
return null;
50+
};
51+
52+
if (Array.isArray(parsedJson)) {
53+
for (const [index, item] of parsedJson.entries()) {
54+
const functionCall = processJsonToolCall(item, index);
55+
if (functionCall) {
56+
functionCalls.push(functionCall);
57+
}
58+
}
59+
} else {
60+
const functionCall = processJsonToolCall(parsedJson, 0);
61+
if (functionCall) {
62+
functionCalls.push(functionCall);
63+
}
64+
}
65+
66+
if (functionCalls.length > 0) {
67+
return functionCalls;
68+
}
69+
} catch (e) {
70+
// Not a valid JSON, proceed with regex parsing
71+
debugLogger.log(
72+
'[Debug] Failed to parse tool calls as JSON, falling back to regex.',
73+
);
74+
}
75+
76+
// This regex finds patterns like `function_name(anything_inside)`.
77+
const toolCallRegex = /(\w+)\((.*?)\)/g;
78+
let match;
79+
80+
// The model might return tool calls wrapped in [].
81+
const content =
82+
strippedText.trim().startsWith('[') && strippedText.trim().endsWith(']')
83+
? strippedText.trim().slice(1, -1)
84+
: strippedText.trim();
85+
86+
while ((match = toolCallRegex.exec(content)) !== null) {
87+
const name = match[1];
88+
const argsString = match[2];
89+
debugLogger.log(
90+
`[Debug] Found tool call: ${name} with args: ${argsString}`,
91+
);
92+
const args: { [key: string]: unknown } = {};
93+
94+
if (argsString) {
95+
// This regex handles key-value pairs, including quoted values that may contain commas.
96+
const argRegex = /(\w+)=(".*?"|'.*?'|[^,]+)/g;
97+
let argMatch;
98+
while ((argMatch = argRegex.exec(argsString)) !== null) {
99+
const key = argMatch[1];
100+
let value: unknown = argMatch[2].trim();
101+
102+
// Basic type inference
103+
if (typeof value === 'string') {
104+
if (
105+
(value.startsWith('"') && value.endsWith('"')) ||
106+
(value.startsWith(`'`) && value.endsWith(`'`))
107+
) {
108+
value = value.slice(1, -1);
109+
} else if (!isNaN(Number(value)) && value.trim() !== '') {
110+
value = Number(value);
111+
} else if (value === 'true') {
112+
value = true;
113+
} else if (value === 'false') {
114+
value = false;
115+
}
116+
}
117+
args[key] = value;
118+
}
119+
}
120+
functionCalls.push({
121+
id: `${promptId}-ollama-${functionCalls.length}`,
122+
name,
123+
args,
124+
});
125+
}
126+
// debugLogger.log(
127+
// `[Debug] Parsed Ollama tool calls: ${JSON.stringify(functionCalls)}`,
128+
// );
129+
return functionCalls;
130+
}

0 commit comments

Comments
 (0)