From e8a29e1f891e4e8cb58cd7e3f55cb37b4c2f15b7 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Sun, 12 Apr 2026 14:16:19 +0800 Subject: [PATCH] feat: toolConfig.ts, expanded preValidate rules, envelope unification, fallbackFrom, temperature pass-through - New src/agent/utils/toolConfig.ts: centralized per-tool timeout/retry config - preValidateToolCall: kb_search min query length + duplicate guard, ticket_list page range, ticket_detail placeholder ID detection - ExecutionLogEntry: added fallbackFrom and retryCount fields - retry.ts: added onAttempt callback for retry counting - tool-executor.ts: all 7 tool content returns unified to {ok, tool, summary, resultCount, data, error, fallback} envelope - supervisor/types.ts: temperature slider config field - enterprise/agent.ts: temperature pass-through to createLlm() Co-Authored-By: Claude Sonnet 4.6 (1M context) --- langgraph/src/agent/enterprise/nodes/agent.ts | 3 +- .../agent/enterprise/nodes/tool-executor.ts | 115 +++++++++++++----- langgraph/src/agent/enterprise/types.ts | 4 + langgraph/src/agent/supervisor/types.ts | 15 +++ langgraph/src/agent/utils/retry.ts | 3 +- langgraph/src/agent/utils/toolConfig.ts | 22 ++++ 6 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 langgraph/src/agent/utils/toolConfig.ts diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts index 94895dd..22c62eb 100644 --- a/langgraph/src/agent/enterprise/nodes/agent.ts +++ b/langgraph/src/agent/enterprise/nodes/agent.ts @@ -121,8 +121,9 @@ export async function agentNode( ): Promise { const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode; const enabledTools = config.configurable?.enabledTools as string[] | undefined; + const temperature = (config.configurable as { temperature?: number } | undefined)?.temperature; - const llm = createLlm({ modelMode }); + const llm = createLlm({ modelMode, temperature }); const tools = filterTools(modelMode, enabledTools); const truncated = truncateMessages(state.messages); diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index 0ee14b9..961c558 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -31,6 +31,7 @@ import { chartGenerateSchema, } from "./tool-defs.js"; import { executeWithRetry, formatToolError } from "@/agent/utils/retry"; +import { getToolConfig } from "@/agent/utils/toolConfig"; // Structured tool execution trace logger — Azure log stream can filter by field function logToolCall(entry: ExecutionLogEntry) { @@ -42,10 +43,7 @@ function truncateInput(s: string, maxLen = 200): string { return s.length > maxLen ? s.slice(0, maxLen) + "..." : s; } -/** Default per-tool timeout in ms */ -const TOOL_TIMEOUT_MS = 15_000; -/** KB search gets a longer timeout due to cold-start */ -const KB_TIMEOUT_MS = 45_000; +// Timeouts and retry counts are now driven by getToolConfig() from toolConfig.ts /** Map raw status codes to Chinese labels for chart display */ function statusLabel(status: string): string { @@ -182,6 +180,30 @@ function preValidateToolCall( if (!id || id === "undefined" || id === "null") { return "ticket_detail 调用被拦截:未提供有效工单编号,请改用 ticket_list 查询工单列表"; } + // Placeholder ID detection + const PLACEHOLDER = /^(xxx|unknown|test|id|null|undefined|\d{1,2})$/i; + if (PLACEHOLDER.test(id)) { + return `ticket_detail 调用被拦截:工单 ID "${id}" 看起来是占位符,请先用 ticket_list 查询真实工单号`; + } + } + + // kb_search: query minimum length + if (name === "kb_search") { + const query = String(args.query ?? "").trim(); + if (query.length < 5) return "kb_search 调用被拦截:搜索词过短(最少5字符)"; + // Duplicate call guard + const alreadyCalled = state.execution_log?.some( + (e) => e.tool === "kb_search" && e.inputSummary?.includes(query) && e.status !== "error", + ); + if (alreadyCalled) return `kb_search 调用被拦截:本轮已查询过相同内容 "${query.slice(0, 30)}"`; + } + + // ticket_list: page range validation + if (name === "ticket_list") { + const page = Number(args.page ?? 1); + if (!Number.isInteger(page) || page < 1 || page > 100) { + return `ticket_list 调用被拦截:page 参数无效(${page}),需为 1~100 的整数`; + } } // web_search / google_search / web_search_deep: query must be non-empty and >= 3 chars @@ -305,16 +327,18 @@ export async function toolExecutorNode( case "kb_search": { const parsed = kbSearchSchema.parse(args); let kbData: Awaited> | null = null; + const kbCfg = getToolConfig("kb_search"); + let kbRetryCount = 0; try { kbData = await withTimeout( () => executeWithRetry( () => kbSearch(parsed.query), - 3, - { backoffMs: 1000, exponential: true }, + kbCfg.maxRetries, + { backoffMs: kbCfg.backoffMs, exponential: kbCfg.exponential, onAttempt: () => { kbRetryCount++; } }, ), - KB_TIMEOUT_MS, + kbCfg.timeoutMs, ); } catch (kbError) { // Push friendly error card, then try fallback to google_search @@ -340,7 +364,7 @@ export async function toolExecutorNode( const { googleSearch } = await import("../tools/soc-client.js"); const gData = await withTimeout( () => googleSearch(parsed.query), - TOOL_TIMEOUT_MS, + getToolConfig("google_search").timeoutMs, ); const fallbackResults = gData.results.slice(0, 5).map((r) => ({ title: r.title, @@ -360,6 +384,8 @@ export async function toolExecutorNode( inputSummary: truncateInput(`query: ${parsed.query}`), durationMs: Date.now() - startTime, resultCount: fallbackResults.length, + fallbackFrom: "kb_search", + retryCount: kbRetryCount, }; executionLog.push(fallbackLogEntry); logToolCall(fallbackLogEntry); @@ -367,9 +393,12 @@ export async function toolExecutorNode( role: "tool" as const, tool_call_id: id, content: JSON.stringify({ - total: fallbackResults.length, - results: fallbackResults, - note: "知识库暂时无法访问,以下结果来自网络搜索", + ok: true, + tool: name, + summary: "知识库暂时无法访问,以下结果来自网络搜索", + resultCount: fallbackResults.length, + data: fallbackResults, + fallback: true, }), }; } catch { @@ -405,7 +434,14 @@ export async function toolExecutorNode( return { role: "tool" as const, tool_call_id: id, - content: formatToolError(name, kbError), + content: JSON.stringify({ + ok: false, + tool: name, + summary: "知识库及网络搜索均不可用", + resultCount: 0, + error: formatToolError(name, kbError), + fallback: false, + }), }; } } @@ -472,9 +508,7 @@ export async function toolExecutorNode( ); } - const kbContent: Record = { total: results.length, results }; if (results.length === 0) { - kbContent.hint = "知识库未找到相关内容。建议:可尝试使用搜索引擎查找相关信息。"; statusList.push({ tool: name, status: "empty", message: "知识库未找到相关内容" }); } else { statusList.push({ tool: name, status: "ok" }); @@ -487,21 +521,31 @@ export async function toolExecutorNode( inputSummary: truncateInput(`query: ${parsed.query}`), durationMs: Date.now() - startTime, resultCount: results.length, + retryCount: kbRetryCount, }; executionLog.push(kbLogEntry); logToolCall(kbLogEntry); return { role: "tool" as const, tool_call_id: id, - content: JSON.stringify(kbContent), + content: JSON.stringify({ + ok: results.length > 0, + tool: name, + summary: execSummary, + resultCount: results.length, + data: results, + ...(results.length === 0 ? { error: "知识库未找到相关内容。建议:可尝试使用搜索引擎查找相关信息。" } : {}), + }), }; } case "ticket_list": { const parsed = ticketListSchema.parse(args); + const tlCfg = getToolConfig("ticket_list"); + let tlRetryCount = 0; const data = await withTimeout( - () => executeWithRetry(() => ticketList(parsed.page ?? 1), 2), - TOOL_TIMEOUT_MS, + () => executeWithRetry(() => ticketList(parsed.page ?? 1), tlCfg.maxRetries, { backoffMs: tlCfg.backoffMs, onAttempt: () => { tlRetryCount++; } }), + tlCfg.timeoutMs, ); const tickets = (data.tickets ?? []).map((t) => ({ id: t.ticketNumber, @@ -597,35 +641,40 @@ export async function toolExecutorNode( if (tickets.length === 0) { statusList.push({ tool: name, status: "empty", message: "未查询到工单" }); - const tlEmptyEntry: ExecutionLogEntry = { tool: name, status: "partial_success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: 0 }; + const tlEmptyEntry: ExecutionLogEntry = { tool: name, status: "partial_success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: 0, retryCount: tlRetryCount }; executionLog.push(tlEmptyEntry); logToolCall(tlEmptyEntry); return { role: "tool" as const, tool_call_id: id, content: JSON.stringify({ - total: 0, - tickets: [], - hint: "未查询到工单。请告知用户可以:1) 尝试不同的查询条件;2) 检查权限;3) 确认工单系统是否有数据。", + ok: false, + tool: name, + summary: "未查询到工单", + resultCount: 0, + data: [], + error: "未查询到工单。请告知用户可以:1) 尝试不同的查询条件;2) 检查权限;3) 确认工单系统是否有数据。", }), }; } statusList.push({ tool: name, status: "ok" }); - const tlOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: tickets.length }; + const tlOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: tickets.length, retryCount: tlRetryCount }; executionLog.push(tlOkEntry); logToolCall(tlOkEntry); return { role: "tool" as const, tool_call_id: id, - content: JSON.stringify({ total: tickets.length, tickets, stats }), + content: JSON.stringify({ ok: true, tool: name, summary: execSummary, resultCount: tickets.length, data: { tickets, stats } }), }; } case "ticket_detail": { const parsed = ticketDetailSchema.parse(args); + const tdCfg = getToolConfig("ticket_detail"); + let tdRetryCount = 0; const t = await withTimeout( - () => executeWithRetry(() => ticketDetail(parsed.ticket_id), 2), - TOOL_TIMEOUT_MS, + () => executeWithRetry(() => ticketDetail(parsed.ticket_id), tdCfg.maxRetries, { backoffMs: tdCfg.backoffMs, onAttempt: () => { tdRetryCount++; } }), + tdCfg.timeoutMs, ); const execSummary = `获取工单 ${String(t.ticketNumber ?? parsed.ticket_id)} 详情`; ui.push( @@ -656,7 +705,7 @@ export async function toolExecutorNode( { message: lastAiMessage }, ); statusList.push({ tool: name, status: "ok" }); - const tdOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`ticket_id: ${parsed.ticket_id}`), durationMs: Date.now() - startTime, resultCount: 1 }; + const tdOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`ticket_id: ${parsed.ticket_id}`), durationMs: Date.now() - startTime, resultCount: 1, retryCount: tdRetryCount }; executionLog.push(tdOkEntry); logToolCall(tdOkEntry); return { @@ -718,7 +767,7 @@ export async function toolExecutorNode( return { role: "tool" as const, tool_call_id: id, - content: JSON.stringify({ status: "图表已生成", title: parsed.title }), + content: JSON.stringify({ ok: true, tool: name, summary: `图表已生成:${parsed.title}`, resultCount: 1, data: { title: parsed.title, chart_type: parsed.chart_type } }), }; } @@ -751,8 +800,8 @@ export async function toolExecutorNode( if (name === "ticket_detail" && suggestion === "check_input") { try { const fallbackData = await withTimeout( - () => executeWithRetry(() => ticketList(1), 2), - TOOL_TIMEOUT_MS, + () => executeWithRetry(() => ticketList(1), getToolConfig("ticket_list").maxRetries), + getToolConfig("ticket_list").timeoutMs, ); const fallbackTickets = (fallbackData.tickets ?? []) .slice(0, 5) @@ -805,9 +854,13 @@ export async function toolExecutorNode( role: "tool" as const, tool_call_id: id, content: JSON.stringify({ + ok: false, + tool: name, + summary: "未找到该工单,已自动查询最近工单列表", + resultCount: fallbackTickets.length, + data: fallbackTickets, error: "未找到该工单", - fallback: "已自动查询最近工单列表", - tickets: fallbackTickets, + fallback: true, }), }; } diff --git a/langgraph/src/agent/enterprise/types.ts b/langgraph/src/agent/enterprise/types.ts index e037a08..bf880fe 100644 --- a/langgraph/src/agent/enterprise/types.ts +++ b/langgraph/src/agent/enterprise/types.ts @@ -34,6 +34,10 @@ export type ExecutionLogEntry = { summary: string; /** Shared trace ID for all tool calls within a single user request */ traceId?: string; + /** If this entry is a fallback result, records the primary tool that triggered it */ + fallbackFrom?: string; + /** Number of retry attempts before success or final failure */ + retryCount?: number; }; function executionLogReducer( diff --git a/langgraph/src/agent/supervisor/types.ts b/langgraph/src/agent/supervisor/types.ts index 0cec40f..03a90b2 100644 --- a/langgraph/src/agent/supervisor/types.ts +++ b/langgraph/src/agent/supervisor/types.ts @@ -45,6 +45,21 @@ export const SupervisorZodConfiguration = z.object({ { label: "Chart Generate", value: "chart_generate" }, ], }), + /** + * LLM temperature override. 0 = deterministic, 1 = creative. + */ + temperature: z + .number() + .optional() + .langgraph.metadata({ + type: "slider", + default: 0.5, + min: 0, + max: 1, + step: 0.1, + description: + "LLM temperature (0=deterministic, 1=creative). Default 0.5 balances accuracy and flexibility.", + }), /** * Task context for action-bar follow-up. When set, bypasses intent routing * and routes directly to the relevant agent with card context injected. diff --git a/langgraph/src/agent/utils/retry.ts b/langgraph/src/agent/utils/retry.ts index 6ade6a3..549c11e 100644 --- a/langgraph/src/agent/utils/retry.ts +++ b/langgraph/src/agent/utils/retry.ts @@ -5,7 +5,7 @@ export async function executeWithRetry( fn: () => Promise, retries = 1, - options?: { backoffMs?: number; exponential?: boolean }, + options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void }, ): Promise { const backoffMs = options?.backoffMs ?? 1000; const exponential = options?.exponential ?? false; @@ -17,6 +17,7 @@ export async function executeWithRetry( return await fn(); } catch (e) { attempt++; + options?.onAttempt?.(attempt); if (attempt >= maxAttempts) throw e; const delay = exponential ? backoffMs * Math.pow(2, attempt - 1) diff --git a/langgraph/src/agent/utils/toolConfig.ts b/langgraph/src/agent/utils/toolConfig.ts new file mode 100644 index 0000000..820dcb4 --- /dev/null +++ b/langgraph/src/agent/utils/toolConfig.ts @@ -0,0 +1,22 @@ +export interface ToolConfig { + timeoutMs: number; + maxRetries: number; + backoffMs?: number; + exponential?: boolean; +} + +export const TOOL_CONFIGS: Record = { + kb_search: { timeoutMs: 45_000, maxRetries: 3, backoffMs: 1000, exponential: true }, + ticket_list: { timeoutMs: 15_000, maxRetries: 2, backoffMs: 500 }, + ticket_detail: { timeoutMs: 15_000, maxRetries: 2, backoffMs: 500 }, + google_search: { timeoutMs: 10_000, maxRetries: 1 }, + web_search: { timeoutMs: 30_000, maxRetries: 1 }, + web_search_deep: { timeoutMs: 45_000, maxRetries: 1 }, + sandbox_run: { timeoutMs: 15_000, maxRetries: 1 }, + code_execute: { timeoutMs: 15_000, maxRetries: 1 }, + chart_generate: { timeoutMs: 5_000, maxRetries: 1 }, +}; + +export function getToolConfig(toolName: string): ToolConfig { + return TOOL_CONFIGS[toolName] ?? { timeoutMs: 15_000, maxRetries: 1 }; +}