import { randomUUID } from "node:crypto"; import { config } from "../config.js"; import { logger } from "../logger.js"; const allowedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); const resultStore = new Map(); export class DynamicHttpExecutor { async execute(input, context) { const method = (input.method ?? "GET").trim().toUpperCase(); if (!allowedMethods.has(method)) { throw new Error(`unsupported method: ${method}`); } const path = input.path.trim(); if (!path.startsWith("/")) { throw new Error("path must start with '/'"); } const query = buildQuery(input.arguments ?? {}); const url = new URL(path, config.TJWATER_API_BASE_URL); for (const [key, value] of query) { url.searchParams.append(key, value); } // 这里复用 chat session 绑定的用户上下文,保持后端鉴权与项目隔离语义不变。 const headers = new Headers({ Accept: "application/json", "x-trace-id": context.traceId, }); if (context.accessToken) { headers.set("Authorization", `Bearer ${context.accessToken}`); } if (context.projectId) { headers.set("x-project-id", context.projectId); } const startedAt = Date.now(); const response = await fetch(url, { method, headers, signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS), }); const durationMs = Date.now() - startedAt; logger.info({ method, path, statusCode: response.status, durationMs, traceId: context.traceId, projectId: context.projectId, }, "dynamic_http_call completed"); const contentType = response.headers.get("content-type") ?? ""; const rawText = await response.text(); const data = contentType.includes("application/json") && rawText ? JSON.parse(rawText) : rawText; if (!response.ok) { return { ok: false, trace_id: context.traceId, upstream: { method, path, status_code: response.status, }, error: { message: "upstream API returned error", detail: data, }, }; } return { ok: true, trace_id: context.traceId, upstream: { method, path, status_code: response.status, }, ...normalizeSuccessResult(data, context), }; } getResult(resultRef) { return resultStore.get(resultRef); } } export const dynamicHttpExecutor = new DynamicHttpExecutor(); const buildQuery = (argumentsObject) => { const pairs = []; for (const [key, value] of Object.entries(argumentsObject)) { if (value === undefined || value === null) { continue; } if (Array.isArray(value)) { if (value.length === 0) { continue; } pairs.push([key, value.map(String).join(",")]); continue; } pairs.push([key, String(value)]); } return pairs; }; const normalizeSuccessResult = (data, context) => { const sizeBytes = estimateBytes(data); if (sizeBytes <= config.MAX_INLINE_RESULT_BYTES) { return { result_mode: "inline", result_size_bytes: sizeBytes, data, }; } const resultRef = `res-${randomUUID().slice(0, 16)}`; // 大结果先落本地引用,避免工具输出把模型上下文直接撑爆。 resultStore.set(resultRef, { rawResult: data, traceId: context.traceId, projectId: context.projectId, }); return { result_mode: "referenced", result_size_bytes: sizeBytes, result_ref: resultRef, preview: buildPreview(data), }; }; const estimateBytes = (data) => Buffer.byteLength(JSON.stringify(data)); const buildPreview = (data) => { if (Array.isArray(data)) { const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS); const fields = sample.length > 0 && isRecord(sample[0]) ? Object.keys(sample[0]).slice(0, 30) : []; return { count: data.length, fields, sample, summary: `list[${data.length}]`, }; } if (isRecord(data)) { const fields = Object.keys(data).slice(0, 30); const sample = Object.fromEntries(fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]])); return { count: fields.length, fields, sample, summary: `object<${fields.length} fields>`, }; } return { count: 1, fields: [], sample: String(data).slice(0, 300), summary: `scalar<${typeof data}>`, }; }; const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);