src/core/contracts/tools.ts
Rama main · réplica de lectura del repositorio original.
import { effectiveToolRateLimit, type RateLimitRequest, type RateLimitDecision } from './tool-rate-limits';
// 工具插座运行时治理核心——设计与安全边界见 docs/TOOLS_DESIGN.md
// 职责:消费 ToolDefinition → 白名单/主体/审批/限流/审计/签名 → 给大脑可调用的运行时句柄。
// 中枢只做 reach(白名单/风险闸/限流/签名/审计);authority(这个人能不能做)永远由业务侧验签后裁决。
import { createHash, createHmac } from 'node:crypto';
import { AgentTaskControlError } from '../runtime/agent-task-control';
import { readResponseBody, truncateUtf8 } from '../platform/http-body';
import type { ToolProvider } from './types';
import { schemaAtPath, schemaTypes, valueMatchesSchemaType, type ToolConfirmCondition, type ToolDefinition } from './tool-definition';
import { toolSummary } from './tool-definition';
import { TOOL_ARGUMENTS_MAX_BYTES } from './tool-input-limits';
/** 路由 allow 白名单匹配:精确 scope 或前缀通配("tenant.staff.*")。 */
export function scopeAllowed(scope: string, allow: string[]): boolean {
for (const a of allow) {
if (a === scope) return true;
if (a.endsWith('.*') && scope.startsWith(a.slice(0, -1))) return true;
if (a === '*') return true;
}
return false;
}
/**
* 工具通道唯一签名(工具调用 + spec 拉取共用同一套构造):
* `sha256=` + HMAC_SHA256(secret, "<ts>.<METHOD>.<path?query>.<sha256hex(body)>.<On-Behalf-Of>.<Job-Id>")
* 返回值已含方案标签前缀 `sha256=`,可直接作 X-Bailing-Signature 头值。
*
* - 标签 `sha256` 是【算法名】不是版本号(GitHub webhook 同款约定);构造细节见 CONTRACT §2.4b。
* - On-Behalf-Of + Job-Id 并入签名材料,杜绝"窗口内重放合法请求、只改这两个头换租户/绕幂等"。
* - spec 拉取无操作主体/任务:onBehalfOf/jobId 传空串即可(同一套构造、尾部为空)。
*/
export function signToolCall(secret: string, ts: number, method: string, pathWithQuery: string, body: string, onBehalfOf = '', jobId = ''): string {
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
const mac = createHmac('sha256', secret).update(`${ts}.${method}.${pathWithQuery}.${bodyHash}.${onBehalfOf}.${jobId}`).digest('hex');
return `sha256=${mac}`;
}
/** 调用参数的规范化哈希:递归按键名排序后 JSON 化再 sha256。审批单按它精确匹配"当时那个调用"(键序不同不算换动作)。 */
export function argsHash(args: Record<string, unknown>): string {
return createHash('sha256').update(canonicalJson(args ?? {}), 'utf8').digest('hex');
}
export type ToolExecutionJournalState =
| 'dispatching'
| 'response_recorded'
| 'completed'
| 'uncertain'
| 'evidence_degraded';
export interface ToolExecutionJournalEntry {
state: ToolExecutionJournalState;
ok: boolean;
status: number;
text: string;
idempotencyKey: string;
error?: string;
}
export interface ToolExecutionJournalRecord extends ToolExecutionJournalEntry {
jobId: string;
tool: string;
scope: string;
argsHash: string;
}
export interface ToolExecutionUncertainty {
state: Exclude<ToolExecutionJournalState, 'completed'>;
tool: string;
scope: string;
idempotency_key: string;
reason: string;
message: string;
}
/** 稳定的下游幂等键。业务系统可据此去重,但仍必须独立完成鉴权与业务授权。 */
export function toolCallIdempotencyKey(jobId: string, tool: string, hash: string, onBehalfOf = ''): string {
return createHash('sha256')
.update(canonicalJson({ job_id: jobId, tool, args_hash: hash, on_behalf_of: onBehalfOf }), 'utf8')
.digest('hex');
}
function canonicalJson(v: unknown): string {
if (v === null) return 'null';
if (Array.isArray(v)) return `[${v.map((item) => canonicalJsonArrayValue(item)).join(',')}]`;
if (typeof v === 'object') {
const record = v as Record<string, unknown>;
const keys = Object.keys(record).filter((key) => !isJsonOmitted(record[key])).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
}
return JSON.stringify(v) ?? 'null';
}
function canonicalJsonArrayValue(v: unknown): string {
return isJsonOmitted(v) ? 'null' : canonicalJson(v);
}
function isJsonOmitted(v: unknown): boolean {
return v === undefined || typeof v === 'function' || typeof v === 'symbol';
}
function normalizeToolArgs(args: Record<string, unknown>): { ok: true; args: Record<string, unknown>; encoded: string; bytes: number } | { ok: false; error: string; bytes?: number } {
try {
// 以实际 HTTP JSON 语义为准:对象中的 undefined 被省略、数组中的 undefined 变为 null。
// 审批快照、幂等哈希和外发请求必须消费同一份规范化参数。
const encoded = JSON.stringify(args);
if (!encoded) return { ok: false, error: 'tool_args_not_json_object' };
const bytes = Buffer.byteLength(encoded, 'utf8');
if (bytes > TOOL_ARGUMENTS_MAX_BYTES) return { ok: false, error: 'arguments_too_large', bytes };
const decoded: unknown = JSON.parse(encoded);
if (!isRecord(decoded)) return { ok: false, error: 'tool_args_not_json_object' };
return { ok: true, args: decoded, encoded, bytes };
} catch {
return { ok: false, error: 'tool_args_not_json_serializable' };
}
}
const RESERVED_TOOL_HEADERS = new Set([
'authorization', 'cookie', 'host', 'content-length', 'connection', 'transfer-encoding',
'x-bailing-timestamp', 'x-bailing-signature', 'x-bailing-job-id', 'x-bailing-client',
'x-bailing-idempotency-key', 'x-bailing-on-behalf-of', 'x-bailing-conversation',
'x-bailing-tool-scope',
]);
/**
* 服务端最后一道参数闸:MCP/Zod 只是客户端体验,不是信任边界。
* 这里拒绝顶层未声明参数、缺少必填项、基础类型/enum 不匹配以及保留治理头。
*/
function validateToolArguments(tool: ToolDefinition, args: Record<string, unknown>): string | null {
const properties = isRecord(tool.inputSchema['properties']) ? tool.inputSchema['properties'] as Record<string, unknown> : {};
// paramIn 也是 ACC/ToolDefinition 的显式参数声明。历史手工工具可能只在
// paramIn 声明 path/header/query 参数,而 inputSchema.properties 留空;这些参数
// 不是“未知参数”,但仍只能按已声明的位置出站。
const unknown = Object.keys(args).filter((key) => (
!Object.prototype.hasOwnProperty.call(properties, key)
&& !Object.prototype.hasOwnProperty.call(tool.paramIn, key)
));
if (unknown.length) return `unknown_arguments:${unknown.slice(0, 8).join(',')}`;
const required = Array.isArray(tool.inputSchema['required'])
? tool.inputSchema['required'].filter((item): item is string => typeof item === 'string')
: [];
const missing = required.filter((key) => args[key] === undefined);
if (missing.length) return `missing_required:${missing.slice(0, 8).join(',')}`;
for (const [key, value] of Object.entries(args)) {
const schema = isRecord(properties[key]) ? properties[key] as Record<string, unknown> : {};
const error = validateSchemaValue(value, schema, key, 0);
if (error) return error;
if (tool.paramIn[key] === 'header' && RESERVED_TOOL_HEADERS.has(key.toLowerCase())) {
return `reserved_header:${key}`;
}
}
return null;
}
function validateSchemaValue(value: unknown, schema: Record<string, unknown>, path: string, depth: number): string | null {
if (depth > 16) return `argument_too_deep:${path}`;
if (!valueMatchesSchemaType(value, schema)) return `argument_type:${path}`;
if (Array.isArray(schema['enum']) && !schema['enum'].some((candidate) => canonicalJson(candidate) === canonicalJson(value))) {
return `argument_enum:${path}`;
}
if (typeof value === 'string') {
const min = Number(schema['minLength']);
const max = Number(schema['maxLength']);
if (Number.isFinite(min) && value.length < min) return `argument_min_length:${path}`;
if (Number.isFinite(max) && value.length > max) return `argument_max_length:${path}`;
}
if (typeof value === 'number') {
const min = Number(schema['minimum']);
const max = Number(schema['maximum']);
if (Number.isFinite(min) && value < min) return `argument_minimum:${path}`;
if (Number.isFinite(max) && value > max) return `argument_maximum:${path}`;
}
if (Array.isArray(value)) {
const itemSchema = isRecord(schema['items']) ? schema['items'] as Record<string, unknown> : null;
if (value.length > 1000) return `argument_array_too_large:${path}`;
if (itemSchema) {
for (let i = 0; i < value.length; i++) {
const error = validateSchemaValue(value[i], itemSchema, `${path}[${i}]`, depth + 1);
if (error) return error;
}
}
}
if (isRecord(value)) {
if (Object.keys(value).length > 256) return `argument_object_too_large:${path}`;
const properties = isRecord(schema['properties']) ? schema['properties'] as Record<string, unknown> : {};
const required = Array.isArray(schema['required'])
? schema['required'].filter((item): item is string => typeof item === 'string')
: [];
const missing = required.filter((key) => value[key] === undefined);
if (missing.length) return `missing_required:${path}.${missing[0]}`;
const additional = schema['additionalProperties'];
for (const [key, child] of Object.entries(value)) {
const declared = properties[key];
if (declared === undefined) {
if (additional === true) continue;
if (isRecord(additional)) {
const error = validateSchemaValue(child, additional, `${path}.${key}`, depth + 1);
if (error) return error;
continue;
}
return `unknown_argument:${path}.${key}`;
}
if (isRecord(declared)) {
const error = validateSchemaValue(child, declared, `${path}.${key}`, depth + 1);
if (error) return error;
}
}
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 工具响应给模型的视图与审计证据分离:
* - 成功响应保持原有上下文预算;
* - 失败响应不把整页 HTML/代理错误页灌进后续模型轮次;
* - 完整响应仍由 tool_result 审计按独立上限留存。
*/
function modelFacingToolResponse(fullText: string, status: number, truncateBytes: number, contentType: string): string {
const limit = Math.max(0, truncateBytes);
if (status >= 200 && status < 300) return truncateUtf8(fullText, limit);
if (status === 0) return truncateUtf8(fullText, limit);
const looksLikeHtml = /text\/html/i.test(contentType)
|| /^\s*(?:<!doctype\s+html|<html\b)/i.test(fullText);
if (looksLikeHtml) {
return truncateUtf8(`HTTP ${status}:业务工具返回 HTML 错误页(${fullText.length} 字符)。响应按独立预算保留在审计记录中。`, limit);
}
const errorText = truncateUtf8(fullText.trim(), Math.min(limit, 2048));
return truncateUtf8(errorText ? `HTTP ${status}:${errorText}` : `HTTP ${status} 空响应`, limit);
}
// ---- 限流(进程内滑窗,三层:每任务 max_calls 由调用方计数 / 每工具 ACC execution.rate_limit / 工具源总闸)----
export class LocalSlidingWindowRateLimiter {
private readonly buckets = new Map<string, { timestamps: number[]; windowMs: number }>();
private lastSweepAt = 0;
constructor(private readonly now: () => number = Date.now) {}
consume(key: string, limit: number, windowSec = 60): boolean {
return this.consumeAll([{ bucket: key, limit, windowSec }]).limited;
}
consumeAll(requests: RateLimitRequest[]): RateLimitDecision {
const now = this.now();
if (now - this.lastSweepAt >= 60_000) {
this.lastSweepAt = now;
for (const [key, bucket] of this.buckets) {
if (!bucket.timestamps.some((ts) => now - ts < bucket.windowMs)) this.buckets.delete(key);
}
}
const active = requests.filter((r) => r.limit > 0).map((r) => {
const windowMs = r.windowSec * 1000;
const timestamps = (this.buckets.get(r.bucket)?.timestamps ?? []).filter((ts) => now - ts < windowMs);
return { ...r, windowMs, timestamps };
});
for (const r of active) {
if (r.timestamps.length >= r.limit) {
const expires = r.timestamps[r.timestamps.length - r.limit]! + r.windowMs;
return { limited: true, bucket: r.bucket, retryAfterMs: Math.max(1, expires - now) };
}
}
// Check every gate before charging any of them.
for (const r of active) this.buckets.set(r.bucket, { timestamps: [...r.timestamps, now], windowMs: r.windowMs });
return { limited: false };
}
bucketCount(): number { return this.buckets.size; }
}
const localRateLimiter = new LocalSlidingWindowRateLimiter();
/** 渐进式披露阈值:白名单内工具数 ≤ 此值全量内联(省一次往返);超过则目录+按需取定义(防几百个工具定义灌爆上下文)。 */
export const TOOL_INLINE_MAX = 12;
/**
* 审计落库单字段封顶(字节)。log_payload 打开且非敏感工具时,工具入参/响应**全量**入审计(远高于回流给模型的 truncateBytes,
* 二者是不同关注点:truncateBytes 控"喂模型的上下文预算",这里控"可追溯的审计留存")。封顶仅防异常大响应撑爆 bz_audit;
* 真实字节数始终另记 resp_bytes/args_bytes,超封顶记 *_truncated=true,绝不静默丢。64KB 覆盖绝大多数业务响应。
*/
export const AUDIT_MAX_BYTES = 65536;
/** Independent HTTP body budget; the application uses this fixed default, not an ACC field. */
export const TOOL_RESPONSE_MAX_BYTES = 5 * 1024 * 1024;
/** 大脑可用的工具运行时:清单(喂 LLM)+ 受治理的调用句柄。由 server 在派发时装配并注入 AdapterContext。 */
export interface ToolRuntime {
/** OpenAI function-calling 形态的工具清单 */
llmTools: Array<{ type: 'function'; function: { name: string; description: string; parameters: Record<string, unknown> } }>;
maxCalls: number;
/** 工具数 > TOOL_INLINE_MAX 时启用渐进披露:大脑先看目录,用哪个再取完整定义 */
progressive: boolean;
/** 工具语义检索可用(工具源配了 embedding 凭证且已建索引):装配方注入了 retrieveNames,retrieve() 可用。
* 优先级高于 progressive——适配器有它就按用户问题预载相关工具 + search_tools 检索更多,而不是甩一份目录让模型自己翻。 */
retrievalMode: boolean;
/** 轻目录(渐进披露第一段):每条 ~20 token,无参数 schema */
catalog: Array<{ name: string; summary: string; scope: string; risk: string; confirm_required: boolean }>;
/** 重跑时的"已批准调用清单"提示(审批车道 B:批准后自动重跑,大脑须按原样执行批准的调用);无则 undefined */
approvedNote?: string;
/** 渐进披露第二段:按名取完整定义(看菜单不点菜:不计入 max_calls,审计 tool_lookup)。未知名忽略。 */
lookup(names: string[]): Promise<ToolRuntime['llmTools']>;
/** 工具语义检索:按一句自然语言意图召回最相关的工具定义(双闸内排序,不计 max_calls)。
* 返回 null = 检索运行时不可用(索引未建/凭证不可用/embedding 临时挂)→ 调用方应降级回 progressive。retrievalMode=false 时不存在。 */
retrieve?(query: string): Promise<ToolRuntime['llmTools'] | null>;
/** 受闸调用:白名单复核→风险闸→限流→审计(fail-closed)→签名外发。返回回流 LLM 的文本(错误也以文本回流)。 */
invoke(name: string, args: Record<string, unknown>): Promise<ToolInvokeResult>;
/** 下游是否已经进入“不允许自动重试、必须人工对账”的执行边界。 */
executionUncertainty(): ToolExecutionUncertainty | null;
}
export type ToolGovernanceState =
| 'executed'
| 'business_rejected'
| 'awaiting_approval'
| 'rejected_before_dispatch'
| 'reconciliation_required';
/** 工具调用的受治理结果;旧调用方仍可只读 ok/text/status。 */
export interface ToolRateLimitFeedback {
level: 'tool' | 'provider';
count: number;
window_sec: number;
scope: 'tool_provider_shared';
source: string;
}
export interface ToolInvokeResult {
ok: boolean;
text: string;
status: number;
governance_state?: ToolGovernanceState;
auto_retry_allowed?: boolean;
retry_after_ms?: number;
rate_limit?: ToolRateLimitFeedback;
approval_id?: number;
error?: 'arguments_too_large';
origin?: 'core';
dispatch?: 'not_dispatched';
actual_bytes?: number;
max_bytes?: number;
limit_scope?: 'arguments';
}
/**
* 多工具源组合器:模型只看到一份工具面,调用时按工具名路由回所属工具源。
* 工具名必须在路由内全局唯一;上下文装配层会在运行前拒绝冲突,避免误调到错误业务系统。
*/
export function composeToolRuntimes(runtimes: ToolRuntime[], maxCalls: number, approvedNote?: string): ToolRuntime {
const owner = new Map<string, ToolRuntime>();
for (const runtime of runtimes) {
for (const tool of runtime.llmTools) {
const name = tool.function.name;
if (owner.has(name)) throw new Error(`工具名冲突 ${name}:同一路由的多工具源必须使用全局唯一 operationId`);
owner.set(name, runtime);
}
}
const llmTools = runtimes.flatMap((runtime) => runtime.llmTools);
const catalog = runtimes.flatMap((runtime) => runtime.catalog);
const retrievalMode = runtimes.length > 0 && runtimes.every((runtime) => runtime.retrievalMode && !!runtime.retrieve);
return {
llmTools,
maxCalls,
progressive: llmTools.length > TOOL_INLINE_MAX,
retrievalMode,
catalog,
approvedNote,
async lookup(names) {
const wanted = [...new Set(names.map(String))].slice(0, 20);
const groups = await Promise.all(runtimes.map((runtime) => runtime.lookup(wanted)));
return groups.flat();
},
...(retrievalMode ? {
async retrieve(query: string) {
const groups = await Promise.all(runtimes.map((runtime) => runtime.retrieve!(query).catch(() => null)));
if (groups.some((group) => group === null)) return null;
const seen = new Set<string>();
return groups.flatMap((group) => group ?? []).filter((tool) => {
if (seen.has(tool.function.name)) return false;
seen.add(tool.function.name);
return true;
});
},
} : {}),
async invoke(name, args) {
const runtime = owner.get(name);
if (!runtime) return { ok: false, text: `工具 ${name} 不在本路由白名单内,调用被拒。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
return runtime.invoke(name, args);
},
executionUncertainty() {
for (const runtime of runtimes) {
const uncertainty = runtime.executionUncertainty();
if (uncertainty) return uncertainty;
}
return null;
},
};
}
/** 审批车道句柄:由 server 接到 bz_tool_approvals + 送达插座。不注入时风险闸按未配置审批承接处理(一律拦)。 */
export interface ApprovalDeps {
/** 传统任务批准后由中枢重跑;Agent 直调由本地 Agent 用 invocation_id 续执行。 */
continuationMode?: 'rerun' | 'resume';
/** 查"已批准且未消费"的同快照审批单并原子消费;消费成功返回单号,否则 null */
consumeApproved(tool: string, hash: string): Promise<number | null>;
/** Managed tasks only: observation without consuming; final permit transaction consumes it. */
peekApproved?(tool: string, hash: string): Promise<number | null>;
/** 查同快照 pending 单(去重:同一调用别重复开单) */
findPending(tool: string, hash: string): Promise<number | null>;
/** 查本任务同工具"已批准未消费"单(不限参数)——重跑时大脑参数漂移的纠偏依据 */
findApprovedAnyArgs(tool: string): Promise<{ id: number; args_json: string } | null>;
/** 开 pending 单,返回单号 */
create(snap: ApprovalIntentSnap): Promise<number>;
/** 推送审批人(送达插座);失败不阻塞——单已在库里,控制台照样能批。summary=ACC approval.prompt 渲染后的人话动作描述 */
notify(approvalId: number, snap: ApprovalIntentSnap): Promise<void>;
}
export interface ApprovalIntentSnap {
tool: string;
scope: string;
risk: string;
policy: string;
reason: string;
method: string;
path: string;
args_json: string;
args_hash: string;
/** Notification previews only; persisted execution arguments remain complete. */
args_bytes?: number;
args_truncated?: boolean;
summary?: string;
confirm_when?: ConfirmHit;
}
const APPROVAL_NOTIFICATION_ARGUMENTS_MAX_BYTES = 1000;
/** Bounded, explicitly labelled notification view; never an execution snapshot. */
export function approvalNotificationSnapshot(snap: ApprovalIntentSnap): ApprovalIntentSnap {
const encodedBytes = Buffer.byteLength(snap.args_json, 'utf8');
const bytes = snap.args_truncated && Number.isSafeInteger(snap.args_bytes) && Number(snap.args_bytes) >= encodedBytes
? Number(snap.args_bytes) : encodedBytes;
const base = { ...snap, ...(snap.summary ? { summary: truncateUtf8(snap.summary, 512) } : {}) };
if (encodedBytes <= APPROVAL_NOTIFICATION_ARGUMENTS_MAX_BYTES) return { ...base, args_bytes: bytes };
return {
...base,
args_json: JSON.stringify({
preview: truncateUtf8(snap.args_json, 384),
truncated: true,
args_bytes: bytes,
args_hash: snap.args_hash,
note: 'Full arguments are available in the authorized approval console.',
}),
args_bytes: bytes,
args_truncated: true,
};
}
export interface ToolRuntimeDeps {
provider: ToolProvider;
allowedTools: ToolDefinition[]; // 已过双闸的清单
maxCalls: number;
onBehalfOf: string; // metadata[subject_field],可空串
conversation?: string; // 来源会话坐标 <渠道名>:<收件人>(X-Bailing-Conversation),供业务批准后 /send 回流;可空
jobId: string;
clientAppId: string;
truncateBytes: number; // 工具结果回流截断(默认 8192)
/** Trusted runtime assembly only; independent from model/audit view budgets. */
maxResponseBodyBytes?: number;
approvedNote?: string; // 重跑时已批准调用清单提示
approvals?: ApprovalDeps; // 不注入 = high/confirm 一律拦
/** A trusted assembly supplies these hooks; model parameters cannot create a task or permit. */
taskControl?: {
prepare(input: { tool: string; argsHash: string; readonly: boolean; approvalRequired: boolean }): Promise<void>;
grant(input: { tool: string; argsHash: string; scope: string; idempotencyKey: string; approvalId?: number }): Promise<boolean>;
settle(outcome: 'unknown' | 'confirmed_dispatched', response?: { ok: boolean; status: number; text: string }, evidenceDegraded?: boolean): Promise<void>;
};
/** Recheck sticky task enrollment for legacy Agent/engine/executor entrypoints before outbound I/O. */
beforeDispatch?: () => Promise<void>;
audit(event: string, detail: Record<string, unknown>): Promise<void>; // 写审计;抛错=调用不放行(fail-closed)
/** 工具语义检索(装配层注入;不注入 = 不启用检索,退回 progressive):一句意图 → 召回工具名(已是双闸内、按相关度排序)。
* 返回 null = 检索运行时不可用(索引/凭证/embedding 临时挂)→ retrieve() 透传 null 让适配器降级。本层不碰 embedding,只收名单。 */
retrieveNames?: (query: string) => Promise<string[] | null>;
/** 多工具源聚合后总工具数超过阈值时,装配层可强制小工具源也开启检索句柄。 */
retrievalMode?: boolean;
/** 副作用工具执行日志。请求发出前必须先占位;任何未完成状态都禁止自动重放。 */
idempotency?: {
get(tool: string, argsHash: string): Promise<ToolExecutionJournalEntry | null>;
reserve(tool: string, scope: string, argsHash: string, idempotencyKey: string): Promise<{ inserted: boolean; entry: ToolExecutionJournalEntry }>;
recordResponse(tool: string, argsHash: string, res: { ok: boolean; status: number; text: string }): Promise<void>;
complete(tool: string, argsHash: string): Promise<void>;
markUncertain(tool: string, argsHash: string, error: string): Promise<void>;
markEvidenceDegraded(tool: string, argsHash: string, error: string): Promise<void>;
};
/** 集中限速器:返回 true = 已触发限流;不注入时退回进程内滑窗。 */
rateLimit?: (bucket: string, limit: number, windowSec: number) => Promise<boolean>;
rateLimitAll?: (requests: RateLimitRequest[]) => Promise<RateLimitDecision>;
}
export function buildToolRuntime(d: ToolRuntimeDeps): ToolRuntime {
const responseMaxBytes = d.maxResponseBodyBytes ?? TOOL_RESPONSE_MAX_BYTES;
if (!Number.isSafeInteger(responseMaxBytes) || responseMaxBytes <= 0) throw new RangeError('Invalid tool response byte limit');
const byName = new Map(d.allowedTools.map((t) => [t.name, t]));
const llmTools = d.allowedTools.map((t) => ({
type: 'function' as const,
function: { name: t.name, description: `${t.description}(scope:${t.scope}${t.readonly ? ',只读' : ''}${hasConditionalConfirm(t) ? ',部分参数需审批' : ''})`, parameters: t.inputSchema },
}));
// 检索模式:工具数超内联阈值 + 装配层注入了 retrieveNames(工具源配了 embedding 且有索引)才启用。
const retrievalMode = !!d.retrieveNames && (d.retrievalMode ?? d.allowedTools.length > TOOL_INLINE_MAX);
let executionUncertainty: ToolExecutionUncertainty | null = null;
return {
llmTools,
maxCalls: d.maxCalls,
progressive: d.allowedTools.length > TOOL_INLINE_MAX,
retrievalMode,
catalog: d.allowedTools.map((t) => ({ name: t.name, summary: toolSummary(t), scope: t.scope, risk: t.risk, confirm_required: t.confirmRequired || hasConditionalConfirm(t) })),
approvedNote: d.approvedNote,
async lookup(names) {
const want = new Set(names.map(String).slice(0, 20));
const found = llmTools.filter((x) => want.has(x.function.name));
// 看菜单不点菜:不计 max_calls;审计留痕但非 fail-closed(只读元操作)
try { void Promise.resolve(d.audit('tool_lookup', { names: [...want], found: found.map((x) => x.function.name) })).catch(() => undefined); } catch { /* best-effort 元审计 */ }
return found;
},
...(retrievalMode ? {
async retrieve(query: string) {
const names = await d.retrieveNames!(query).catch(() => null);
if (names === null) return null; // 检索运行时不可用 → 透传降级信号
// 保留相关度顺序(retrieveNames 已按分数排序),映射回完整定义
const found = names.map((n) => llmTools.find((x) => x.function.name === n)).filter(Boolean) as ToolRuntime['llmTools'];
try { void Promise.resolve(d.audit('tools_retrieved', { query: query.slice(0, 120), picked: found.map((x) => x.function.name) })).catch(() => undefined); } catch { /* best-effort 元审计 */ }
return found;
},
} : {}),
executionUncertainty() {
return executionUncertainty;
},
async invoke(name, args) {
if (executionUncertainty) {
return {
ok: false,
status: 0,
text: `前序工具 ${executionUncertainty.tool} 的执行结果尚未完成可信确认,本任务已冻结,禁止继续调用业务工具;请按幂等键 ${executionUncertainty.idempotency_key} 人工对账。`,
};
}
const t = byName.get(name);
// 闸1:白名单复核(与清单装配解耦的独立校验——清单多吐了也走不到执行)
if (!t) return { ok: false, text: `工具 ${name} 不在本路由白名单内,调用被拒。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
const normalized = normalizeToolArgs(args);
if (!normalized.ok) {
const details = normalized.error === 'arguments_too_large'
? { actual_bytes: normalized.bytes!, max_bytes: TOOL_ARGUMENTS_MAX_BYTES, limit_scope: 'arguments' as const }
: {};
await d.audit('tool_blocked', { tool: name, scope: t.scope, reason: normalized.error, ...details }).catch(() => undefined);
if (normalized.error === 'arguments_too_large') return {
ok: false, status: 413, text: `Tool arguments contain ${normalized.bytes} bytes; the limit is ${TOOL_ARGUMENTS_MAX_BYTES} bytes. The invocation was not dispatched.`,
error: 'arguments_too_large', origin: 'core', dispatch: 'not_dispatched',
governance_state: 'rejected_before_dispatch', auto_retry_allowed: false, ...details,
};
return { ok: false, text: `工具 ${name} 参数不是合法 JSON 对象,调用已拒绝。请按工具参数 schema 重新传参。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
const callArgs = normalized.args;
const schemaError = validateToolArguments(t, callArgs);
if (schemaError) {
await d.audit('tool_blocked', { tool: name, scope: t.scope, reason: schemaError }).catch(() => undefined);
const mismatch = schemaError.startsWith('argument_type:')
? '参数类型与声明的 schema 不一致'
: '参数与声明的 schema 不一致';
return { ok: false, text: `工具 ${name} ${mismatch},调用已拒绝(${schemaError})。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
// 闸1.5:主体闸——ACC subject.required 的工具必须有操作主体(装配层已过滤看不见,这里防绕过双闸兜底)
if (t.requiresSubject && !d.onBehalfOf) {
await d.audit('tool_blocked', { tool: name, scope: t.scope, reason: 'requires-subject:任务无操作主体' }).catch(() => undefined);
return { ok: false, text: `工具 ${name} 需要明确的操作主体,当前任务没有可信身份(如匿名访客)。请告知用户登录后再操作或走人工流程。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
// 闸1.8:幂等账本——同 job 内"副作用工具"(非只读、非声明幂等)已执行过的相同调用,重试/崩溃恢复重跑时直接返回上次结果,
// 不重复执行(防写操作重复扣款)。放在审批闸之前:否则重跑时已消费的审批单会被当成"无批准"再次触发待审批,把重跑卡死。
const sideEffecting = !t.readonly && !t.idempotent;
if ((sideEffecting || d.taskControl) && !d.idempotency) {
await d.audit('tool_blocked', {
tool: name,
scope: t.scope,
reason: '副作用工具缺少持久化执行日志,请求未发出',
}).catch(() => undefined);
return {
ok: false,
status: 0,
text: `工具 ${name} 会产生业务副作用,但当前运行时未配置持久化执行日志,请求未发出。`,
};
}
const idemHash = sideEffecting || d.taskControl ? argsHash(callArgs) : '';
const idemKey = idemHash ? toolCallIdempotencyKey(d.jobId, name, idemHash, d.onBehalfOf) : '';
if (idemHash) {
let existing: ToolExecutionJournalEntry | null;
try {
existing = await d.idempotency!.get(name, idemHash);
} catch (error) {
await d.audit('tool_blocked', {
tool: name,
scope: t.scope,
reason: '执行日志查询不可用,请求未发出',
error: String(error).slice(0, 500),
}).catch(() => undefined);
return {
ok: false,
status: 0,
text: `工具 ${name} 的执行日志当前不可用,请求未发出,也未消费审批。请稍后重新发起。`,
};
}
if (existing?.state === 'completed') {
await d.audit('tool_call_deduped', { tool: name, scope: t.scope, status: existing.status, idempotency_key: existing.idempotencyKey, reason: '同 job 已完成相同调用,返回已确认结果(防重试/恢复重复副作用)' }).catch(() => undefined);
return { ok: existing.ok, text: existing.text, status: existing.status };
}
if (existing) {
executionUncertainty = toolExecutionUncertaintyFromJournal(name, t.scope, existing);
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: existing.state,
idempotency_key: existing.idempotencyKey,
reason: existing.error ?? '检测到未完成的执行日志,禁止自动重放',
}).catch(() => undefined);
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
}
// 前置调度闸:任何可重试的限流都必须发生在审批单消费前。
// 请求尚未出站,不能把 429 伪装成业务拒绝,也不能白白用掉人工批准。
const effective = effectiveToolRateLimit(t, d.provider.tool_rate_limits);
const gates: RateLimitRequest[] = [
{ bucket: `tool:${d.provider.name}:${name}`, limit: effective.enabled ? effective.count : 0, windowSec: effective.window_sec || 60 },
{ bucket: `provider:${d.provider.name}`, limit: d.provider.rate_limit_per_min, windowSec: 60 },
].filter((gate) => gate.limit > 0);
let limitDecision: RateLimitDecision;
if (d.rateLimitAll) limitDecision = await d.rateLimitAll(gates);
else if (d.rateLimit) {
// Compatibility for external runtime assemblers that implement only the legacy single gate callback.
limitDecision = { limited: false };
for (const gate of gates) {
if (await d.rateLimit(gate.bucket, gate.limit, gate.windowSec)) {
limitDecision = { limited: true, bucket: gate.bucket, retryAfterMs: gate.windowSec * 1000 };
break;
}
}
} else limitDecision = localRateLimiter.consumeAll(gates);
if (limitDecision.limited) {
const gate = gates.find((candidate) => candidate.bucket === limitDecision.bucket) ?? gates[0]!;
const level = gate.bucket.startsWith('provider:') ? 'provider' : 'tool';
return {
ok: false,
text: `工具 ${name} 达到${level === 'tool' ? '单工具' : '工具源总'}限额(该工具源下的用户与会话共享)。调用未派发;等待后通过原 invocation_id 恢复,不要创建新调用。`,
status: 429,
governance_state: 'rejected_before_dispatch',
auto_retry_allowed: true,
retry_after_ms: limitDecision.retryAfterMs ?? gate.windowSec * 1000,
rate_limit: { level, count: gate.limit, window_sec: gate.windowSec, scope: 'tool_provider_shared', source: level === 'tool' ? effective.source : 'provider_total' },
};
}
// 闸2:风险闸 + 审批车道(B 方案:先撤再来)。high / confirm-required 的调用:
// 有"已批准未消费"的同快照单 → 原子消费后放行;没有 → 开 pending 单推审批人,本次调用拒绝(任务正常收尾,批准后自动重跑)。
// 批准按 job+tool+args_hash 精确匹配——批的是"那一次调用",重跑换参数/换动作 = 重新走审批。
let approvalId: number | null = null;
const confirmCheck = firstConfirmHit(t, callArgs);
if (confirmCheck.error) {
await d.audit('tool_blocked', { tool: name, scope: t.scope, reason: confirmCheck.error }).catch(() => undefined);
return { ok: false, text: `工具 ${name} 的审批条件参数类型不符合声明,调用已拒绝。请按工具参数 schema 重新传参。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
const confirmHit = confirmCheck.hit;
const needsApproval = t.risk === 'high' || t.confirmRequired || !!confirmHit;
if (d.taskControl) await d.taskControl.prepare({ tool: name, argsHash: argsHash(callArgs),
readonly: t.readonly, approvalRequired: needsApproval });
if (needsApproval) {
if (!d.approvals) { // 未接审批车道(理论不发生,server 总会注入):一律拦
await d.audit('tool_blocked', { tool: name, scope: t.scope, reason: approvalReason(t, confirmHit) }).catch(() => undefined);
return { ok: false, text: `工具 ${name} 属于需人工确认的高风险操作,当前不允许自动执行。请告知用户走人工流程。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
const hash = argsHash(callArgs);
if (d.taskControl && !d.approvals.peekApproved) throw new AgentTaskControlError('TASK_UNSUPPORTED');
approvalId = d.taskControl ? await d.approvals.peekApproved!(name, hash) : await d.approvals.consumeApproved(name, hash);
if (approvalId === null) {
const pendingId = await d.approvals.findPending(name, hash);
if (pendingId !== null) {
return { ok: false, text: `该操作已有待审批单(编号 ${pendingId}),请勿重复提交。`, status: 0, governance_state: 'awaiting_approval', auto_retry_allowed: false, approval_id: pendingId };
}
// 参数漂移纠偏:同工具已有"已批准未消费"单但参数对不上(大脑重跑没按原样发,如 {id:25} 漂成 {name:"周杰伦"})。
// 不开新单空转审批——把批准的精确参数怼回去让大脑当场重发;安全零降级:执行仍只认精确匹配的原子消费。
const drifted = await d.approvals.findApprovedAnyArgs(name);
if (drifted) {
await d.audit('tool_args_drift', { tool: name, approval_id: drifted.id, got_args: JSON.stringify(callArgs).slice(0, 500) }).catch(() => undefined);
return { ok: false, text: `该工具已有人工批准的调用(审批单 ${drifted.id}),但你本次的参数与批准的不一致。请使用原审批快照续执行,不要更改参数。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
const argsJson = normalized.encoded;
const reason = approvalReason(t, confirmHit);
const summary = t.confirmPrompt ? truncateUtf8(t.confirmPrompt.replace(/\{(\w+)\}/g, (_, k) => String(callArgs[k] ?? `{${k}}`)), 512) : undefined;
const snap: ApprovalIntentSnap = {
tool: name, scope: t.scope, risk: t.risk, policy: approvalPolicy(t, confirmHit), reason,
method: t.method, path: t.path, args_json: argsJson, args_hash: hash,
...(summary ? { summary } : {}),
...(confirmHit ? { confirm_when: confirmHit } : {}),
};
const id = await d.approvals.create(snap);
await d.audit('tool_approval_pending', { approval_id: id, tool: name, scope: t.scope, risk: t.risk, confirm_required: t.confirmRequired, ...(confirmHit ? { confirm_when: confirmHit } : {}) }).catch(() => undefined);
await d.approvals.notify(id, approvalNotificationSnapshot(snap)).catch(() => undefined);
const continuation = d.approvals.continuationMode === 'resume'
? '批准后请用本 invocation_id 续执行;中枢不会启动模型重跑。'
: '批准后系统会自动重跑原任务。';
return { ok: false, text: `工具 ${name} 属于需人工审批的操作,已提交审批单(编号 ${id})。${continuation}`, status: 0, governance_state: 'awaiting_approval', auto_retry_allowed: false, approval_id: id };
}
// 走到这 = 批准单已消费,放行执行(审计带 approval_id 闭环留痕)
}
// 组装请求:path/header/query/body 各归其位;GET 未标位置的参数仍回落 query。
const query = new URLSearchParams();
const bodyObj: Record<string, unknown> = {};
const headers: Record<string, string> = {};
let path = t.path;
for (const [k, v] of Object.entries(callArgs)) {
if (v === undefined || v === null) continue;
const where = t.paramIn[k] ?? (t.method === 'GET' ? 'query' : 'body');
if (where === 'path') path = path.replace(new RegExp(`\\{${escapeRegExp(k)}\\}`, 'g'), encodeURIComponent(String(v)));
else if (where === 'header') headers[k] = String(v);
else if (where === 'query') query.set(k, String(v));
else bodyObj[k] = v;
}
const qs = query.toString();
const pathWithQuery = path + (qs ? `?${qs}` : '');
const body = t.method === 'GET' ? '' : JSON.stringify(bodyObj);
const ts = Math.floor(Date.now() / 1000);
// 工具调用签名(把 On-Behalf-Of + Job-Id 钉进 HMAC)。签名材料里的主体/任务必须与下面实际发出的头逐字一致(同源)。
const subjectHeader = d.onBehalfOf || '';
const sigHeader = signToolCall(d.provider.secret, ts, t.method, pathWithQuery, body, subjectHeader, d.jobId); // 已含 sha256= 前缀
// 闸4:审计 fail-closed——账记不下来就不放行。ACC audit.sensitive 工具强制只记键名(优先级高于工具源 log_payload)。
// logFull 时入参全量入审计(封顶 AUDIT_MAX_BYTES,超出记 args_truncated;真实字节数 args_bytes 始终记)。
const logFull = d.provider.log_payload && !t.sensitive;
const argsStr = normalized.encoded;
const argsBytes = normalized.bytes;
const argsLog = logFull
? { args: truncateUtf8(argsStr, AUDIT_MAX_BYTES), args_bytes: argsBytes, ...(argsBytes > AUDIT_MAX_BYTES ? { args_truncated: true } : {}) }
: { args: JSON.stringify(Object.keys(callArgs)) };
await d.audit('tool_call', {
provider: d.provider.name, tool: name, scope: t.scope, method: t.method, path: t.path,
on_behalf_of: d.onBehalfOf || null, signature_scheme: 'sha256=', idempotency_key: idemKey || null, ...argsLog,
...(approvalId !== null ? { approval_id: approvalId } : {}),
});
if (d.beforeDispatch) await d.beforeDispatch();
if (d.taskControl) {
const fresh = await d.taskControl.grant({ tool: name, argsHash: idemHash, scope: t.scope,
idempotencyKey: idemKey, ...(approvalId !== null ? { approvalId } : {}) });
if (!fresh) {
const existing = await d.idempotency!.get(name, idemHash);
if (existing?.state === 'completed') return { ok: existing.ok, text: existing.text, status: existing.status };
return { ok: false, text: 'The original task invocation already owns a dispatch attempt. Inspect the original invocation; do not replay it.',
status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
} else if (idemHash) {
let reserved: { inserted: boolean; entry: ToolExecutionJournalEntry };
try {
reserved = await d.idempotency!.reserve(name, t.scope, idemHash, idemKey);
} catch (error) {
await d.audit('tool_blocked', {
tool: name,
scope: t.scope,
reason: '执行日志不可用,请求未发出',
error: String(error).slice(0, 500),
}).catch(() => undefined);
return { ok: false, text: `工具 ${name} 的执行日志当前不可用,请求未发出。`, status: 0, governance_state: 'rejected_before_dispatch', auto_retry_allowed: false };
}
if (!reserved.inserted) {
if (reserved.entry.state === 'completed') {
await d.audit('tool_call_deduped', {
tool: name,
scope: t.scope,
status: reserved.entry.status,
idempotency_key: reserved.entry.idempotencyKey,
reason: '并发调用已完成,返回已确认结果',
}).catch(() => undefined);
return { ok: reserved.entry.ok, text: reserved.entry.text, status: reserved.entry.status };
}
executionUncertainty = toolExecutionUncertaintyFromJournal(name, t.scope, reserved.entry);
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: reserved.entry.state,
idempotency_key: reserved.entry.idempotencyKey,
reason: reserved.entry.error ?? '并发或恢复路径检测到未完成执行日志',
}).catch(() => undefined);
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
}
const started = Date.now();
const timeoutMs = t.timeoutMs || d.provider.timeout_ms || 10000; // ACC execution.timeout_ms 单工具覆盖(慢接口放宽),缺省工具源超时
let status = 0; let fullText = '';
let responseBytes = 0;
let responseContentType = '';
try {
const r = await fetch(d.provider.base_url.replace(/\/+$/, '') + pathWithQuery, {
method: t.method,
headers: {
// ACC 声明的普通业务头先放;下面的治理头由中枢最后覆盖。
// validateToolArguments 仍会显式拒绝保留头,这里是第二道防线。
...headers,
...(body ? { 'content-type': 'application/json' } : {}),
'x-bailing-timestamp': String(ts),
'x-bailing-signature': sigHeader,
'x-bailing-job-id': d.jobId,
'x-bailing-client': d.clientAppId,
...(idemKey ? { 'x-bailing-idempotency-key': idemKey } : {}),
...(subjectHeader ? { 'x-bailing-on-behalf-of': subjectHeader } : {}),
// 来源会话坐标(非签名材料):业务侧自审批批准后据此 /send 回流到原会话;收件人权威仍以已验签的 on-behalf-of 为准
...(d.conversation ? { 'x-bailing-conversation': d.conversation } : {}),
'x-bailing-tool-scope': t.scope,
},
body: body || undefined,
signal: AbortSignal.timeout(timeoutMs),
});
status = r.status;
responseContentType = r.headers?.get?.('content-type') ?? '';
const responseBody = await readResponseBody(r, responseMaxBytes);
responseBytes = responseBody.byteLength;
fullText = new TextDecoder().decode(responseBody);
} catch (e) {
const error = e instanceof Error && e.name === 'TimeoutError' ? `超时(${timeoutMs}ms)` : String(e);
fullText = `调用结果不确定:${error}`;
if (d.taskControl) await d.taskControl.settle('unknown').catch(() => undefined);
if (idemHash) {
await d.idempotency!.markUncertain(name, idemHash, error).catch(() => undefined);
executionUncertainty = {
state: 'uncertain',
tool: name,
scope: t.scope,
idempotency_key: idemKey,
reason: error,
message: `工具 ${name} 的请求可能已经到达业务系统,但未取得可信响应。为避免重复业务后果,本任务已禁止自动重试;请使用幂等键 ${idemKey} 人工对账。`,
};
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: 'uncertain',
idempotency_key: idemKey,
reason: error,
}).catch(() => undefined);
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
// fetch (including response body consumption) has already been attempted. Readonly or
// declared-idempotent tools do not use the execution journal, but a missing response
// still cannot prove that dispatch was rejected. Agent invocations persist this state
// and resume the original result instead of dispatching a replacement call.
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: 'uncertain',
reason: error,
}).catch(() => undefined);
return {
ok: false,
text: `工具 ${name} 的请求已尝试发出,但未取得可信响应,执行结果未知。禁止自动重试或新建调用;请恢复原调用,必要时人工核实结果。`,
status: 0,
governance_state: 'reconciliation_required',
auto_retry_allowed: false,
};
}
// 回流给模型的:受上下文预算 truncateBytes 截断(与审计留存解耦)
const text = modelFacingToolResponse(fullText, status, d.truncateBytes, responseContentType);
const ok = status >= 200 && status < 300;
const retText = text || truncateUtf8(`(HTTP ${status} 空响应)`, d.truncateBytes);
if (idemHash && !d.taskControl) {
try {
await d.idempotency!.recordResponse(name, idemHash, { ok, status, text: retText });
} catch (error) {
executionUncertainty = {
state: 'uncertain',
tool: name,
scope: t.scope,
idempotency_key: idemKey,
reason: `响应已收到但执行日志写入失败:${String(error).slice(0, 300)}`,
message: `工具 ${name} 已收到业务响应,但未能持久化执行结果。为避免重复业务后果,本任务已禁止自动重试;请使用幂等键 ${idemKey} 人工对账。`,
};
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: 'uncertain',
idempotency_key: idemKey,
reason: executionUncertainty.reason,
}).catch(() => undefined);
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
}
// 审计留存:logFull 记全量响应(封顶 AUDIT_MAX_BYTES,超出记 resp_truncated);resp_bytes 始终记真实字节数。
const respLog = logFull
? { resp: truncateUtf8(fullText, AUDIT_MAX_BYTES), resp_bytes: responseBytes, ...(Buffer.byteLength(fullText, 'utf8') > AUDIT_MAX_BYTES ? { resp_truncated: true } : {}) }
: { resp: `<${responseBytes} bytes>`, resp_bytes: responseBytes };
try {
await d.audit('tool_result', {
tool: name,
status,
ok,
duration_ms: Date.now() - started,
idempotency_key: idemKey || null,
...respLog,
});
} catch (error) {
if (d.taskControl) {
await d.taskControl.settle('confirmed_dispatched', { ok, status, text: retText }, true).catch(() => undefined);
executionUncertainty = { state: 'evidence_degraded', tool: name, scope: t.scope, idempotency_key: idemKey,
reason: 'The business response was received, but its audit evidence could not be completed.',
message: 'The original operation may have completed; inspect its original receipt. Do not submit a replacement operation.' };
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
if (idemHash) {
const reason = `业务响应已收到,但结果审计写入失败:${String(error).slice(0, 300)}`;
await d.idempotency!.markEvidenceDegraded(name, idemHash, reason).catch(() => undefined);
executionUncertainty = {
state: 'evidence_degraded',
tool: name,
scope: t.scope,
idempotency_key: idemKey,
reason,
message: `工具 ${name} 已产生业务响应,但审计证据不完整。为避免重复业务后果,本任务已禁止自动重试;请使用幂等键 ${idemKey} 人工对账。`,
};
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
throw error;
}
if (d.taskControl) {
try { await d.taskControl.settle('confirmed_dispatched', { ok, status, text: retText }); }
catch {
executionUncertainty = { state: 'uncertain', tool: name, scope: t.scope, idempotency_key: idemKey,
reason: 'The response was received but task settlement was not confirmed.',
message: 'Task settlement is unconfirmed. Inspect the original invocation; do not repeat the business operation.' };
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
} else if (idemHash) {
try {
await d.idempotency!.complete(name, idemHash);
} catch (error) {
executionUncertainty = {
state: 'response_recorded',
tool: name,
scope: t.scope,
idempotency_key: idemKey,
reason: `响应与审计已记录,但执行日志未能确认完成:${String(error).slice(0, 300)}`,
message: `工具 ${name} 已产生业务响应,但执行日志未完成最终确认。为避免重复业务后果,本任务已禁止自动重试;请使用幂等键 ${idemKey} 人工对账。`,
};
await d.audit('tool_reconciliation_required', {
tool: name,
scope: t.scope,
state: 'response_recorded',
idempotency_key: idemKey,
reason: executionUncertainty.reason,
}).catch(() => undefined);
return { ok: false, text: executionUncertainty.message, status: 0, governance_state: 'reconciliation_required', auto_retry_allowed: false };
}
}
return { ok, text: retText, status, governance_state: ok ? 'executed' : 'business_rejected', auto_retry_allowed: false };
},
};
}
export function toolExecutionUncertaintyFromJournal(
tool: string,
scope: string,
entry: ToolExecutionJournalEntry,
): ToolExecutionUncertainty {
const state = entry.state === 'completed' ? 'response_recorded' : entry.state;
const reason = entry.error ?? `检测到执行日志状态 ${state}`;
return {
state,
tool,
scope,
idempotency_key: entry.idempotencyKey,
reason,
message: `工具 ${tool} 已存在未完成的执行记录(${state})。系统不能确认是否已经产生业务后果,因此禁止自动重放;请使用幂等键 ${entry.idempotencyKey} 人工对账。`,
};
}
function hasConditionalConfirm(t: ToolDefinition): boolean {
return Array.isArray(t.confirmWhen) && t.confirmWhen.length > 0;
}
function approvalReason(t: ToolDefinition, hit: ConfirmHit | null): string {
if (t.risk === 'high') return 'risk=high';
if (t.confirmRequired) return 'confirm-required';
return hit?.reason ?? 'confirm-when';
}
function approvalPolicy(t: ToolDefinition, hit: ConfirmHit | null): string {
if (t.risk === 'high') return 'risk_high';
if (t.confirmRequired) return 'confirm_required';
if (hit) return 'confirm_when';
return 'unknown';
}
export interface ConfirmHit {
param: string;
op: string;
value?: unknown;
actual?: unknown;
reason: string;
}
interface ConfirmCheck {
hit: ConfirmHit | null;
error?: string;
}
function firstConfirmHit(tool: ToolDefinition, args: Record<string, unknown>): ConfirmCheck {
for (const cond of tool.confirmWhen ?? []) {
const result = matchConfirmCondition(cond, args, tool.inputSchema);
if (result && 'error' in result) return { hit: null, error: result.error };
if (result) return { hit: result };
}
return { hit: null };
}
function matchConfirmCondition(
cond: ToolConfirmCondition,
args: Record<string, unknown>,
inputSchema: Record<string, unknown>,
): ConfirmHit | { error: string } | null {
const actual = valueAtPath(args, cond.param);
const exists = actual !== undefined && actual !== null;
const schema = schemaAtPath(inputSchema, cond.param);
if (!schema) return { error: `confirm_when_missing_schema:${cond.param}` };
if (!schemaTypes(schema).length) return { error: `confirm_when_untyped_schema:${cond.param}` };
if (actual !== undefined && !valueMatchesSchemaType(actual, schema)) {
return { error: `confirm_when_argument_type:${cond.param}` };
}
const base = (): ConfirmHit => ({
param: cond.param,
op: cond.op,
...('value' in cond ? { value: cond.value } : {}),
...(exists ? { actual } : {}),
reason: cond.label || `confirm_when: ${cond.param} ${cond.op}${'value' in cond ? ` ${String(cond.value)}` : ''}`,
});
switch (cond.op) {
case 'exists': return exists ? base() : null;
case '>': return numericMatch(actual, cond.value, (a, b) => a > b, cond.param, base);
case '>=': return numericMatch(actual, cond.value, (a, b) => a >= b, cond.param, base);
case '<': return numericMatch(actual, cond.value, (a, b) => a < b, cond.param, base);
case '<=': return numericMatch(actual, cond.value, (a, b) => a <= b, cond.param, base);
case '==': return valueEquals(actual, cond.value) ? base() : null;
case '!=': return !valueEquals(actual, cond.value) ? base() : null;
case 'in': {
if (!Array.isArray(cond.value)) return { error: `confirm_when_invalid_in:${cond.param}` };
if (actual !== undefined && !cond.value.some((value) => sameJsonType(actual, value))) return { error: `confirm_when_argument_type:${cond.param}` };
return cond.value.some((value) => valueEquals(actual, value)) ? base() : null;
}
case 'contains': {
const contained = containsValue(actual, cond.value, cond.param);
if (typeof contained === 'object') return contained;
return contained ? base() : null;
}
}
return null;
}
function valueAtPath(obj: Record<string, unknown>, path: string): unknown {
let cur: unknown = obj;
for (const part of path.split('.').filter(Boolean)) {
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return cur;
}
function numericMatch(
a: unknown,
b: unknown,
cmp: (x: number, y: number) => boolean,
param: string,
onMatch: () => ConfirmHit,
): ConfirmHit | { error: string } | null {
if (a === undefined) return null;
if (typeof a !== 'number' || !Number.isFinite(a) || typeof b !== 'number' || !Number.isFinite(b)) {
return { error: `confirm_when_argument_type:${param}` };
}
return cmp(a, b) ? onMatch() : null;
}
function valueEquals(a: unknown, b: unknown): boolean {
if (a === undefined || b === undefined) return a === b;
return canonicalJson(a) === canonicalJson(b);
}
function sameJsonType(a: unknown, b: unknown): boolean {
if (a === null || b === null) return a === null && b === null;
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b);
return typeof a === typeof b;
}
function containsValue(actual: unknown, expected: unknown, param: string): boolean | { error: string } {
if (Array.isArray(actual)) return actual.some((v) => valueEquals(v, expected));
if (actual === undefined) return false;
if (typeof actual !== 'string' || typeof expected !== 'string') return { error: `confirm_when_argument_type:${param}` };
return actual.includes(expected);
}