Ctrl K
ComunidadGuías…

src/core/contracts/tools.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
}