feat: toolConfig.ts, expanded preValidate rules, envelope unification, fallbackFrom, temperature pass-through
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 24s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 39s

- 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:
gongzhiyong
2026-04-12 14:16:19 +08:00
co-authored by Claude Sonnet 4.6
parent fc56503e22
commit e8a29e1f89
6 changed files with 129 additions and 33 deletions
@@ -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,
}),
};
}
+4
View File
@@ -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(
+15
View File
@@ -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.
+2 -1
View File
@@ -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)
+22
View File
@@ -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 };
}