Ctrl K
ComunidadGuías…

src/core/contracts/tool-definition.ts

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

Archivo original ↗Preguntar a KiBot
import { TOOL_USAGE_GUIDANCE_MAX_BYTES, TOOL_USAGE_GUIDANCE_MAX_CHARS, type ToolUsageGuidance } from './tool-guidance';

export type ToolSourceKind = 'openapi' | 'overlay' | 'sdk' | 'mcp' | 'manual';
export type ToolRisk = 'low' | 'medium' | 'high';
export type ToolParamLocation = 'query' | 'body' | 'path' | 'header';
export type ToolDiagnosticSeverity = 'error' | 'warning' | 'info';
export type ToolConfirmOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'in' | 'contains' | 'exists';
export type ToolRateLimitWindow = '1s' | '1m' | '1h' | '1d';
export type ToolOutcomeSideEffect = 'none' | 'read' | 'write' | 'notify' | 'external';

export const TOOL_DEFINITION_SCHEMA_VERSION = 'bailing.tool-definition.v1';
export const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;

const SOURCE_KINDS: readonly ToolSourceKind[] = ['openapi', 'overlay', 'sdk', 'mcp', 'manual'];
const RISKS: readonly ToolRisk[] = ['low', 'medium', 'high'];
const PARAM_LOCS: readonly ToolParamLocation[] = ['query', 'body', 'path', 'header'];
const CONFIRM_OPS: readonly ToolConfirmOp[] = ['>', '>=', '<', '<=', '==', '!=', 'in', 'contains', 'exists'];
const RATE_LIMIT_WINDOWS: readonly ToolRateLimitWindow[] = ['1s', '1m', '1h', '1d'];
const OUTCOME_SIDE_EFFECTS: readonly ToolOutcomeSideEffect[] = ['none', 'read', 'write', 'notify', 'external'];
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);

export interface ToolConfirmCondition {
  param: string;
  op: ToolConfirmOp;
  value?: unknown;
  label?: string;
}

export interface ToolRateLimit {
  count: number;
  window: ToolRateLimitWindow;
}

export interface ToolOutcome {
  result: string;
  sideEffect: ToolOutcomeSideEffect;
}

export interface ToolDefinition {
  schemaVersion: typeof TOOL_DEFINITION_SCHEMA_VERSION;
  name: string;
  source: ToolSourceKind;
  method: string;
  path: string;
  description: string;
  /** Only the short discovery description was shortened; separate guidance is not implied truncation. */
  descriptionTruncated?: boolean;
  /** Explicitly retrievable, bounded authored guidance; never passed to models as an inline description. */
  usageGuidance?: ToolUsageGuidance;
  scope: string;
  risk: ToolRisk;
  confirmRequired: boolean;
  rateLimitPerMin: number;
  rateLimit?: ToolRateLimit;
  requiresSubject: boolean;
  sensitive: boolean;
  readonly: boolean;
  idempotent: boolean;
  timeoutMs: number;
  confirmWhen?: ToolConfirmCondition[];
  confirmPrompt: string;
  context: string[];
  outcome?: ToolOutcome;
  extensions: Record<string, unknown>;
  inputSchema: Record<string, unknown>;
  paramIn: Record<string, ToolParamLocation>;
}

export interface ToolDiagnostic {
  severity: ToolDiagnosticSeverity;
  path: string;
  code: string;
  message: string;
  suggestion?: string;
}

export interface ToolCompileResult {
  tools: ToolDefinition[];
  diagnostics: ToolDiagnostic[];
}

/** 从工具入参 JSON Schema 中定位点路径;参数级审批只能引用已声明的业务参数。 */
export function schemaAtPath(inputSchema: unknown, path: string): Record<string, unknown> | null {
  let current: unknown = inputSchema;
  for (const part of path.split('.').filter(Boolean)) {
    if (!isRecord(current) || !isRecord(current['properties']) || !isRecord(current['properties'][part])) return null;
    current = current['properties'][part];
  }
  return isRecord(current) ? current : null;
}

