feat: Agent Engine 文本解析兜底 — 支持不支持 function calling 的模型

当模型没有通过 Ollama API 的 tool_calls 字段返回工具调用,
而是在回复文本中写出 Action/Action Input 时(如 gemma4 系列),
自动从文本中提取工具调用,确保 ReAct Loop 正常执行。

支持的文本格式:
- **Action:** tool_name  **Action Input:** {json}
- Action: tool_name  Action Input: {json}
- 工具名白名单校验 + JSON 解析容错(单引号、尾逗号等)
This commit is contained in:
thzxx
2026-04-18 08:15:56 +08:00
parent 95153e9171
commit 445695c6e1
+91
View File
@@ -87,6 +87,89 @@ const TOOL_USAGE_GUIDE = `【工具使用规则】
9. 记忆工具:你可以使用 memory_search 搜索过去的记忆,使用 memory_add 添加新的重要记忆。 9. 记忆工具:你可以使用 memory_search 搜索过去的记忆,使用 memory_add 添加新的重要记忆。
10. 会话工具:你可以使用 session_list 列出历史会话,使用 session_read 读取历史会话内容。`; 10. 会话工具:你可以使用 session_list 列出历史会话,使用 session_read 读取历史会话内容。`;
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
const VALID_TOOL_NAMES = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
'append_file', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'session_list', 'session_read'
]);
/**
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
* 而是在文本中写了 "Action: xxx" / "Action Input: {...}" 时,
* 从文本中提取工具调用。
*/
function parseToolCallsFromText(content: string): ToolCall[] {
const calls: ToolCall[] = [];
// 匹配模式1: **Action:** tool_name **Action Input:** {json}
// 匹配模式2: Action: tool_name Action Input: {json}
// 同时支持 Action 和 Action Input 写在同一段或多行
const actionRegex = /\*?\*?Action:?\*?\*?\s*([\w]+)\s*[\r\n]+\s*\*?\*?Action\s*Input:?\*?\*?\s*(\{[\s\S]*?\})/gi;
let match;
while ((match = actionRegex.exec(content)) !== null) {
const toolName = match[1].trim();
const argsStr = match[2].trim();
if (!VALID_TOOL_NAMES.has(toolName)) continue;
try {
// 清理 JSON:去除可能的 markdown 代码块包裹
let cleaned = argsStr.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
const args = JSON.parse(cleaned);
calls.push({
type: 'function',
function: { name: toolName, arguments: args }
});
} catch {
// JSON 解析失败,尝试修复常见问题
try {
// 处理单引号、尾逗号等
let fixed = argsStr
.replace(/'/g, '"')
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']')
.replace(/```json?\s*/g, '')
.replace(/```/g, '')
.trim();
const args = JSON.parse(fixed);
calls.push({
type: 'function',
function: { name: toolName, arguments: args }
});
} catch {
logWarn(`文本解析兜底: 工具 ${toolName} 的参数 JSON 解析失败`, argsStr.slice(0, 100));
}
}
}
// 备选模式:Action 和 Action Input 写在一行或用不同分隔
if (calls.length === 0) {
const singleLineRegex = /Action:\s*(\w+)\s*.*?Action\s*Input:\s*(\{[^}]+\})/gi;
while ((match = singleLineRegex.exec(content)) !== null) {
const toolName = match[1].trim();
const argsStr = match[2].trim();
if (!VALID_TOOL_NAMES.has(toolName)) continue;
try {
const args = JSON.parse(argsStr);
calls.push({
type: 'function',
function: { name: toolName, arguments: args }
});
} catch { /* skip */ }
}
}
if (calls.length > 0) {
logInfo(`文本解析兜底: 从回复中提取到 ${calls.length} 个工具调用`, calls.map(c => c.function.name).join(', '));
}
return calls;
}
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */ /** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */
const toolResultCache = new Map<string, ToolResult>(); const toolResultCache = new Map<string, ToolResult>();
@@ -340,6 +423,14 @@ export async function runAgentLoop(
logModelResponse(content.length, toolCalls.length); logModelResponse(content.length, toolCalls.length);
// 文本解析兜底:模型没通过 tool_calls 返回但文本中写了 Action
if (toolCalls.length === 0 && useTools) {
const parsedCalls = parseToolCallsFromText(content);
if (parsedCalls.length > 0) {
toolCalls.push(...parsedCalls);
}
}
// 检查是否是 Final Answer(没有工具调用且包含 Final Answer // 检查是否是 Final Answer(没有工具调用且包含 Final Answer
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:'); const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:');
if (toolCalls.length === 0) { if (toolCalls.length === 0) {