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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
fc56503e22
commit
e8a29e1f89
@@ -121,8 +121,9 @@ export async function agentNode(
|
||||
): Promise<EnterpriseUpdate> {
|
||||
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);
|
||||
|
||||
@@ -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<ReturnType<typeof kbSearch>> | 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<string, unknown> = { 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export async function executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries = 1,
|
||||
options?: { backoffMs?: number; exponential?: boolean },
|
||||
options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void },
|
||||
): Promise<T> {
|
||||
const backoffMs = options?.backoffMs ?? 1000;
|
||||
const exponential = options?.exponential ?? false;
|
||||
@@ -17,6 +17,7 @@ export async function executeWithRetry<T>(
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
attempt++;
|
||||
options?.onAttempt?.(attempt);
|
||||
if (attempt >= maxAttempts) throw e;
|
||||
const delay = exponential
|
||||
? backoffMs * Math.pow(2, attempt - 1)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface ToolConfig {
|
||||
timeoutMs: number;
|
||||
maxRetries: number;
|
||||
backoffMs?: number;
|
||||
exponential?: boolean;
|
||||
}
|
||||
|
||||
export const TOOL_CONFIGS: Record<string, ToolConfig> = {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user