refactor(agent): 移除旧工具桥

This commit is contained in:
2026-06-04 18:02:38 +08:00
parent f4749d6e2e
commit 10c11a5254
9 changed files with 31 additions and 376 deletions
-4
View File
@@ -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."),
},
+9 -1
View File
@@ -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
-6
View File
@@ -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) {
-30
View File
@@ -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<string, unknown>) =>
.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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
-8
View File
@@ -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,
)
+1 -3
View File
@@ -36,8 +36,6 @@ type ProgressPayload = {
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
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),
});
}
};
};
+1 -92
View File
@@ -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" });
-167
View File
@@ -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<string, unknown>;
};
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<string, unknown>) => {
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));
+20 -65
View File
@@ -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",