@@ -14,3 +14,5 @@ temperature: 0.2
|
|||||||
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
|
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
|
||||||
5. 保持回复准确、简洁,对供水网络用户在操作上有用。
|
5. 保持回复准确、简洁,对供水网络用户在操作上有用。
|
||||||
6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
|
6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
|
||||||
|
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
|
||||||
|
8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。",
|
"通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。",
|
||||||
args: {
|
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 '/'."),
|
path: tool.schema.string().describe("Target backend API path, starting with '/'."),
|
||||||
method: tool.schema
|
method: tool.schema
|
||||||
.string()
|
.string()
|
||||||
@@ -27,6 +30,7 @@ export default tool({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: context.sessionID,
|
sessionId: context.sessionID,
|
||||||
|
reason: args.reason,
|
||||||
path: args.path,
|
path: args.path,
|
||||||
method: args.method,
|
method: args.method,
|
||||||
arguments: args.arguments,
|
arguments: args.arguments,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "在前端地图上定位并高亮指定的管网要素。",
|
description: "在前端地图上定位并高亮指定的管网要素。",
|
||||||
args: {
|
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."),
|
ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."),
|
||||||
feature_type: tool.schema
|
feature_type: tool.schema
|
||||||
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
|
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "在前端对话界面中渲染图表。",
|
description: "在前端对话界面中渲染图表。",
|
||||||
args: {
|
args: {
|
||||||
|
reason: tool.schema
|
||||||
|
.string()
|
||||||
|
.describe("Why this chart should be rendered for the user request."),
|
||||||
title: tool.schema.string().optional().describe("Chart title."),
|
title: tool.schema.string().optional().describe("Chart title."),
|
||||||
chart_type: tool.schema
|
chart_type: tool.schema
|
||||||
.enum(["line", "bar", "pie"])
|
.enum(["line", "bar", "pie"])
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
||||||
args: {
|
args: {
|
||||||
|
reason: tool.schema
|
||||||
|
.string()
|
||||||
|
.describe("Why this history panel should be opened for the current task."),
|
||||||
feature_infos: tool.schema
|
feature_infos: tool.schema
|
||||||
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
||||||
.describe("List of [id, type] pairs."),
|
.describe("List of [id, type] pairs."),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "打开前端的 SCADA 监测数据历史面板。",
|
description: "打开前端的 SCADA 监测数据历史面板。",
|
||||||
args: {
|
args: {
|
||||||
|
reason: tool.schema
|
||||||
|
.string()
|
||||||
|
.describe("Why SCADA panel interaction is required for this request."),
|
||||||
device_ids: tool.schema
|
device_ids: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ services:
|
|||||||
- /home/ubuntu/.local/share/opencode:/root/.local/share/opencode
|
- /home/ubuntu/.local/share/opencode:/root/.local/share/opencode
|
||||||
- ./opencode/agents:/app/.opencode/agents
|
- ./opencode/agents:/app/.opencode/agents
|
||||||
- ./opencode/skills:/app/.opencode/skills
|
- ./opencode/skills:/app/.opencode/skills
|
||||||
|
- ./logs:/app/logs
|
||||||
# - .:/workspace
|
# - .:/workspace
|
||||||
ports:
|
ports:
|
||||||
- "8787:8787"
|
- "8787:8787"
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let logDirectoryReadyPromise: Promise<void> | 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");
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ const envSchema = z.object({
|
|||||||
PORT: z.coerce.number().int().positive().default(8787),
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
HOST: z.string().default("0.0.0.0"),
|
HOST: z.string().default("0.0.0.0"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
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(),
|
AGENT_INTERNAL_TOKEN: z.string().optional(),
|
||||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||||
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
|
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
|
|
||||||
const payloadSchema = z.object({
|
const payloadSchema = z.object({
|
||||||
message: z.string().min(1).max(10000),
|
message: z.string().min(1).max(10000),
|
||||||
@@ -204,6 +205,8 @@ export const buildChatRouter = (
|
|||||||
opencodeSessionId: binding.sessionId,
|
opencodeSessionId: binding.sessionId,
|
||||||
clientSessionId,
|
clientSessionId,
|
||||||
message: parsed.data.message,
|
message: parsed.data.message,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
|
projectId: requestContext.projectId,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
write: (event, data) => {
|
write: (event, data) => {
|
||||||
if (streamClosed || res.writableEnded || res.destroyed) {
|
if (streamClosed || res.writableEnded || res.destroyed) {
|
||||||
@@ -260,6 +263,42 @@ const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractRequestReason = (params: Record<string, unknown>) => {
|
||||||
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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<string, unknown>) =>
|
const hasToolParams = (params: Record<string, unknown>) =>
|
||||||
Object.keys(params).length > 0;
|
Object.keys(params).length > 0;
|
||||||
|
|
||||||
@@ -268,6 +307,8 @@ type StreamPromptOptions = {
|
|||||||
opencodeSessionId: string;
|
opencodeSessionId: string;
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
traceId?: string;
|
||||||
|
projectId?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
write: (event: string, data: Record<string, unknown>) => void;
|
write: (event: string, data: Record<string, unknown>) => void;
|
||||||
};
|
};
|
||||||
@@ -277,6 +318,8 @@ const streamPromptResponse = async ({
|
|||||||
opencodeSessionId,
|
opencodeSessionId,
|
||||||
clientSessionId,
|
clientSessionId,
|
||||||
message,
|
message,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
signal,
|
signal,
|
||||||
write,
|
write,
|
||||||
}: StreamPromptOptions) => {
|
}: StreamPromptOptions) => {
|
||||||
@@ -379,6 +422,23 @@ const streamPromptResponse = async ({
|
|||||||
continue;
|
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") {
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||||
const partType = partTypes.get(event.properties.partID);
|
const partType = partTypes.get(event.properties.partID);
|
||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
@@ -420,6 +480,7 @@ const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
const toolParams = normalizeToolParams(part.state.input);
|
const toolParams = normalizeToolParams(part.state.input);
|
||||||
|
const reason = extractRequestReason(toolParams);
|
||||||
const isToolFinalState =
|
const isToolFinalState =
|
||||||
part.state.status === "completed" || part.state.status === "error";
|
part.state.status === "completed" || part.state.status === "error";
|
||||||
|
|
||||||
@@ -436,10 +497,34 @@ const streamPromptResponse = async ({
|
|||||||
(hasToolParams(toolParams) || isToolFinalState)
|
(hasToolParams(toolParams) || isToolFinalState)
|
||||||
) {
|
) {
|
||||||
emittedToolParts.add(part.id);
|
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", {
|
write("tool_call", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
tool: part.tool,
|
tool: part.tool,
|
||||||
params: toolParams,
|
params: toolParams,
|
||||||
|
reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
|||||||
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
|
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
|
||||||
const result = await dynamicHttpExecutor.execute(
|
const result = await dynamicHttpExecutor.execute(
|
||||||
{
|
{
|
||||||
|
reason: req.body?.reason,
|
||||||
path: req.body?.path,
|
path: req.body?.path,
|
||||||
method: req.body?.method,
|
method: req.body?.method,
|
||||||
arguments: req.body?.arguments,
|
arguments: req.body?.arguments,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { config } from "../config.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
|
||||||
export type DynamicHttpInput = {
|
export type DynamicHttpInput = {
|
||||||
|
reason?: string;
|
||||||
path: string;
|
path: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
@@ -65,6 +66,7 @@ export class DynamicHttpExecutor {
|
|||||||
{
|
{
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
|
reason: typeof input.reason === "string" ? input.reason : undefined,
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
durationMs,
|
durationMs,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
|
|||||||
Reference in New Issue
Block a user