- Fix ticket_detail UUID resolution: use find() to match ticketNumber instead of tickets[0] - Add sandbox fallback hint in coder tool-executor catch block - Add ticket_list empty result friendly hint with suggestions - Add google_search auto-supplement with Jina when results < 3 - Enhance enterprise tool-executor general catch with fallback_hint Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
/**
|
|
* Searcher tool executor: executes search tool calls, pushes Gen-UI cards.
|
|
*/
|
|
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
|
|
import type ComponentMap from "../../../agent-uis/index.js";
|
|
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
|
import { AIMessage } from "@langchain/core/messages";
|
|
import { SearcherState, SearcherUpdate } from "../types.js";
|
|
import type { ToolExecStatus } from "../../types.js";
|
|
import {
|
|
googleSearch,
|
|
webSearch,
|
|
webRead,
|
|
jinaRerank,
|
|
} from "../../enterprise/tools/soc-client.js";
|
|
import {
|
|
googleSearchSchema,
|
|
webSearchDeepSchema,
|
|
webReadSchema,
|
|
} from "./agent.js";
|
|
|
|
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
|
|
|
|
export async function toolExecutorNode(
|
|
state: SearcherState,
|
|
config: LangGraphRunnableConfig,
|
|
): Promise<SearcherUpdate> {
|
|
const ui = typedUi<typeof ComponentMap>(config);
|
|
|
|
const lastAiMessage = [...state.messages]
|
|
.reverse()
|
|
.find(
|
|
(m): m is AIMessage =>
|
|
(m as AIMessage).tool_calls !== undefined &&
|
|
((m as AIMessage).tool_calls?.length ?? 0) > 0,
|
|
) as AIMessage | undefined;
|
|
|
|
if (!lastAiMessage?.tool_calls?.length) {
|
|
return { ui: ui.items, timestamp: Date.now() };
|
|
}
|
|
|
|
const toolCalls = lastAiMessage.tool_calls!;
|
|
const toolMessages: Array<{
|
|
role: "tool";
|
|
tool_call_id: string;
|
|
content: string;
|
|
}> = [];
|
|
|
|
const statusList: ToolExecStatus[] = [];
|
|
|
|
const executions = toolCalls.map(async (tc) => {
|
|
const name = tc.name;
|
|
const args = tc.args;
|
|
const id = tc.id ?? "";
|
|
|
|
try {
|
|
switch (name) {
|
|
case "google_search": {
|
|
const parsed = googleSearchSchema.parse(args);
|
|
let results: Array<{ title: string; url: string; snippet: string }>;
|
|
let knowledgeGraph: { title: string; description: string } | undefined;
|
|
let fallbackUsed = false;
|
|
try {
|
|
const data = await executeWithRetry(() => googleSearch(parsed.query));
|
|
results = data.results.map((r) => ({
|
|
title: r.title,
|
|
url: r.url,
|
|
snippet: r.snippet,
|
|
}));
|
|
knowledgeGraph = data.knowledgeGraph;
|
|
// Auto-supplement with Jina if results < 3
|
|
if (results.length < 3) {
|
|
try {
|
|
const supplementData = await webSearch(parsed.query + " 详细信息");
|
|
const supplementResults = supplementData.results.slice(0, 3 - results.length).map((r) => ({
|
|
title: r.title ?? "",
|
|
url: r.url ?? "",
|
|
snippet: (r.description ?? r.content ?? "").slice(0, 200),
|
|
}));
|
|
results = [...results, ...supplementResults];
|
|
} catch { /* supplement search failed silently */ }
|
|
}
|
|
} catch {
|
|
// google_search failed, fallback to web_search_deep (Jina)
|
|
console.warn(`[fallback] google_search failed, trying web_search_deep`);
|
|
const data = await webSearch(parsed.query);
|
|
results = (data.results ?? []).slice(0, 5).map((r) => ({
|
|
title: r.title ?? "",
|
|
url: r.url ?? "",
|
|
snippet: (r.description ?? r.content ?? "").slice(0, 200),
|
|
}));
|
|
fallbackUsed = true;
|
|
}
|
|
ui.push(
|
|
{
|
|
name: "search-result",
|
|
props: {
|
|
query: parsed.query,
|
|
total: results.length,
|
|
results,
|
|
sourceType: "external_web",
|
|
confidence: fallbackUsed ? "medium" : "high",
|
|
},
|
|
},
|
|
{ message: lastAiMessage },
|
|
);
|
|
statusList.push({
|
|
tool: "google_search",
|
|
status: fallbackUsed ? "fallback" : "ok",
|
|
...(fallbackUsed ? { message: "快速搜索不可用,已使用深度搜索替代" } : {}),
|
|
});
|
|
return {
|
|
role: "tool" as const,
|
|
tool_call_id: id,
|
|
content: JSON.stringify({
|
|
total: results.length,
|
|
results,
|
|
knowledgeGraph,
|
|
...(fallbackUsed ? { note: "快速搜索不可用,已使用深度搜索替代" } : {}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
case "web_search_deep": {
|
|
const parsed = webSearchDeepSchema.parse(args);
|
|
let enriched: Array<{ title: string; url: string; snippet: string }>;
|
|
let fallbackUsed = false;
|
|
try {
|
|
const data = await executeWithRetry(() => webSearch(parsed.query));
|
|
enriched = (data.results ?? []).slice(0, 5).map((r) => ({
|
|
title: r.title ?? "",
|
|
url: r.url ?? "",
|
|
snippet: (r.description ?? r.content ?? "").slice(0, 200),
|
|
}));
|
|
|
|
// Rerank results using Jina Reranker as post-processing
|
|
try {
|
|
const docs = enriched.map((r) => `${r.title} ${r.snippet}`);
|
|
if (docs.length > 0) {
|
|
const reranked = await jinaRerank(parsed.query, docs, Math.min(docs.length, 5));
|
|
enriched = reranked.results
|
|
.sort((a, b) => b.relevance_score - a.relevance_score)
|
|
.map((r) => enriched[r.index]);
|
|
}
|
|
} catch {
|
|
// Rerank failed silently, use original order
|
|
}
|
|
} catch {
|
|
// web_search_deep (Jina) failed, fallback to google_search
|
|
console.warn(`[fallback] web_search_deep failed, trying google_search`);
|
|
const data = await googleSearch(parsed.query);
|
|
enriched = data.results.slice(0, 5).map((r) => ({
|
|
title: r.title,
|
|
url: r.url,
|
|
snippet: r.snippet,
|
|
}));
|
|
fallbackUsed = true;
|
|
}
|
|
|
|
ui.push(
|
|
{
|
|
name: "search-result",
|
|
props: {
|
|
query: parsed.query,
|
|
total: enriched.length,
|
|
results: enriched,
|
|
sourceType: "external_web",
|
|
confidence: fallbackUsed ? "medium" : "high",
|
|
},
|
|
},
|
|
{ message: lastAiMessage },
|
|
);
|
|
statusList.push({
|
|
tool: "web_search_deep",
|
|
status: fallbackUsed ? "fallback" : "ok",
|
|
...(fallbackUsed ? { message: "深度搜索不可用,已使用快速搜索替代" } : {}),
|
|
});
|
|
return {
|
|
role: "tool" as const,
|
|
tool_call_id: id,
|
|
content: JSON.stringify({
|
|
total: enriched.length,
|
|
results: enriched,
|
|
...(fallbackUsed ? { note: "深度搜索不可用,已使用快速搜索替代" } : {}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
case "web_read": {
|
|
const parsed = webReadSchema.parse(args);
|
|
const data = await executeWithRetry(() => webRead(parsed.url));
|
|
// Truncate content to avoid token explosion
|
|
const truncated = data.content.slice(0, 4000);
|
|
statusList.push({ tool: "web_read", status: "ok" });
|
|
return {
|
|
role: "tool" as const,
|
|
tool_call_id: id,
|
|
content: JSON.stringify({
|
|
title: data.title,
|
|
url: parsed.url,
|
|
content: truncated,
|
|
truncated: data.content.length > 4000,
|
|
}),
|
|
};
|
|
}
|
|
|
|
default:
|
|
return {
|
|
role: "tool" as const,
|
|
tool_call_id: id,
|
|
content: `Unknown tool: ${name}`,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
statusList.push({ tool: name, status: "error", message: formatToolError(name, e) });
|
|
return {
|
|
role: "tool" as const,
|
|
tool_call_id: id,
|
|
content: formatToolError(name, e),
|
|
};
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(executions);
|
|
toolMessages.push(...results);
|
|
|
|
return {
|
|
messages: toolMessages,
|
|
ui: ui.items,
|
|
timestamp: Date.now(),
|
|
toolStatus: statusList,
|
|
};
|
|
}
|