refactor: preValidateToolCall rules array, extract getLastHumanContent helper
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 17s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 42s

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 15:43:04 +08:00
co-authored by Claude Sonnet 4.6
parent e4ba9f78b1
commit 1be8b2602d
@@ -197,129 +197,138 @@ async function withTimeout<T>(
}
}
type ValidationRule = (
name: string,
args: Record<string, unknown>,
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<string, unknown>).role === "user" ||
(typeof (m as unknown as Record<string, unknown>)._getType === "function" &&
((m as unknown as Record<string, unknown>)._getType as () => string)() === "human"),
);
return typeof (lastHuman as unknown as Record<string, unknown>)?.content === "string"
? ((lastHuman as unknown as Record<string, unknown>).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<string, unknown>,
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<string, unknown>).role === "user" ||
(typeof (m as unknown as Record<string, unknown>)._getType === "function" &&
((m as unknown as Record<string, unknown>)._getType as () => string)() === "human")
);
const content = typeof (lastHuman as unknown as Record<string, unknown>)?.content === "string"
? (lastHuman as unknown as Record<string, unknown>).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<string, unknown>).role === "user" ||
(typeof (m as unknown as Record<string, unknown>)._getType === "function" &&
((m as unknown as Record<string, unknown>)._getType as () => string)() === "human")
);
const content = typeof (lastHuman as unknown as Record<string, unknown>)?.content === "string"
? (lastHuman as unknown as Record<string, unknown>).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;
}