Files
TJWaterAgent/dist/tools/dynamicHttpExecutor.js
T

155 lines
5.1 KiB
JavaScript

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);