From a27c45910c2e2af6fd617ed3b3bd8046659f1a05 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 6 May 2026 11:03:07 +0800 Subject: [PATCH] =?UTF-8?q?LLM=20=E8=AF=B7=E6=B1=82=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .opencode/agents/agent.md | 2 + .opencode/tools/dynamic_http_call.ts | 4 ++ .opencode/tools/locate_features.ts | 3 + .opencode/tools/show_chart.ts | 3 + .opencode/tools/view_history.ts | 3 + .opencode/tools/view_scada.ts | 3 + src/audit/llmRequestAudit.ts | 36 ++++++++++++ src/config.ts | 1 + src/routes/chat.ts | 85 ++++++++++++++++++++++++++++ src/server.ts | 1 + src/tools/dynamicHttpExecutor.ts | 2 + 11 files changed, 143 insertions(+) create mode 100644 src/audit/llmRequestAudit.ts diff --git a/.opencode/agents/agent.md b/.opencode/agents/agent.md index 04916dd..2725b5b 100644 --- a/.opencode/agents/agent.md +++ b/.opencode/agents/agent.md @@ -14,3 +14,5 @@ temperature: 0.2 4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。 5. 保持回复准确、简洁,对供水网络用户在操作上有用。 6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。 +7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。 +8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。 diff --git a/.opencode/tools/dynamic_http_call.ts b/.opencode/tools/dynamic_http_call.ts index f78e9e6..2570da2 100644 --- a/.opencode/tools/dynamic_http_call.ts +++ b/.opencode/tools/dynamic_http_call.ts @@ -7,6 +7,9 @@ export default tool({ description: "通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。", args: { + reason: tool.schema + .string() + .describe("Why this tool call is required for the current user request."), path: tool.schema.string().describe("Target backend API path, starting with '/'."), method: tool.schema .string() @@ -27,6 +30,7 @@ export default tool({ }, body: JSON.stringify({ sessionId: context.sessionID, + reason: args.reason, path: args.path, method: args.method, arguments: args.arguments, diff --git a/.opencode/tools/locate_features.ts b/.opencode/tools/locate_features.ts index 63e6c6e..0290b9a 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "在前端地图上定位并高亮指定的管网要素。", args: { + reason: tool.schema + .string() + .describe("Why this map positioning action is needed for the user request."), ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."), feature_type: tool.schema .enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"]) diff --git a/.opencode/tools/show_chart.ts b/.opencode/tools/show_chart.ts index d980af1..a8e3423 100644 --- a/.opencode/tools/show_chart.ts +++ b/.opencode/tools/show_chart.ts @@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "在前端对话界面中渲染图表。", args: { + reason: tool.schema + .string() + .describe("Why this chart should be rendered for the user request."), title: tool.schema.string().optional().describe("Chart title."), chart_type: tool.schema .enum(["line", "bar", "pie"]) diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index 97615f6..9d4f07f 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "为选定的管网要素打开前端的历史记录或计算结果面板。", args: { + reason: tool.schema + .string() + .describe("Why this history panel should be opened for the current task."), feature_infos: tool.schema .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) .describe("List of [id, type] pairs."), diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index 5c4e97e..3c6cb21 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "打开前端的 SCADA 监测数据历史面板。", args: { + reason: tool.schema + .string() + .describe("Why SCADA panel interaction is required for this request."), device_ids: tool.schema .array(tool.schema.string()) .optional() diff --git a/src/audit/llmRequestAudit.ts b/src/audit/llmRequestAudit.ts new file mode 100644 index 0000000..be9307a --- /dev/null +++ b/src/audit/llmRequestAudit.ts @@ -0,0 +1,36 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { config } from "../config.js"; + +export type LlmRequestAuditEntry = { + kind: "tool" | "skill"; + sessionId: string; + clientSessionId: string; + traceId?: string; + projectId?: string; + target: string; + reason: string; + reasonProvided: boolean; + payload?: Record; +}; + +let logDirectoryReadyPromise: Promise | null = null; + +const ensureLogDirectory = async () => { + if (!logDirectoryReadyPromise) { + logDirectoryReadyPromise = mkdir(dirname(config.LLM_REQUEST_AUDIT_LOG_PATH), { + recursive: true, + }).then(() => undefined); + } + await logDirectoryReadyPromise; +}; + +export const writeLlmRequestAuditLog = async (entry: LlmRequestAuditEntry) => { + await ensureLogDirectory(); + const line = JSON.stringify({ + timestamp: new Date().toISOString(), + ...entry, + }); + await appendFile(config.LLM_REQUEST_AUDIT_LOG_PATH, `${line}\n`, "utf8"); +}; diff --git a/src/config.ts b/src/config.ts index 59211c8..e0639c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(8787), HOST: z.string().default("0.0.0.0"), LOG_LEVEL: z.string().default("info"), + LLM_REQUEST_AUDIT_LOG_PATH: z.string().default("./logs/llm-request-audit.log"), AGENT_INTERNAL_TOKEN: z.string().optional(), OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), OPENCODE_PORT: z.coerce.number().int().positive().default(4096), diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 71111d9..d9af3a3 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; +import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; const payloadSchema = z.object({ message: z.string().min(1).max(10000), @@ -204,6 +205,8 @@ export const buildChatRouter = ( opencodeSessionId: binding.sessionId, clientSessionId, message: parsed.data.message, + traceId: requestContext.traceId, + projectId: requestContext.projectId, signal: abortController.signal, write: (event, data) => { if (streamClosed || res.writableEnded || res.destroyed) { @@ -260,6 +263,42 @@ const normalizeToolParams = (value: unknown): Record => { return {}; }; +const extractRequestReason = (params: Record) => { + const candidates = ["reason", "request_reason", "why", "purpose", "rationale"]; + for (const key of candidates) { + const value = params[key]; + if (typeof value === "string") { + const normalized = value.trim(); + if (normalized) { + return normalized; + } + } + } + return ""; +}; + +const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill"); + +const extractSkillAuditInfo = (event: OpencodeEvent) => { + const payload = isObjectRecord(event.properties) + ? (event.properties as Record) + : {}; + const candidateName = + typeof payload.skill === "string" + ? payload.skill + : typeof payload.skillName === "string" + ? payload.skillName + : typeof payload.name === "string" + ? payload.name + : event.type; + const reason = extractRequestReason(payload); + return { + name: candidateName, + reason, + payload, + }; +}; + const hasToolParams = (params: Record) => Object.keys(params).length > 0; @@ -268,6 +307,8 @@ type StreamPromptOptions = { opencodeSessionId: string; clientSessionId: string; message: string; + traceId?: string; + projectId?: string; signal?: AbortSignal; write: (event: string, data: Record) => void; }; @@ -277,6 +318,8 @@ const streamPromptResponse = async ({ opencodeSessionId, clientSessionId, message, + traceId, + projectId, signal, write, }: StreamPromptOptions) => { @@ -379,6 +422,23 @@ const streamPromptResponse = async ({ continue; } + if (isSkillEvent(event)) { + const { name, reason, payload } = extractSkillAuditInfo(event); + void writeLlmRequestAuditLog({ + kind: "skill", + sessionId: opencodeSessionId, + clientSessionId, + traceId, + projectId, + target: name, + reason, + reasonProvided: Boolean(reason), + payload, + }).catch((error) => { + logger.warn({ err: error }, "failed to write skill audit log"); + }); + } + if (event.type === "message.part.delta" && event.properties.field === "text") { const partType = partTypes.get(event.properties.partID); if (partType === "text") { @@ -420,6 +480,7 @@ const streamPromptResponse = async ({ } if (part.type === "tool") { const toolParams = normalizeToolParams(part.state.input); + const reason = extractRequestReason(toolParams); const isToolFinalState = part.state.status === "completed" || part.state.status === "error"; @@ -436,10 +497,34 @@ const streamPromptResponse = async ({ (hasToolParams(toolParams) || isToolFinalState) ) { emittedToolParts.add(part.id); + if (!reason) { + logger.warn( + { + tool: part.tool, + sessionId: opencodeSessionId, + clientSessionId, + }, + "llm tool request missing reason", + ); + } + void writeLlmRequestAuditLog({ + kind: "tool", + sessionId: opencodeSessionId, + clientSessionId, + traceId, + projectId, + target: part.tool, + reason, + reasonProvided: Boolean(reason), + payload: toolParams, + }).catch((error) => { + logger.warn({ err: error }, "failed to write tool audit log"); + }); write("tool_call", { session_id: clientSessionId, tool: part.tool, params: toolParams, + reason, }); } } diff --git a/src/server.ts b/src/server.ts index cb91656..da18ee9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,6 +61,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => { // opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。 const result = await dynamicHttpExecutor.execute( { + reason: req.body?.reason, path: req.body?.path, method: req.body?.method, arguments: req.body?.arguments, diff --git a/src/tools/dynamicHttpExecutor.ts b/src/tools/dynamicHttpExecutor.ts index 7f8d3a1..e11feb1 100644 --- a/src/tools/dynamicHttpExecutor.ts +++ b/src/tools/dynamicHttpExecutor.ts @@ -4,6 +4,7 @@ import { config } from "../config.js"; import { logger } from "../logger.js"; export type DynamicHttpInput = { + reason?: string; path: string; method?: string; arguments?: Record; @@ -65,6 +66,7 @@ export class DynamicHttpExecutor { { method, path, + reason: typeof input.reason === "string" ? input.reason : undefined, statusCode: response.status, durationMs, traceId: context.traceId,