From 1be8b2602d722b8dcf2a267567f6a95384aa2a45 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Sun, 12 Apr 2026 15:43:04 +0800 Subject: [PATCH] refactor: preValidateToolCall rules array, extract getLastHumanContent helper Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../agent/enterprise/nodes/tool-executor.ts | 237 +++++++++--------- 1 file changed, 123 insertions(+), 114 deletions(-) diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index db9581f..33ccab6 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -197,129 +197,138 @@ async function withTimeout( } } +type ValidationRule = ( + name: string, + args: Record, + state: EnterpriseState, +) => string | null; + +/** Extract the last human message text (shared across multiple rules to eliminate duplicate code) */ +function getLastHumanContent(state: EnterpriseState): string { + const lastHuman = [...state.messages].reverse().find( + (m) => + (m as unknown as Record).role === "user" || + (typeof (m as unknown as Record)._getType === "function" && + ((m as unknown as Record)._getType as () => string)() === "human"), + ); + return typeof (lastHuman as unknown as Record)?.content === "string" + ? ((lastHuman as unknown as Record).content as string) + : ""; +} + +const VALIDATE_RULES: ValidationRule[] = [ + // Rule 1: Max 3 tool calls per turn + (_name, _args, state) => { + const count = state.execution_log?.length ?? 0; + if (count >= 3) return `已执行 ${count} 次工具调用,达到单轮上限(3次),停止继续调用。`; + return null; + }, + + // Rule 2: ticket_detail ID must be valid, not a placeholder + (name, args) => { + if (name !== "ticket_detail") return null; + const id = String(args.ticket_id ?? "").trim(); + if (!id || id === "undefined" || id === "null") + return "ticket_detail 调用被拦截:未提供有效工单编号,请改用 ticket_list 查询工单列表"; + const PLACEHOLDER = /^(xxx|unknown|test|id|null|undefined|\d{1,2}|\{.*\})$/i; + if (PLACEHOLDER.test(id)) + return `ticket_detail 调用被拦截:工单 ID "${id}" 看起来是占位符,请先用 ticket_list 查询真实工单号`; + return null; + }, + + // Rule 3: kb_search query length + same-turn duplicate call guard + (name, args, state) => { + if (name !== "kb_search") return null; + const query = String(args.query ?? "").trim(); + if (query.length < 5) return "kb_search 调用被拦截:搜索词过短(最少5字符)"; + 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)}"`; + return null; + }, + + // Rule 4: Block external search when internal keywords detected, redirect to kb_search + (name, _args, state) => { + if (name !== "google_search" && name !== "web_search_deep") return null; + const INTERNAL_KEYWORDS = /知识库|内部文档|公司规定|内部规范|内部流程|产品手册/; + if (INTERNAL_KEYWORDS.test(getLastHumanContent(state))) + return "该查询涉及内部知识,请优先使用 kb_search 检索内部知识库"; + return null; + }, + + // Rule 5: ticket_list page parameter range validation (1~100 integer) + (name, args) => { + if (name !== "ticket_list") return null; + const page = Number(args.page ?? 1); + if (!Number.isInteger(page) || page < 1 || page > 100) + return `ticket_list 调用被拦截:page 参数无效(${page}),需为 1~100 的整数`; + return null; + }, + + // Rule 6: ticket_list when message contains specific ticket ID, should use ticket_detail instead + (name, _args, state) => { + if (name !== "ticket_list") return null; + if (/TK-[A-Za-z0-9]{4,}/i.test(getLastHumanContent(state))) + return "消息中包含具体工单号,请改用 ticket_detail 查询详情"; + return null; + }, + + // Rule 7: Search query minimum length (generic for web_search tools) + (name, args) => { + if (!["web_search", "google_search", "web_search_deep"].includes(name)) return null; + const query = String(args.query ?? "").trim(); + if (!query || query.length < 3) return `${name} 调用被拦截:搜索词过短或为空`; + return null; + }, + + // Rule 8: Code execution - block dangerous commands + (name, args) => { + if (!["sandbox_run", "code_execute"].includes(name)) return null; + const code = String(args.code ?? "").trim(); + if (!code) return `${name} 调用被拦截:代码内容为空`; + const DANGEROUS = ["rm -rf", "dd if=", "mkfs", ":(){:|:&};:"]; + for (const d of DANGEROUS) { + if (code.includes(d)) return `${name} 调用被拦截:检测到危险命令 "${d}"`; + } + return null; + }, + + // Rule 9: Document tools must have title or topic + (name, args) => { + if (!["doc_create", "report_generate", "reply_draft"].includes(name)) return null; + const title = String(args.title ?? "").trim(); + const topic = String(args.topic ?? args.subject ?? "").trim(); + if (!title && !topic) return `${name} 调用被拦截:文档标题或主题不能为空`; + return null; + }, + + // Rule 10: chart_generate requires prior upstream tool data + (name, _args, state) => { + if (name !== "chart_generate") return null; + const hasData = state.execution_log?.some( + (e) => + e.status !== "error" && + ["kb_search", "ticket_list", "ticket_detail"].includes(e.tool), + ); + if (!hasData) return "chart_generate 调用被拦截:当前对话尚无工具数据,无法生成图表"; + return null; + }, +]; + /** * Pre-validate tool call arguments before execution. - * Returns a human-readable block reason, or null if validation passes. + * Returns a human-readable block reason string, or null if validation passes. */ export function preValidateToolCall( name: string, args: Record, state: EnterpriseState, ): string | null { - // ── Hard limit: max 3 tool calls per turn ──────────────────────────────── - const toolCallCount = state.execution_log?.length ?? 0; - if (toolCallCount >= 3) { - return `已执行 ${toolCallCount} 次工具调用,达到单轮上限(3次),停止继续调用。`; + for (const rule of VALIDATE_RULES) { + const reason = rule(name, args, state); + if (reason !== null) return reason; } - // ──────────────────────────────────────────────────────────────────────── - - // ticket_detail: must have a valid ticket ID - if (name === "ticket_detail") { - const id = String(args.ticket_id ?? "").trim(); - 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)}"`; - } - - // google_search / web_search_deep: 若消息明显是内部信息查询,拦截外网搜索 - if (name === "google_search" || name === "web_search_deep") { - const lastHuman = [...state.messages].reverse().find( - (m) => (m as unknown as Record).role === "user" || - (typeof (m as unknown as Record)._getType === "function" && - ((m as unknown as Record)._getType as () => string)() === "human") - ); - const content = typeof (lastHuman as unknown as Record)?.content === "string" - ? (lastHuman as unknown as Record).content as string - : ""; - // 明确内部信息关键词时,拒绝走外网 - const INTERNAL_KEYWORDS = /知识库|内部文档|公司规定|内部规范|内部流程|产品手册/; - if (INTERNAL_KEYWORDS.test(content)) { - return "该查询涉及内部知识,请优先使用 kb_search 检索内部知识库"; - } - } - - // 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 的整数`; - } - } - - // ticket_list: 若消息中已有具体工单号(TK-xxxx),应调 ticket_detail 而非 ticket_list - if (name === "ticket_list") { - const lastHuman = [...state.messages].reverse().find( - (m) => (m as unknown as Record).role === "user" || - (typeof (m as unknown as Record)._getType === "function" && - ((m as unknown as Record)._getType as () => string)() === "human") - ); - const content = typeof (lastHuman as unknown as Record)?.content === "string" - ? (lastHuman as unknown as Record).content as string - : ""; - if (/TK-[A-Za-z0-9]{4,}/i.test(content)) { - return "消息中包含具体工单号,请改用 ticket_detail 查询详情"; - } - } - - // web_search / google_search / web_search_deep: query must be non-empty and >= 3 chars - if (["web_search", "google_search", "web_search_deep"].includes(name)) { - const query = String(args.query ?? "").trim(); - if (!query || query.length < 3) { - return `${name} 调用被拦截:搜索词过短或为空`; - } - } - - // sandbox_run / code_execute: code must be non-empty, block dangerous commands - if (["sandbox_run", "code_execute"].includes(name)) { - const code = String(args.code ?? "").trim(); - if (!code) { - return `${name} 调用被拦截:代码内容为空`; - } - const DANGEROUS = ["rm -rf", "dd if=", "mkfs", ":(){:|:&};:"]; - for (const d of DANGEROUS) { - if (code.includes(d)) { - return `${name} 调用被拦截:检测到危险命令 "${d}"`; - } - } - } - - // doc_create / report_generate / reply_draft: must have title or topic - if (["doc_create", "report_generate", "reply_draft"].includes(name)) { - const title = String(args.title ?? "").trim(); - const topic = String(args.topic ?? args.subject ?? "").trim(); - if (!title && !topic) { - return `${name} 调用被拦截:文档标题或主题不能为空`; - } - } - - // chart_generate: must have prior successful tool data in this conversation - if (name === "chart_generate") { - const hasData = state.execution_log?.some( - (e) => - e.status !== "error" && - ["kb_search", "ticket_list", "ticket_detail"].includes(e.tool), - ); - if (!hasData) { - return "chart_generate 调用被拦截:当前对话尚无工具数据,无法生成图表"; - } - } - return null; }