feat(agent): add typed server API tools

This commit is contained in:
2026-07-15 10:44:07 +08:00
parent f3f873e3c4
commit 8a21908785
30 changed files with 3119 additions and 15 deletions
+10
View File
@@ -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,
+6 -2
View File
@@ -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(),
+1048
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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 =
+25
View File
@@ -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) {
+22
View File
@@ -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" });
+103
View File
@@ -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;
+34
View File
@@ -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;