From 10c11a5254270b86eda84ba578923c28c2a807c4 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 4 Jun 2026 18:02:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(agent):=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/tools/view_scada.ts | 4 - README.md | 10 +- src/config.ts | 6 -- src/results/resolver.ts | 30 ------ src/results/store.ts | 8 -- src/routes/chatStream.ts | 4 +- src/server.ts | 93 +---------------- src/tools/dynamicHttpExecutor.ts | 167 ------------------------------- tests/results/store.test.ts | 85 ++++------------ 9 files changed, 31 insertions(+), 376 deletions(-) delete mode 100644 src/tools/dynamicHttpExecutor.ts diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index 3c6cb21..904f363 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -11,10 +11,6 @@ export default tool({ .optional() .describe("Preferred SCADA device ids."), device_id: tool.schema.string().optional().describe("Single SCADA device id."), - feature_infos: tool.schema - .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) - .optional() - .describe("Legacy [id, type] pairs."), start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."), end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."), }, diff --git a/README.md b/README.md index 852574d..868fc7a 100644 --- a/README.md +++ b/README.md @@ -85,17 +85,25 @@ src/ ```text .opencode/tools/ tjwater_cli.ts + store_render_ref.ts locate_features.ts view_history.ts view_scada.ts show_chart.ts + render_junctions.ts + apply_layer_style.ts + memory_manager.ts + session_search.ts + skill_manager.ts ``` 这些是 opencode 可以调用的自定义工具。 `tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令。 -前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。 +`store_render_ref.ts` 用于把大型 junction 渲染 payload 存成 `render_ref`,再由 `render_junctions.ts` 交给前端回读并渲染。 + +前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart`、`render_junctions`、`apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。 ### skills diff --git a/src/config.ts b/src/config.ts index 8d9d2ea..6aaac1d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,12 +105,6 @@ const envSchema = z .int() .positive() .default(3600000), - // fetch_result_ref 默认最多返回的顶层项/字段数量。 - RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce - .number() - .int() - .positive() - .default(50), }) .superRefine((env, ctx) => { if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 95986a2..3c5730b 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,4 +1,3 @@ -import { config } from "../config.js"; import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js"; import { type ResultReferenceKind, @@ -11,7 +10,6 @@ import { type ResolveOptions = { expectedKind?: ResultReferenceKind; - maxItems?: number; }; type RegisterResultReferenceInput = { @@ -89,24 +87,6 @@ export class ResultReferenceResolver { }); } - async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) { - const record = await this.getResolvedRecord(resultRef, context, options); - if (!record) { - return null; - } - return { - ok: true, - result_ref: record.resultRef, - result_size_bytes: record.sizeBytes, - stored_at: record.createdAt, - data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS), - preview: record.preview, - kind: record.kind, - schema_version: record.schemaVersion, - source: record.source, - }; - } - async getFullAuthorized( resultRef: string, context: RetrievalContext, @@ -250,16 +230,6 @@ const normalizeStringRecord = (value: Record) => .filter(([, entry]) => entry.length > 0), ); -const projectData = (data: unknown, maxItems: number) => { - if (Array.isArray(data)) { - return data.slice(0, maxItems); - } - if (isRecord(data)) { - return Object.fromEntries(Object.entries(data).slice(0, maxItems)); - } - return data; -}; - const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/results/store.ts b/src/results/store.ts index 81530f5..a033254 100644 --- a/src/results/store.ts +++ b/src/results/store.ts @@ -17,12 +17,10 @@ export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/; const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/; export const RESULT_REFERENCE_KIND = { - dynamicHttpResult: "dynamic-http-result", renderJunctionsPayload: "render-junctions-payload", } as const; export const RESULT_REFERENCE_SOURCE = { - dynamicHttp: "dynamic_http", agentGenerated: "agent_generated", legacy: "legacy", migration: "migration", @@ -280,9 +278,6 @@ export const normalizeResultReferenceRecord = ( const normalizeResultReferenceKind = ( value: unknown, ): ResultReferenceKind | null => { - if (value === undefined) { - return RESULT_REFERENCE_KIND.dynamicHttpResult; - } return Object.values(RESULT_REFERENCE_KIND).includes( value as ResultReferenceKind, ) @@ -293,9 +288,6 @@ const normalizeResultReferenceKind = ( const normalizeResultReferenceSource = ( value: unknown, ): ResultReferenceSource | null => { - if (value === undefined) { - return RESULT_REFERENCE_SOURCE.legacy; - } return Object.values(RESULT_REFERENCE_SOURCE).includes( value as ResultReferenceSource, ) diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 7782366..f3ffdef 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -36,8 +36,6 @@ type ProgressPayload = { const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { - dynamic_http_call: "后端数据查询", - fetch_result_ref: "结果引用回读", memory_manager: "记忆写入", session_search: "历史会话检索", skill_manager: "流程沉淀", @@ -843,4 +841,4 @@ export const streamPromptResponse = async ({ totalDurationMs: Math.max(0, Date.now() - requestStartedAt), }); } -}; \ No newline at end of file +}; diff --git a/src/server.ts b/src/server.ts index b5f6ac9..07b21fe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,6 @@ import { ResultReferenceStore } from "./results/store.js"; import { buildChatRouter } from "./routes/chat.js"; import { opencodeRuntime } from "./runtime/opencode.js"; import { SessionRuntimeContextStore } from "./sessions/runtimeContextStore.js"; -import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js"; const app = express(); const sessionBridge = new ChatSessionBridge(opencodeRuntime); @@ -32,10 +31,9 @@ const learningOrchestrator = new LearningOrchestrator( ); const resultReferenceStore = new ResultReferenceStore(); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); -const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); -// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。 +// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。 process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; app.use(cors()); @@ -60,52 +58,6 @@ app.get("/health", async (_req, res) => { } }); -app.post("/internal/tools/dynamic-http-call", async (req, res) => { - if (req.header("x-agent-internal-token") !== internalToken) { - res.status(403).json({ message: "forbidden" }); - return; - } - - const sessionId = - typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; - const context = sessionId ? await sessionRuntimeContextStore.read(sessionId) : null; - if (!context) { - res.status(404).json({ - message: "session context not found", - detail: sessionId, - }); - return; - } - - try { - // opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。 - const result = await dynamicHttpExecutor.execute( - { - reason: req.body?.reason, - path: req.body?.path, - method: req.body?.method, - arguments: req.body?.arguments, - }, - { - accessToken: context.accessToken, - actorKey: context.actorKey, - clientSessionId: context.clientSessionId, - projectId: context.projectId, - projectKey: context.projectKey, - sessionId: context.clientSessionId, - traceId: context.traceId, - }, - ); - res.json(result); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - res.status(400).json({ - message: "dynamic http execution failed", - detail, - }); - } -}); - app.post("/internal/tools/tjwater-cli-call", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); @@ -209,49 +161,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { } }); -app.post("/internal/tools/fetch-result-ref", async (req, res) => { - if (req.header("x-agent-internal-token") !== internalToken) { - res.status(403).json({ message: "forbidden" }); - return; - } - - const sessionId = - typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; - const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : ""; - const context = sessionId ? await sessionRuntimeContextStore.read(sessionId) : null; - if (!context) { - res.status(404).json({ - message: "session context not found", - detail: sessionId, - }); - return; - } - if (!resultRef) { - res.status(400).json({ message: "result_ref is required" }); - return; - } - - const result = await resultReferenceResolver.getAuthorized( - resultRef, - { - actorKey: context.actorKey, - clientSessionId: context.clientSessionId, - projectId: context.projectId, - }, - { - maxItems: - typeof req.body?.max_items === "number" ? req.body.max_items : undefined, - }, - ); - - if (!result) { - res.status(404).json({ message: "result_ref not found" }); - return; - } - - res.json(result); -}); - app.post("/internal/tools/store-render-ref", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); diff --git a/src/tools/dynamicHttpExecutor.ts b/src/tools/dynamicHttpExecutor.ts deleted file mode 100644 index d002395..0000000 --- a/src/tools/dynamicHttpExecutor.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { config } from "../config.js"; -import { logger } from "../logger.js"; -import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js"; -import { ResultReferenceStore } from "../results/store.js"; - -export type DynamicHttpInput = { - reason?: string; - path: string; - method?: string; - arguments?: Record; -}; - -export type SessionToolContext = { - accessToken?: string; - actorKey: string; - clientSessionId: string; - projectKey: string; - sessionId: string; - projectId?: string; - traceId: string; -}; - -const allowedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); - -export class DynamicHttpExecutor { - constructor(private readonly resultStore: ResultReferenceStore) {} - - async execute(input: DynamicHttpInput, context: SessionToolContext) { - 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, - reason: typeof input.reason === "string" ? input.reason : undefined, - 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, - }, - ...(await normalizeSuccessResult(data, context, this.resultStore)), - }; - } -} - -const buildQuery = (argumentsObject: Record) => { - const pairs: Array<[string, string]> = []; - 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 = async ( - data: unknown, - context: SessionToolContext, - resultStore: ResultReferenceStore, -) => { - const sizeBytes = estimateBytes(data); - if (sizeBytes <= config.MAX_INLINE_RESULT_BYTES) { - return { - result_mode: "inline", - result_size_bytes: sizeBytes, - data, - }; - } - - // 大结果转成持久化引用,支持 review 和跨重启回读。 - const record = await resultStore.store({ - actorKey: context.actorKey, - clientSessionId: context.clientSessionId, - data, - kind: RESULT_REFERENCE_KIND.dynamicHttpResult, - projectId: context.projectId, - projectKey: context.projectKey, - schemaVersion: 1, - sessionId: context.sessionId, - source: RESULT_REFERENCE_SOURCE.dynamicHttp, - traceId: context.traceId, - }); - - return { - result_mode: "referenced", - result_size_bytes: sizeBytes, - result_ref: record.resultRef, - preview: record.preview, - }; -}; - -const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data)); diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts index a86424d..2afb1fb 100644 --- a/tests/results/store.test.ts +++ b/tests/results/store.test.ts @@ -26,83 +26,50 @@ describe("ResultReferenceResolver", () => { await rm(tempDir, { force: true, recursive: true }); }); - it("stores metadata for new referenced results and resolves them", async () => { + it("stores metadata for render refs and resolves them", async () => { const record = await resolver.register({ actorKey: "actor-1", clientSessionId: "client-1", - data: [{ id: "J1" }, { id: "J2" }], - kind: RESULT_REFERENCE_KIND.dynamicHttpResult, + data: { + node_area_map: { + J1: "DMA-1", + J2: "DMA-2", + }, + }, + kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, projectId: "project-1", projectKey: "project-key-1", schemaVersion: 1, sessionId: "session-1", - source: RESULT_REFERENCE_SOURCE.dynamicHttp, + source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: "trace-1", }); - expect(record.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); + expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); expect(record.schemaVersion).toBe(1); - expect(record.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp); + expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated); - const result = await resolver.getAuthorized( + const result = await resolver.getFullAuthorized( record.resultRef, { actorKey: "actor-1", projectId: "project-1", }, - { - maxItems: 1, - }, ); expect(result).not.toBeNull(); - expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); + expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); expect(result?.schema_version).toBe(1); - expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp); - expect(result?.data).toEqual([{ id: "J1" }]); - }); - - it("keeps legacy result refs readable while defaulting metadata", async () => { - const legacyRef = "res-aaaaaaaaaaaaaaaa"; - await writeFile( - join(tempDir, `${legacyRef}.json`), - JSON.stringify( - { - resultRef: legacyRef, - actorKey: "actor-legacy", - clientSessionId: "client-legacy", - createdAt: "2026-05-21T00:00:00.000Z", - data: { nodes: ["J1"] }, - preview: { - count: 1, - fields: ["nodes"], - sample: { nodes: ["J1"] }, - summary: "object<1 fields>", - }, - projectId: "project-legacy", - projectKey: "project-key-legacy", - sessionId: "session-legacy", - sizeBytes: 16, - traceId: "trace-legacy", - }, - null, - 2, - ), - "utf8", - ); - - const record = await store.getAuthorizedRecord(legacyRef, { - actorKey: "actor-legacy", - projectId: "project-legacy", + expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated); + expect(result?.data).toEqual({ + node_area_map: { + J1: "DMA-1", + J2: "DMA-2", + }, }); - - expect(record).not.toBeNull(); - expect(record?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); - expect(record?.schemaVersion).toBe(1); - expect(record?.source).toBe(RESULT_REFERENCE_SOURCE.legacy); }); - it("rejects malformed refs, mismatched kinds, and auth mismatches", async () => { + it("rejects malformed refs and auth mismatches", async () => { const malformedRef = "res-bbbbbbbbbbbbbbbb"; await writeFile( join(tempDir, `${malformedRef}.json`), @@ -152,18 +119,6 @@ describe("ResultReferenceResolver", () => { traceId: "trace-2", }); - const wrongKind = await resolver.getFullAuthorized( - renderRecord.resultRef, - { - actorKey: "actor-2", - projectId: "project-2", - }, - { - expectedKind: RESULT_REFERENCE_KIND.dynamicHttpResult, - }, - ); - expect(wrongKind).toBeNull(); - const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, { actorKey: "actor-other", projectId: "project-2",