/** 提取 schema 声明的 JSON 基础类型;审批条件只接受显式类型。 */
export function schemaTypes(schema: Record<string, unknown>): string[] {
  const raw = schema['type'];
  return Array.isArray(raw)
    ? raw.filter((x): x is string => typeof x === 'string')
    : typeof raw === 'string' ? [raw] : [];
}

/** 只判断 JSON 基础类型,不替业务侧代管 enum、format 等业务校验。 */
export function valueMatchesSchemaType(value: unknown, schema: Record<string, unknown>): boolean {
  const types = schemaTypes(schema);
  if (!types.length) return true;
  if (value === null) return types.includes('null') || schema['nullable'] === true;
  return types.some((type) => {
    switch (type) {
      case 'string': return typeof value === 'string';
      case 'boolean': return typeof value === 'boolean';
      case 'number': return typeof value === 'number' && Number.isFinite(value);
      case 'integer': return typeof value === 'number' && Number.isInteger(value);
      case 'array': return Array.isArray(value);
      case 'object': return isRecord(value);
      default: return false;
    }
  });
}

export function toolSummary(t: Pick<ToolDefinition, 'description'>): string {
  return t.description.split('。')[0]!.slice(0, 80);
}

export function skippedDiagnostics(diags: ToolDiagnostic[]): Array<{ path: string; reason: string; code: string }> {
  return diags
    .filter((d) => d.severity === 'error')
    .map((d) => ({ path: d.path, reason: d.message, code: d.code }));
}

export function warningDiagnostics(diags: ToolDiagnostic[]): Array<{ path: string; message: string; code: string; suggestion?: string }> {
  return diags
    .filter((d) => d.severity === 'warning')
    .map((d) => ({ path: d.path, message: d.message, code: d.code, ...(d.suggestion ? { suggestion: d.suggestion } : {}) }));
}

