refactor: preValidateToolCall rules array, extract getLastHumanContent helper
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user