Ctrl K
ComunidadGuías…

lib/agent-tool-profile.ts

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

Archivo original ↗Preguntar a KiBot
import type { AstrailToolProfile, McpActionLevel, McpTool, McpToolAnnotations, McpToolPolicy, OpenApiEndpoint } from "./types";

function titleFromName(name: string) {
  return name
    .replace(/[_-]+/g, " ")
    .replace(/\s+/g, " ")
    .trim()
    .replace(/\b\w/g, (char) => char.toUpperCase());
}

function methodRisk(method?: string): AstrailToolProfile["risk"] {
  const normalized = method?.toUpperCase();
  if (!normalized || normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS") return "read";
  if (normalized === "DELETE") return "destructive";
  return "write";
}

const ACTION_LEVELS = new Set<McpActionLevel>(["read", "draft", "write", "send", "destructive"]);
// "send" is outward-facing communication (email, SMS, notifications) — the
// action a human most often wants gated separately from ordinary writes.
const SEND_NAME_PATTERN = /(^|[_\-/])(send|publish|dispatch|broadcast|notify|email|sms|announce|reply|invite)([_\-/]|$|s\b)/i;
const DRAFT_NAME_PATTERN = /(^|[_\-/])(draft|preview|dry[_-]?run|validate|estimate|quote)([_\-/]|$|s\b)/i;

function explicitActionLevel(tool: Pick<McpTool, "metadata" | "x_astrail">): McpActionLevel | null {
  const candidates = [
    tool.x_astrail?.action_level,
    tool.metadata && typeof tool.metadata === "object" ? (tool.metadata as Record<string, unknown>).action_level : undefined,
  ];
  for (const candidate of candidates) {
    if (typeof candidate === "string" && ACTION_LEVELS.has(candidate as McpActionLevel)) {
      return candidate as McpActionLevel;
    }
  }
  return null;
}

export function toolActionLevel(
  tool: Pick<McpTool, "name" | "method" | "path" | "metadata" | "x_astrail" | "annotations">,
  endpoint?: Pick<OpenApiEndpoint, "method" | "path" | "operation_id" | "operation_kind">
): McpActionLevel {
  const explicit = explicitActionLevel(tool);
  if (explicit) return explicit;

  const risk = endpoint?.operation_kind === "read" || endpoint?.operation_kind === "destructive"
    ? endpoint.operation_kind
    : methodRisk(endpoint?.method ?? tool.method);
  if (risk === "read") return "read";
  if (risk === "destructive" || tool.annotations?.destructiveHint === true) return "destructive";

  const text = [tool.name, endpoint?.operation_id, endpoint?.path ?? tool.path].filter(Boolean).join(" ");
  if (DRAFT_NAME_PATTERN.test(text)) return "draft";
  if (SEND_NAME_PATTERN.test(text)) return "send";
  return "write";
}

export function defaultToolPolicy(tool: Pick<McpTool, "method" | "annotations">, endpoint?: Pick<OpenApiEndpoint, "method" | "operation_kind">): McpToolPolicy {
  const risk = endpoint?.operation_kind ?? methodRisk(endpoint?.method ?? tool.method);
  if (tool.annotations?.destructiveHint === true || risk === "destructive") return "block";
  if (tool.annotations?.readOnlyHint === true || risk === "read") return "allow";
  return "approval";
}

function authSchemes(security: unknown) {
  if (!security) return [];
  if (Array.isArray(security)) {
    return Array.from(new Set(security.flatMap((item) => {
      if (!item || typeof item !== "object") return [];
      return Object.keys(item);
    })));
  }
  if (typeof security === "object") return Object.keys(security);
  return [String(security)];
}

function authScopes(security: unknown) {
  if (!Array.isArray(security)) return [];
  return Array.from(new Set(security.flatMap((item) => {
    if (!item || typeof item !== "object") return [];
    return Object.values(item as Record<string, unknown>).flatMap((value) =>
      Array.isArray(value) ? value.filter((scope): scope is string => typeof scope === "string") : []
    );
  })));
}

function parameterName(parameter: unknown) {
  if (!parameter || typeof parameter !== "object") return null;
  const name = (parameter as Record<string, unknown>).name;
  return typeof name === "string" ? name : null;
}

function schemaExample(schema: unknown, fallback: unknown): unknown {
  if (!schema || typeof schema !== "object") return fallback;
  const record = schema as Record<string, unknown>;
  if (record.example !== undefined) return record.example;
  if (Array.isArray(record.examples) && record.examples.length > 0) return record.examples[0];
  if (Array.isArray(record.enum) && record.enum.length > 0) return record.enum[0];
  const type = record.type;
  if (type === "integer" || type === "number") return 1;
  if (type === "boolean") return true;
  if (type === "array") return [];
  if (type === "object") return {};
  if (record.format === "date-time") return "2026-06-12T00:00:00.000Z";
  if (record.format === "date") return "2026-06-12";
  if (record.format === "email") return "user@company.com";
  return fallback;
}

function exampleArguments(endpoint: OpenApiEndpoint) {
  const args: Record<string, unknown> = {};
  const parameters = Array.isArray(endpoint.parameters) ? endpoint.parameters : [];

  for (const parameter of parameters.slice(0, 8)) {
    if (!parameter || typeof parameter !== "object") continue;
    const record = parameter as Record<string, unknown>;
    const name = parameterName(parameter);
    if (!name) continue;
    args[name] = schemaExample(record.schema, name.includes("id") ? "example_id" : "example");
  }

  if (endpoint.request_body_schema && !("body" in args)) {
    args.body = schemaExample(endpoint.request_body_schema, {});
  }

  return args;
}

function countSchemaProperties(schema: unknown, seen = new WeakSet<object>()): number {
  if (!schema || typeof schema !== "object") return 0;
  if (seen.has(schema)) return 0;
  seen.add(schema);

  const record = schema as Record<string, unknown>;
  const properties: unknown[] = record.properties && typeof record.properties === "object"
    ? Object.values(record.properties as Record<string, unknown>)
    : [];

  let nestedCount = 0;
  for (const value of properties) {
    nestedCount += countSchemaProperties(value, seen);
  }

  return properties.length + nestedCount + (record.items ? countSchemaProperties(record.items, seen) : 0);
}

function prerequisiteHints(endpoint: OpenApiEndpoint, risk: AstrailToolProfile["risk"]) {
  const hints: string[] = [];
  if (endpoint.requires_auth) hints.push("Connect provider credentials before live execution.");
  if (risk !== "read") hints.push("Ask for user confirmation before calling this write-capable tool.");
  if (endpoint.request_body_schema && countSchemaProperties(endpoint.request_body_schema) > 10) {
    hints.push("Complex request body is accepted as one compact JSON object instead of thousands of generated parameters.");
  }
  const requiredPathParams = (endpoint.path_params ?? []).map(parameterName).filter(Boolean);
  if (requiredPathParams.length > 0) {
    hints.push(`Resolve required path parameters first: ${requiredPathParams.join(", ")}.`);
  }
  return hints;
}

function agentInstructions(endpoint: OpenApiEndpoint, risk: AstrailToolProfile["risk"]) {
  const instructions = [
    `Use for ${endpoint.method} ${endpoint.path}.`,
    "Pass only documented arguments; unknown values are rejected by the hosted runtime.",
  ];

  if (endpoint.response_hints) {
    instructions.push("Read response status and error descriptions before retrying or escalating.");
  }
  if (risk === "read") {
    instructions.push("Safe for discovery and context gathering.");
  } else {
    instructions.push("Prefer dry-run or user approval when this changes external state.");
  }
  if (endpoint.request_body_schema && countSchemaProperties(endpoint.request_body_schema) > 10) {
    instructions.push("For complex schemas, pass the full request payload in the body object.");
  }

  return instructions;
}

export function toolAnnotationsForEndpoint(tool: McpTool, endpoint?: OpenApiEndpoint): McpToolAnnotations {
  const risk = methodRisk(endpoint?.method ?? tool.method);
  const method = endpoint?.method?.toUpperCase() ?? tool.method?.toUpperCase();

  return {
    title: titleFromName(tool.name),
    readOnlyHint: risk === "read",
    destructiveHint: risk === "destructive",
    idempotentHint: method === "GET" || method === "PUT" || method === "DELETE",
    openWorldHint: true,
  };
}

export function toolProfileForEndpoint(tool: McpTool, endpoint?: OpenApiEndpoint): AstrailToolProfile {
  const risk = methodRisk(endpoint?.method ?? tool.method);
  const security = endpoint?.security_requirements ?? endpoint?.security;
  const parameterCount = endpoint
    ? (Array.isArray(endpoint.parameters) ? endpoint.parameters.length : 0) + countSchemaProperties(endpoint.request_body_schema)
    : 0;
  const compressed = Boolean(endpoint?.request_body_schema && countSchemaProperties(endpoint.request_body_schema) > 10);

  return {
    method: endpoint?.method ?? tool.method,
    path: endpoint?.path ?? tool.path,
    risk,
    requires_auth: Boolean(endpoint?.requires_auth),
    auth_schemes: authSchemes(security),
    required_scopes: authScopes(security),
    action_level: toolActionLevel(tool, endpoint),
    prerequisites: endpoint ? prerequisiteHints(endpoint, risk) : [],
    agent_instructions: endpoint ? agentInstructions(endpoint, risk) : ["Use only with documented arguments."],
    response_hints: endpoint?.response_hints,
    example_arguments: endpoint ? exampleArguments(endpoint) : {},
    complexity: {
      parameter_count: parameterCount,
      body_mode: compressed ? "compact_object" : "schema",
      compressed,
    },
  };
}

export function enrichToolsForAgents(tools: McpTool[], endpoints: OpenApiEndpoint[]) {
  return tools.map((tool, index) => {
    const endpoint = endpoints[index]
      ?? endpoints.find((item) => item.tool_name === tool.name)
      ?? endpoints.find((item) => item.method === tool.method && item.path === tool.path);

    return {
      ...tool,
      policy: tool.policy ?? endpoint?.policy ?? defaultToolPolicy(tool, endpoint),
      annotations: {
        ...toolAnnotationsForEndpoint(tool, endpoint),
        ...(tool.annotations ?? {}),
      },
      x_astrail: {
        ...toolProfileForEndpoint(tool, endpoint),
        ...(tool.x_astrail ?? {}),
      },
    };
  });
}