feat(agent): add typed server API tools
This commit is contained in:
@@ -84,6 +84,9 @@ const envSchema = z
|
||||
OPENCODE_BASE_URL: z.string().url().optional(),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 本地实验环境默认开放受控领域工具;生产环境仍由下方鉴权校验保护。
|
||||
TJWATER_API_ENABLED: optionalBoolean(true),
|
||||
TJWATER_AUTH_MODE: z.enum(["disabled"]).default("disabled"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
||||
@@ -138,6 +141,13 @@ const envSchema = z
|
||||
.default(3600000),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.NODE_ENV === "production" && env.TJWATER_API_ENABLED && env.TJWATER_AUTH_MODE === "disabled") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_AUTH_MODE"],
|
||||
message: "TJWATER API tools cannot run without authentication in production",
|
||||
});
|
||||
}
|
||||
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -9,6 +9,10 @@ const timeRange = {
|
||||
start_time: isoTime.optional(),
|
||||
end_time: isoTime.optional(),
|
||||
};
|
||||
const requiredTimeRange = {
|
||||
start_time: isoTime,
|
||||
end_time: isoTime,
|
||||
};
|
||||
const locateInput = z.object({
|
||||
ids: z.array(id).min(1).max(100),
|
||||
feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]),
|
||||
@@ -16,8 +20,8 @@ const locateInput = z.object({
|
||||
const historyInput = z.object({
|
||||
feature_infos: z.array(z.tuple([id, id])).min(1).max(100),
|
||||
data_type: z.enum(["realtime", "scheme", "none"]),
|
||||
...timeRange,
|
||||
}).strict().refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
|
||||
...requiredTimeRange,
|
||||
}).strict().refine((value) => Date.parse(value.start_time) < Date.parse(value.end_time), "start_time must precede end_time");
|
||||
const scadaInput = z.object({
|
||||
device_id: id.optional(),
|
||||
device_ids: z.array(id).min(1).max(100).optional(),
|
||||
|
||||
Vendored
+1048
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,12 @@ const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
renderJunctionsPayload: "render-junctions-payload",
|
||||
serverApiPayload: "server_api_payload",
|
||||
} as const;
|
||||
|
||||
export const RESULT_REFERENCE_SOURCE = {
|
||||
agentGenerated: "agent_generated",
|
||||
serverApi: "server_api",
|
||||
} as const;
|
||||
|
||||
export type ResultReferenceKind =
|
||||
|
||||
@@ -85,6 +85,31 @@ export const registerChatAuxiliaryRoutes = (
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
chatRouter.get("/result-ref/:result_ref", async (req, res) => {
|
||||
const resultRef = req.params.result_ref?.trim();
|
||||
const context = getLocalAgentContext(req);
|
||||
const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined;
|
||||
const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0);
|
||||
const limit = Math.min(500, Math.max(1, Number.parseInt(String(req.query.limit ?? "100"), 10) || 100));
|
||||
if (!resultRef || !clientSessionId) {
|
||||
res.status(400).json({ message: "result_ref and session_id are required" });
|
||||
return;
|
||||
}
|
||||
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
|
||||
actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId,
|
||||
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
|
||||
if (!result) {
|
||||
res.status(404).json({ message: "result_ref not found" });
|
||||
return;
|
||||
}
|
||||
const rows = Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data && typeof result.data === "object" && Array.isArray((result.data as { data?: unknown }).data)
|
||||
? (result.data as { data: unknown[] }).data
|
||||
: [result.data];
|
||||
res.json({ ...result, data: rows.slice(offset, offset + limit), page: { offset, limit, total: rows.length, has_more: offset + limit < rows.length } });
|
||||
});
|
||||
|
||||
chatRouter.post("/abort", async (req, res) => {
|
||||
const parsed = abortPayloadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { ServerApiGateway } from "./serverApi/gateway.js";
|
||||
import { isDomainToolName } from "./serverApi/schemas.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
@@ -38,6 +40,7 @@ const learningOrchestrator = new LearningOrchestrator(
|
||||
);
|
||||
const resultReferenceStore = new ResultReferenceStore();
|
||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||
const serverApiGateway = new ServerApiGateway(resultReferenceResolver);
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||
const frontendActionCoordinator = new FrontendActionCoordinator();
|
||||
|
||||
@@ -47,6 +50,25 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.post("/internal/tools/server-api/:tool", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const name = req.params.tool;
|
||||
if (!isDomainToolName(name)) {
|
||||
res.status(404).json({ message: "unknown domain tool" });
|
||||
return;
|
||||
}
|
||||
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
if (!context) {
|
||||
res.status(404).json({ message: "session context not found" });
|
||||
return;
|
||||
}
|
||||
res.json(await serverApiGateway.execute(name, req.body?.args ?? {}, context));
|
||||
});
|
||||
|
||||
app.post("/internal/frontend-actions/request", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { ResultReferenceResolver } from "../results/resolver.js";
|
||||
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
|
||||
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { domainToolSchemas, type DomainToolName } from "./schemas.js";
|
||||
|
||||
export type GatewayErrorCode = "VALIDATION_FAILED" | "NOT_FOUND" | "UPSTREAM_UNAVAILABLE" | "TIMEOUT" | "PROJECT_CONTEXT_INVALID";
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
const responseSchemas: Record<DomainToolName, z.ZodTypeAny> = {
|
||||
get_project_context: z.object({
|
||||
project_id: z.string().uuid(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
status: z.enum(["active", "inactive"]),
|
||||
project_role: z.string(),
|
||||
gs_workspace: z.string(),
|
||||
map_extent: z.object({ xmin: z.number(), ymin: z.number(), xmax: z.number(), ymax: z.number() }).nullable(),
|
||||
}),
|
||||
list_monitoring_assets: z.object({ devices: z.array(z.unknown()).optional(), points: z.array(z.unknown()).optional() }),
|
||||
query_monitoring_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }),
|
||||
start_data_quality_analysis: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
start_simulation: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
get_job_status: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
};
|
||||
|
||||
export class ServerApiGateway {
|
||||
constructor(private readonly resolver: ResultReferenceResolver, private readonly fetchImpl: Fetch = fetch) {}
|
||||
|
||||
async execute(name: DomainToolName, rawArgs: unknown, context: RuntimeSessionContext) {
|
||||
if (!config.TJWATER_API_ENABLED) return failure("UPSTREAM_UNAVAILABLE", "TJWater API tools are disabled");
|
||||
if (!context.projectId || !z.string().uuid().safeParse(context.projectId).success) {
|
||||
return failure("PROJECT_CONTEXT_INVALID", "project context is unavailable or invalid");
|
||||
}
|
||||
const parsed = domainToolSchemas[name].safeParse(rawArgs);
|
||||
if (!parsed.success) return failure("VALIDATION_FAILED", "tool parameters are invalid", parsed.error.flatten());
|
||||
try {
|
||||
const data = await this.dispatch(name, parsed.data, context);
|
||||
const validated = responseSchemas[name].safeParse(data);
|
||||
if (!validated.success) return failure("UPSTREAM_UNAVAILABLE", "upstream returned an invalid response");
|
||||
const bytes = Buffer.byteLength(JSON.stringify(validated.data));
|
||||
if (bytes <= config.MAX_INLINE_RESULT_BYTES) return { ok: true, data: validated.data, truncated: false };
|
||||
const record = await this.resolver.register({
|
||||
actorKey: context.actorKey, clientSessionId: context.clientSessionId, data: validated.data,
|
||||
kind: RESULT_REFERENCE_KIND.serverApiPayload, projectId: context.projectId,
|
||||
projectKey: context.projectKey, schemaVersion: 1, sessionId: context.clientSessionId,
|
||||
source: RESULT_REFERENCE_SOURCE.serverApi, traceId: context.traceId,
|
||||
});
|
||||
return { ok: true, result_ref: record.resultRef, preview: record.preview, result_size_bytes: bytes, truncated: true };
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayHttpError) {
|
||||
if (error.status === 404) return failure("NOT_FOUND", "requested resource was not found");
|
||||
return failure("UPSTREAM_UNAVAILABLE", `upstream request failed (${error.status})`);
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") return failure("TIMEOUT", "upstream request timed out");
|
||||
return failure("UPSTREAM_UNAVAILABLE", "upstream service is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(name: DomainToolName, args: any, context: RuntimeSessionContext): Promise<unknown> {
|
||||
if (name === "list_monitoring_assets") {
|
||||
const result: Record<string, unknown> = {};
|
||||
if (args.include !== "points") result.devices = await this.request("GET", "/api/v1/monitoring/devices", undefined, context);
|
||||
if (args.include !== "devices") result.points = await this.request("GET", "/api/v1/monitoring/points", undefined, context);
|
||||
return result;
|
||||
}
|
||||
const routes = {
|
||||
get_project_context: ["GET", "/api/v1/meta/project"],
|
||||
query_monitoring_readings: ["POST", "/api/v1/monitoring/readings/query"],
|
||||
start_data_quality_analysis: ["POST", "/api/v1/analyses"],
|
||||
start_simulation: ["POST", "/api/v1/simulations"],
|
||||
get_job_status: ["GET", args.job_type === "analysis" ? `/api/v1/analyses/${args.job_id}` : `/api/v1/simulations/${args.job_id}`],
|
||||
} as const;
|
||||
const [method, path] = routes[name];
|
||||
const body = name === "start_data_quality_analysis" ? { ...args, algorithm: "data-quality" } : method === "POST" ? args : undefined;
|
||||
const idempotencyKey = name.startsWith("start_") ? createIdempotencyKey(context, name, body) : undefined;
|
||||
return this.request(method, path, body, context, idempotencyKey);
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext, idempotencyKey?: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await this.fetchImpl(new URL(path, config.TJWATER_API_BASE_URL), {
|
||||
method, signal: controller.signal, headers: {
|
||||
Accept: "application/json", "Content-Type": "application/json", "X-Project-Id": context.projectId!,
|
||||
"X-Request-Id": context.traceId, ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
}, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!response.ok) throw new GatewayHttpError(response.status);
|
||||
return await response.json();
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayHttpError extends Error { constructor(readonly status: number) { super("upstream request failed"); } }
|
||||
const failure = (code: GatewayErrorCode, message: string, detail?: unknown) => ({ ok: false, error: { code, message, ...(detail ? { detail } : {}) } });
|
||||
export const createIdempotencyKey = (context: RuntimeSessionContext, name: string, args: unknown) => createHash("sha256").update(JSON.stringify([context.clientSessionId, name, canonicalize(args)])).digest("hex");
|
||||
const canonicalize = (value: unknown): unknown => Array.isArray(value) ? value.map(canonicalize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, canonicalize(entry)])) : value;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const dateTime = z.string().datetime({ offset: true });
|
||||
const metrics = z.enum([
|
||||
"flow_mean", "flow_total", "conductivity_mean", "velocity_mean",
|
||||
"temperature_mean", "level_mean",
|
||||
]);
|
||||
|
||||
export const domainToolSchemas = {
|
||||
get_project_context: z.object({}),
|
||||
list_monitoring_assets: z.object({ include: z.enum(["devices", "points", "all"]).default("all") }),
|
||||
query_monitoring_readings: z.object({
|
||||
device_ids: z.array(uuid).min(1).max(100),
|
||||
observed_from: dateTime,
|
||||
observed_to: dateTime,
|
||||
granularity: z.enum(["5m", "1h", "1d"]).default("5m"),
|
||||
metrics: z.array(metrics).min(1).max(6),
|
||||
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
|
||||
message: "observed_from must precede observed_to",
|
||||
}),
|
||||
start_data_quality_analysis: z.object({
|
||||
device_ids: z.array(uuid).min(1).max(100),
|
||||
observed_from: dateTime,
|
||||
observed_to: dateTime,
|
||||
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
|
||||
message: "observed_from must precede observed_to",
|
||||
}),
|
||||
start_simulation: z.object({ model_version_id: uuid, parameters: z.record(z.unknown()).default({}) }),
|
||||
get_job_status: z.object({ job_id: uuid, job_type: z.enum(["analysis", "simulation"]) }),
|
||||
} as const;
|
||||
|
||||
export type DomainToolName = keyof typeof domainToolSchemas;
|
||||
export const isDomainToolName = (value: string): value is DomainToolName => value in domainToolSchemas;
|
||||
Reference in New Issue
Block a user