export function validateToolDefinition(tool: unknown, opts: { path?: string } = {}): ToolDiagnostic[] {
  const path = opts.path ?? toolDiagnosticPath(tool);
  const diagnostics: ToolDiagnostic[] = [];
  const fail = (code: string, message: string, suggestion?: string) => {
    diagnostics.push({ severity: 'error', path, code, message, ...(suggestion ? { suggestion } : {}) });
  };
  const warn = (code: string, message: string, suggestion?: string) => {
    diagnostics.push({ severity: 'warning', path, code, message, ...(suggestion ? { suggestion } : {}) });
  };

  if (!isRecord(tool)) {
    fail('invalid_tool_definition', 'ToolDefinition 必须是对象', '请检查输入编译器输出,确保每个工具都是完整对象');
    return diagnostics;
  }

  if (tool['schemaVersion'] !== TOOL_DEFINITION_SCHEMA_VERSION) fail('invalid_schema_version', `schemaVersion 必须是 ${TOOL_DEFINITION_SCHEMA_VERSION}`, '请升级输入编译器或显式转换到当前 ToolDefinition 版本');
  if (typeof tool['name'] !== 'string' || !TOOL_NAME_RE.test(tool['name'])) fail('invalid_name', '工具名必须是 1~64 位机器可读标识,且以字母或下划线开头', '优先使用稳定 operationId,避免路径变化导致工具名变化');
  if (!SOURCE_KINDS.includes(tool['source'] as ToolSourceKind)) fail('invalid_source', 'source 必须是 openapi / overlay / sdk / mcp / manual 之一');
  if (typeof tool['method'] !== 'string' || !METHODS.has(tool['method'])) fail('invalid_method', 'method 必须是大写 HTTP 方法');
  if (typeof tool['path'] !== 'string' || !tool['path'].startsWith('/')) fail('invalid_path', 'path 必须以 / 开头');
  if (typeof tool['description'] !== 'string' || !tool['description'].trim()) fail('invalid_description', 'description 必须是非空字符串', '补充面向 AI 的一句话用途说明');
  if (tool['descriptionTruncated'] !== undefined && typeof tool['descriptionTruncated'] !== 'boolean') fail('invalid_description_truncated', 'descriptionTruncated 必须是 boolean');
  if (tool['usageGuidance'] !== undefined) {
    const guidance = tool['usageGuidance'];
    if (!isRecord(guidance) || typeof guidance['text'] !== 'string' || !guidance['text'].trim()
      || typeof guidance['truncated'] !== 'boolean' || !Number.isSafeInteger(guidance['sourceChars']) || Number(guidance['sourceChars']) < 0
      || !Number.isSafeInteger(guidance['sourceBytes']) || Number(guidance['sourceBytes']) < 0) {
      fail('invalid_usage_guidance', 'usageGuidance 必须包含 text / truncated / sourceChars / sourceBytes');
    } else {
      const retainedChars = Array.from(guidance['text']).length;
      const retainedBytes = Buffer.byteLength(guidance['text'], 'utf8');
      const sourceChars = Number(guidance['sourceChars']);
      const sourceBytes = Number(guidance['sourceBytes']);
      if (retainedChars > TOOL_USAGE_GUIDANCE_MAX_CHARS || retainedBytes > TOOL_USAGE_GUIDANCE_MAX_BYTES
        || sourceChars < retainedChars || sourceBytes < retainedBytes
        || (!guidance['truncated'] && (sourceChars !== retainedChars || sourceBytes !== retainedBytes))
        || (guidance['truncated'] && (sourceChars === retainedChars || sourceBytes === retainedBytes))) {
        fail('invalid_usage_guidance', 'usageGuidance 超过大小限制或截断状态与原始长度不一致');
      }
    }
  }
  if (typeof tool['scope'] !== 'string' || !tool['scope'].trim()) fail('invalid_scope', 'scope 必须是非空字符串', '为工具声明稳定权限标签,例如 tenant.staff.read');
  if (!RISKS.includes(tool['risk'] as ToolRisk)) fail('invalid_risk', 'risk 必须是 low / medium / high 之一');

  for (const key of ['confirmRequired', 'requiresSubject', 'sensitive', 'readonly', 'idempotent']) {
    if (typeof tool[key] !== 'boolean') fail('invalid_boolean', `${key} 必须是 boolean`);
  }
  if (!Number.isInteger(tool['rateLimitPerMin']) || Number(tool['rateLimitPerMin']) < 0) fail('invalid_rate_limit', 'rateLimitPerMin 必须是非负整数');
  if (tool['rateLimit'] !== undefined) {
    if (!isRecord(tool['rateLimit'])) {
      fail('invalid_rate_limit', 'rateLimit 必须是对象');
    } else {
      if (!Number.isInteger(tool['rateLimit']['count']) || Number(tool['rateLimit']['count']) <= 0) fail('invalid_rate_limit_count', 'rateLimit.count 必须是正整数');
      if (!RATE_LIMIT_WINDOWS.includes(tool['rateLimit']['window'] as ToolRateLimitWindow)) fail('invalid_rate_limit_window', 'rateLimit.window 必须是 1s / 1m / 1h / 1d');
    }
  }
  const timeoutMs = Number(tool['timeoutMs']);
  if (!Number.isInteger(tool['timeoutMs']) || timeoutMs < 0 || timeoutMs > 600000) {
    fail('invalid_timeout', 'timeoutMs 必须是 0(继承工具源默认值)或 1~600000 的整数');
  }
  if (typeof tool['confirmPrompt'] !== 'string') fail('invalid_confirm_prompt', 'confirmPrompt 必须是字符串');
  if (tool['confirmWhen'] !== undefined) {
    if (!Array.isArray(tool['confirmWhen'])) {
      fail('invalid_confirm_when', 'confirmWhen 必须是数组');
    } else {
      tool['confirmWhen'].forEach((cond, i) => {
        if (!isRecord(cond)) { fail('invalid_confirm_when', `confirmWhen[${i}] 必须是对象`); return; }
        if (typeof cond['param'] !== 'string' || !cond['param'].trim()) fail('invalid_confirm_when_param', `confirmWhen[${i}].param 必须是非空字符串`);
        if (!CONFIRM_OPS.includes(cond['op'] as ToolConfirmOp)) fail('invalid_confirm_when_op', `confirmWhen[${i}].op 不支持`);
        if (cond['label'] !== undefined && typeof cond['label'] !== 'string') fail('invalid_confirm_when_label', `confirmWhen[${i}].label 必须是字符串`);
        const op = cond['op'] as ToolConfirmOp;
        if (op !== 'exists' && !('value' in cond)) {
          fail('invalid_confirm_when_value', `confirmWhen[${i}].value 必填`);
        }
      });
    }
  }

  if (!Array.isArray(tool['context']) || tool['context'].some((x) => typeof x !== 'string' || !x.trim())) {
    fail('invalid_context', 'context 必须是非空字符串数组');
  }
  if (tool['outcome'] !== undefined) {
    if (!isRecord(tool['outcome'])) {
      fail('invalid_outcome', 'outcome 必须是对象');
    } else {
      if (typeof tool['outcome']['result'] !== 'string' || !tool['outcome']['result'].trim()) fail('invalid_outcome_result', 'outcome.result 必须是非空字符串');
      if (!OUTCOME_SIDE_EFFECTS.includes(tool['outcome']['sideEffect'] as ToolOutcomeSideEffect)) fail('invalid_outcome_side_effect', 'outcome.sideEffect 必须是 none / read / write / notify / external');
    }
  }
  if (!isRecord(tool['extensions'])) {
    fail('invalid_extensions', 'extensions 必须是对象');
  } else {
    for (const k of Object.keys(tool['extensions'])) {
      if (!/^x-(bailing|business)-/.test(k)) warn('extension_namespace', `扩展字段 ${k} 不在 x-bailing-* / x-business-* 命名空间内`, '业务私有扩展建议使用 x-business-*,框架扩展使用 x-bailing-*');
    }
  }

  if (!isRecord(tool['inputSchema'])) {
    fail('invalid_input_schema', 'inputSchema 必须是 JSON Schema 对象');
  } else {
    if (tool['inputSchema']['type'] !== 'object') fail('invalid_input_schema', 'inputSchema.type 必须是 object');
    if (!isRecord(tool['inputSchema']['properties'])) fail('invalid_input_schema_properties', 'inputSchema.properties 必须是对象');
    const props = isRecord(tool['inputSchema']['properties']) ? tool['inputSchema']['properties'] : {};
    const required = tool['inputSchema']['required'];
    if (required !== undefined && (!Array.isArray(required) || required.some((x) => typeof x !== 'string'))) {
      fail('invalid_input_schema_required', 'inputSchema.required 必须是字符串数组');
    } else if (Array.isArray(required)) {
      const seen = new Set<string>();
      for (const k of required) {
        if (seen.has(k)) fail('duplicate_required_param', `required 参数 ${k} 重复声明`);
        seen.add(k);
        if (!(k in props)) fail('required_param_missing_schema', `required 参数 ${k} 未在 properties 中定义 schema`, 'required 中的每个参数都必须有 schema,避免 AI 看到不可填的必填项');
      }
    }
  }

  if (Array.isArray(tool['confirmWhen']) && isRecord(tool['inputSchema'])) {
    tool['confirmWhen'].forEach((cond, i) => {
      if (!isRecord(cond) || typeof cond['param'] !== 'string' || !cond['param'].trim()) return;
      const schema = schemaAtPath(tool['inputSchema'], cond['param']);
      if (!schema) {
        fail('confirm_when_param_missing_schema', `confirmWhen[${i}].param ${cond['param']} 未在 inputSchema.properties 中声明`, '参数级确认只能引用工具已声明的业务参数;请补充参数 schema 或修正 param 点路径');
        return;
      }
      const op = cond['op'] as ToolConfirmOp;
      const types = schemaTypes(schema);
      if (!types.length) {
        fail('confirm_when_param_untyped', `confirmWhen[${i}].param ${cond['param']} 缺少可比较的 JSON Schema type`, '为条件参数声明明确 type,避免字符串/数字/布尔值被隐式混用');
        return;
      }
      if (op === 'exists') return;
      if (['>', '>=', '<', '<='].includes(op)) {
        if (!(types.includes('number') || types.includes('integer')) || !valueMatchesSchemaType(cond['value'], { type: types.includes('integer') ? 'integer' : 'number' })) {
          fail('confirm_when_numeric_type', `confirmWhen[${i}] 的 ${op} 仅支持 number / integer 参数和值`, '金额、数量等阈值参数应声明为 number 或 integer,并使用 JSON 数字 value');
        }
        return;
      }
      if (op === 'in') {
        if (!Array.isArray(cond['value']) || cond['value'].some((value) => !valueMatchesSchemaType(value, schema))) {
          fail('confirm_when_in_type', `confirmWhen[${i}].value 必须是与参数 ${cond['param']} 类型一致的数组`, '示例:string 参数用 ["pending","blocked"];number 参数用 [1,2]');
        }
        return;
      }
      if (op === 'contains') {
        const value = cond['value'];
        if (types.includes('string') && typeof value !== 'string') {
          fail('confirm_when_contains_type', `confirmWhen[${i}] 的字符串 contains 条件 value 必须是 string`, '字符串参数示例:{ "param":"reason", "op":"contains", "value":"敏感" }');
        } else if (!types.includes('string') && !types.includes('array')) {
          fail('confirm_when_contains_type', `confirmWhen[${i}] 的 contains 仅支持 string 或 array 参数`, '请改用 == / in,或把参数声明为 string / array');
        }
        return;
      }
      if (!valueMatchesSchemaType(cond['value'], schema)) {
        fail('confirm_when_value_type', `confirmWhen[${i}].value 与参数 ${cond['param']} 的 JSON Schema type 不一致`, '条件 value 必须与业务参数使用同一种 JSON 类型,不进行字符串强转');
      }
    });
  }

  if (!isRecord(tool['paramIn'])) {
    fail('invalid_param_in', 'paramIn 必须是对象');
  } else {
    const props = isRecord(tool['inputSchema']) && isRecord(tool['inputSchema']['properties']) ? tool['inputSchema']['properties'] : {};
    for (const [k, v] of Object.entries(tool['paramIn'])) {
      if (!PARAM_LOCS.includes(v as ToolParamLocation)) fail('invalid_param_location', `参数 ${k} 的位置必须是 query / body / path / header 之一`);
      if (!(k in props)) fail('param_location_without_schema', `paramIn.${k} 没有对应 inputSchema.properties.${k}`, '每个可传参数都必须同时有位置和 schema');
    }
    const placeholders = pathPlaceholders(typeof tool['path'] === 'string' ? tool['path'] : '');
    for (const k of placeholders) {
      if (tool['paramIn'][k] !== 'path') fail('path_param_missing', `路径参数 ${k} 必须在 paramIn 中声明为 path`, 'OpenAPI path 参数必须有 parameters schema,Overlay/MCP 输入也要显式补齐');
    }
    for (const [k, v] of Object.entries(tool['paramIn'])) {
      if (v === 'path' && !placeholders.includes(k)) warn('path_param_not_in_path', `参数 ${k} 声明为 path,但 path 中没有 {${k}} 占位`, '确认它是否应改为 query/header/body');
    }
  }

  return diagnostics;
}

export function hasToolDefinitionErrors(diags: ToolDiagnostic[]): boolean {
  return diags.some((d) => d.severity === 'error');
}

function toolDiagnosticPath(tool: unknown): string {
  if (!isRecord(tool)) return '-';
  const method = typeof tool['method'] === 'string' ? tool['method'] : '';
  const path = typeof tool['path'] === 'string' ? tool['path'] : '';
  if (method && path) return `${method} ${path}`;
  return typeof tool['name'] === 'string' ? tool['name'] : '-';
}

function pathPlaceholders(path: string): string[] {
  return [...path.matchAll(/\{([^{}]+)\}/g)].map((m) => m[1]!).filter(Boolean);
}

function isRecord(v: unknown): v is Record<string, unknown> {
  return !!v && typeof v === 'object' && !Array.isArray(v);
}