diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 8f28048..f541d05 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -21,7 +21,7 @@ temperature: 0.2 | 会话文件计算 | 经审批的沙箱 `shell` | | 稳定记忆、通用执行方法 | `memory_manager`、`skill_manager`;事件业务流程由 Server 经 MCP 分发 | -工具可见不等于有权执行,只使用当前角色已启用的工具。参数以工具 schema 为准,要求 reason 时填写具体理由,不增加身份、项目、会话目标等未声明参数。 +工具可见不等于有权执行,只使用当前角色已启用的工具。参数以工具 schema 为准,不增加身份、项目、会话目标等未声明参数。说明字段统一使用 reason,用一句话说明具体目的或业务依据,不填写“为了回答用户”等套话,也不输出内部推理过程。普通查询、读取和展示无需逐次补写原因;schema 未声明 reason 时不要添加,可选时仅在目的不明显时填写。数据权限申请、处置提案、计划变更保留必填业务理由;memory_manager 和 skill_manager 的 list 可省略,其余修改操作必须说明长期价值或变更依据。多步调查在阶段说明中交代目的,不在每次查询重复。 围绕用户尚未完成的目标连续推进必要的查询、计算、核验和结果整理。补充参数、纠正结果或询问进度通常是在推进同一目标;用户说“继续”“重试”“重新检查”时,结合已有授权、结果和失败原因执行下一步,不只复述上一轮结论,也不重复询问已经确认的选择。用户明确缩小范围、更换目标或要求暂停时,以最新要求为准;“继续”不扩大权限,也不自动重启已结束的后台调查。 diff --git a/.opencode/tool-reason.ts b/.opencode/tool-reason.ts new file mode 100644 index 0000000..ae73186 --- /dev/null +++ b/.opencode/tool-reason.ts @@ -0,0 +1,10 @@ +import { tool } from "./tool.js"; + +// One field and format for optional purpose and required business justification. +export const toolReason = tool.schema.string().trim().min(1).max(2000); +export const optionalToolReason = toolReason.optional().describe( + "Optional reason: one concise sentence explaining the specific purpose. Omit when the command already makes it clear.", +); +export const learningToolReason = toolReason.optional().describe( + "Reason: one concise sentence explaining the durable value or change basis. Required for every write, update or removal; optional for list.", +); diff --git a/.opencode/tools/clear_scada_analysis.ts b/.opencode/tools/clear_scada_analysis.ts index a1495ea..4f700b8 100644 --- a/.opencode/tools/clear_scada_analysis.ts +++ b/.opencode/tools/clear_scada_analysis.ts @@ -1,3 +1,4 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; @@ -5,11 +6,7 @@ import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "清除当前浏览器地图上的 Agent SCADA 分析结果覆盖层。", args: { - reason: tool.schema - .string() - .trim() - .min(1) - .describe("Why the current SCADA analysis overlay should be cleared."), + reason: optionalToolReason, }, async execute(args, context) { return executeFrontendAction("clear_scada_analysis", args, context); diff --git a/.opencode/tools/locate_features.ts b/.opencode/tools/locate_features.ts index b6d6f63..3fd147e 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -1,7 +1,8 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "在当前供水地图定位资产并等待浏览器执行回执。返回已定位和未找到的 ID,失败时不得宣称成功。", - args: { layer: tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]), feature_ids: tool.schema.array(tool.schema.string().min(1).max(128)).min(1).max(100), reason: tool.schema.string().max(2000) }, + args: { layer: tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]), feature_ids: tool.schema.array(tool.schema.string().min(1).max(128)).min(1).max(100), reason: optionalToolReason }, async execute(args, context) { return executeFrontendAction("locate_features", args, context); }, }); diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index 143e795..156d2e1 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -1,3 +1,4 @@ +import { learningToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; @@ -8,9 +9,7 @@ export default tool({ action: tool.schema .enum(["add", "list", "replace", "remove"]) .describe("Memory operation to perform."), - reason: tool.schema - .string() - .describe("Why this memory should be persisted for future requests."), + reason: learningToolReason, scope: tool.schema .string() .describe( diff --git a/.opencode/tools/propose_task_action.ts b/.opencode/tools/propose_task_action.ts index d1d41c5..ba23084 100644 --- a/.opencode/tools/propose_task_action.ts +++ b/.opencode/tools/propose_task_action.ts @@ -1,3 +1,4 @@ +import { toolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; import { proposedTaskActionSchema } from "../../src/product/operational-task-contract.js"; @@ -7,7 +8,7 @@ export default tool({ args: { request_id: tool.schema.string().describe("Stable UUID idempotency key for this proposal"), kind: tool.schema.enum(["create_work_order", "issue_command"]), - reason: tool.schema.string(), + reason: toolReason.describe("One concise sentence explaining the business basis for this proposed action."), payload: tool.schema.union([ tool.schema.object({ title: tool.schema.string(), diff --git a/.opencode/tools/publish_execution_plan.ts b/.opencode/tools/publish_execution_plan.ts index aeb8cea..0af80bf 100644 --- a/.opencode/tools/publish_execution_plan.ts +++ b/.opencode/tools/publish_execution_plan.ts @@ -1,3 +1,4 @@ +import { toolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js"; @@ -8,7 +9,7 @@ export default tool({ request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"), schema: tool.schema.enum(["execution-plan@2"]), title: tool.schema.string(), - reason: tool.schema.string().describe("Why the plan or node status changed"), + reason: toolReason.max(1000).describe("One concise sentence explaining why the plan or node status changed."), nodes: tool.schema.array(tool.schema.object({ id: tool.schema.string(), title: tool.schema.string(), status: tool.schema.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]), diff --git a/.opencode/tools/render_scada_analysis.ts b/.opencode/tools/render_scada_analysis.ts index 1d5cd47..55a0c01 100644 --- a/.opencode/tools/render_scada_analysis.ts +++ b/.opencode/tools/render_scada_analysis.ts @@ -1,3 +1,4 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; @@ -8,11 +9,7 @@ export default tool({ description: "将已经完成分析并确定等级的 SCADA 点位结果高亮、编号并定位到前端地图。等级只能来自已完成的分析结论。", args: { - reason: tool.schema - .string() - .trim() - .min(1) - .describe("Why the completed SCADA analysis should be rendered on the map."), + reason: optionalToolReason, items: tool.schema .array( tool.schema.object({ diff --git a/.opencode/tools/request_task_data_access.ts b/.opencode/tools/request_task_data_access.ts index 4a54e3c..5241353 100644 --- a/.opencode/tools/request_task_data_access.ts +++ b/.opencode/tools/request_task_data_access.ts @@ -1,3 +1,4 @@ +import { toolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; @@ -10,7 +11,7 @@ export default tool({ "workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", ])).min(1), - reason: tool.schema.string().min(1), + reason: toolReason.describe("One concise sentence explaining which evidence gap requires the requested data access."), }, async execute(args, context) { const { baseUrl, internalToken } = getInternalBridgeConfig(); diff --git a/.opencode/tools/show_chart.ts b/.opencode/tools/show_chart.ts index ccb007c..271c7e0 100644 --- a/.opencode/tools/show_chart.ts +++ b/.opencode/tools/show_chart.ts @@ -1,7 +1,8 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "显示受控折线或柱状图并等待浏览器回执。不接收 ECharts 脚本或任意 option;数据须来自可信查询或用户输入。", - args: { title: tool.schema.string().max(200), chart_type: tool.schema.enum(["line", "bar"]), x_data: tool.schema.array(tool.schema.string().max(128)).min(1).max(100), series: tool.schema.array(tool.schema.object({ name: tool.schema.string().max(128), data: tool.schema.array(tool.schema.number().finite()).min(1).max(100) })).min(1).max(8), reason: tool.schema.string().max(2000) }, + args: { title: tool.schema.string().max(200), chart_type: tool.schema.enum(["line", "bar"]), x_data: tool.schema.array(tool.schema.string().max(128)).min(1).max(100), series: tool.schema.array(tool.schema.object({ name: tool.schema.string().max(128), data: tool.schema.array(tool.schema.number().finite()).min(1).max(100) })).min(1).max(8), reason: optionalToolReason }, async execute(args, context) { return executeFrontendAction("show_chart", args, context); }, }); diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 01495be..1ad14e9 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -1,3 +1,4 @@ +import { learningToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; @@ -18,11 +19,7 @@ export default tool({ "remove_script", ]) .describe("Skill maintenance operation."), - reason: tool.schema - .string() - .describe( - "Why this skill maintenance action is justified for future reuse.", - ), + reason: learningToolReason, skill_path: tool.schema .string() .describe( diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index 5d8881a..f82cb6b 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -1,7 +1,8 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "查询当前项目资产最多24小时的监测历史并显示曲线面板,等待浏览器回执。使用服务返回的单位和数据,缺数时明确说明。仅普通会话可用。", - args: { element_type: tool.schema.enum(["junction", "pipe"]), element_id: tool.schema.string().min(1).max(128), start: tool.schema.string(), end: tool.schema.string(), reason: tool.schema.string().max(2000) }, + args: { element_type: tool.schema.enum(["junction", "pipe"]), element_id: tool.schema.string().min(1).max(128), start: tool.schema.string(), end: tool.schema.string(), reason: optionalToolReason }, async execute(args, context) { return executeFrontendAction("view_history", args, context); }, }); diff --git a/.opencode/tools/zoom_to_map.ts b/.opencode/tools/zoom_to_map.ts index 43bc366..ab086d9 100644 --- a/.opencode/tools/zoom_to_map.ts +++ b/.opencode/tools/zoom_to_map.ts @@ -1,7 +1,8 @@ +import { optionalToolReason } from "../tool-reason.js"; import { tool } from "../tool.js"; import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "将当前地图缩放到 WGS84 经纬度并等待浏览器回执。", - args: { center: tool.schema.tuple([tool.schema.number().min(-180).max(180), tool.schema.number().min(-90).max(90)]), zoom: tool.schema.number().min(0).max(24), reason: tool.schema.string().max(2000) }, + args: { center: tool.schema.tuple([tool.schema.number().min(-180).max(180), tool.schema.number().min(-90).max(90)]), zoom: tool.schema.number().min(0).max(24), reason: optionalToolReason }, async execute(args, context) { return executeFrontendAction("zoom_to_map", args, context); }, }); diff --git a/README.md b/README.md index 6ef53d1..6971c34 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,8 @@ Agent 服务的 API 前缀是 `/api/v1`,路径不再包含 `/agent/chat`。生 这里是现有 HTTP/SSE 服务的 `tjwater` 扩展,映射 OpenCode 原生用户收件箱;不等同于 OpenAI WebSocket 的 `response.steer` 事件,官方协议边界见 [Mid-turn steering](https://developers.openai.com/api/docs/guides/steering)。验证命令 `bun tests/integration/response-input-smoke.ts` 使用隔离 OpenCode 和本机确定性模型,验证模式切换及队列后续输出,不调用付费推理。 +工具说明统一使用 `reason`,填写时用一句话说明具体目的或业务依据。普通查询不额外注入该参数,地图、图表和 SCADA 展示工具的说明可选;缺少可选说明不会触发警告。数据权限申请、处置提案和计划变更保留必填理由;`memory_manager` / `skill_manager` 的 `list` 可省略,其余修改操作在内部桥接接口强制校验非空理由。说明随调用进入审计(`reasonProvided` 仅表示是否提供),进度中有值才显示“说明”,不替代授权、审批或执行回执。新工具只使用 `reason`,旧事件别名仅兼容读取。 + 流使用 `response.created`、`response.in_progress`、`response.output_item.added/done`、`response.content_part.added/done`、`response.output_text.delta/done` 和终态 `response.completed/failed/incomplete`,带单调递增的 `sequence_number`。中止返回 incomplete;未知原因不伪造标准 incomplete reason。进度、审批、问题、凭据续期、地图和富文本展示通过 `tjwater.<事件名>` 扩展携带 `response_id`。前端使用 Responses transport 接入现有 useChat 和 UIMessage 渲染;UIMessage 不再是生成接口的网络协议。标准流语义见 [OpenAI Responses streaming](https://developers.openai.com/api/docs/guides/streaming-responses)。 当前支持文本、受权会话续接和流式/非流式生成;`previous_response_id`、客户端指定 tools、图片/文件输入及其余未实现参数均返回400,不能将此接口宣称为完整 OpenAI API 代理。模型推理仍由 OpenCode 执行,上游 provider 协议沿用 `agent-models.json` 的配置。`GET /session/:session_id/stream` 保留独立的会话状态订阅,事件包含 state、token、progress、done 等,用于历史恢复和已运行任务跟随;Server 的 `/api/v1/workbench/events` 继续承载业务事件。 diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index dc763ae..d5d3912 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -877,16 +877,6 @@ export const streamPromptResponse = async ({ ) { emittedToolParts.add(part.id); toolCallCount += 1; - if (!reason) { - logger.warn( - { - tool: part.tool, - sessionId: sessionId, - clientSessionId, - }, - "llm tool request missing reason", - ); - } void writeLlmRequestAuditLog({ kind: "tool", sessionId: sessionId, diff --git a/src/routes/chatStreamEvents.ts b/src/routes/chatStreamEvents.ts index a3311f5..20d3703 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -119,9 +119,11 @@ export const normalizeToolParams = (value: unknown): Record => return {}; }; +// New tools use reason; aliases are read-only compatibility for existing events. +const reasonKeys = ["reason", "request_reason", "why", "purpose", "rationale"]; + export const extractRequestReason = (params: Record) => { - const candidates = ["reason", "request_reason", "why", "purpose", "rationale"]; - for (const key of candidates) { + for (const key of reasonKeys) { const value = params[key]; if (typeof value === "string") { const normalized = value.trim(); @@ -381,7 +383,7 @@ const truncateProgressText = (text: string, maxLength: number) => text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; const summarizeToolParams = (params: Record) => { - const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]); + const ignoredKeys = new Set(reasonKeys); const summary = Object.entries(params) .filter(([key]) => !ignoredKeys.has(key)) .slice(0, 4) @@ -433,7 +435,7 @@ export const buildToolProgressDetail = ( error?: string, ) => { const toolName = toolLabels[tool] ?? tool; - const reasonText = reason ? `;调用原因:${reason}` : ""; + const reasonText = reason ? `;说明:${reason}` : ""; const paramsText = `;关键参数:${summarizeToolParams(params)}`; if (status === "error") { diff --git a/src/routes/learningToolBridge.ts b/src/routes/learningToolBridge.ts index a60d659..3b66842 100644 --- a/src/routes/learningToolBridge.ts +++ b/src/routes/learningToolBridge.ts @@ -9,19 +9,23 @@ import { type SkillStore } from "../skills/store.js"; const memoryInput = z.object({ action: z.enum(["add", "list", "replace", "remove"]), scope: z.string().max(32), - reason: z.string().max(2000).optional(), + reason: z.string().trim().min(1).max(2000).optional(), content: z.string().max(20000).optional(), target_id: z.string().max(128).optional(), -}).strict(); +}).strict().refine((input) => input.action === "list" || Boolean(input.reason), { + path: ["reason"], message: "reason is required for writes, updates and removals", +}); const skillInput = z.object({ action: z.enum(["list", "write_skill", "remove_skill", "append_pattern", "remove_pattern", "write_reference", "remove_reference", "write_script", "remove_script"]), skill_path: z.string().max(512), - reason: z.string().max(2000).optional(), + reason: z.string().trim().min(1).max(2000).optional(), content: z.string().max(200000).optional(), file_path: z.string().max(512).optional(), pattern: z.string().max(20000).optional(), target_id: z.string().max(128).optional(), -}).strict(); +}).strict().refine((input) => input.action === "list" || Boolean(input.reason), { + path: ["reason"], message: "reason is required for writes, updates and removals", +}); export const registerLearningToolBridge = (app: Express, options: { internalToken: string; diff --git a/tests/opencode/learningToolBridge.test.ts b/tests/opencode/learningToolBridge.test.ts index b151bd4..2e4ba05 100644 --- a/tests/opencode/learningToolBridge.test.ts +++ b/tests/opencode/learningToolBridge.test.ts @@ -32,10 +32,10 @@ describe("learning tools across process boundaries", () => { 'import memory from "./.opencode/tools/memory_manager.ts";', 'import skill from "./.opencode/tools/skill_manager.ts";', 'const ctx = { sessionID: "learning-bridge-session" };', - 'const call = (action, content) => memory.execute({ action, scope: "user", reason: "test", content }, ctx).then(JSON.parse);', + 'const call = (action, content) => memory.execute({ action, scope: "user", ...(action === "list" ? {} : { reason: "test" }), content }, ctx).then(JSON.parse);', 'const before = await call("add", "用户偏好简洁答复");', 'await call("list"); const added = await call("add", "用户偏好简洁答复");', - 'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__", reason: "test" }, ctx));', + 'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__" }, ctx));', 'process.stdout.write(JSON.stringify({ before, added, skills }));', ].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" }); const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); @@ -48,6 +48,21 @@ describe("learning tools across process boundaries", () => { expect(await memoryStore.list("user", "owner")).toHaveLength(1); expect(await memoryStore.list("user", "other-owner")).toHaveLength(0); const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) }); + // Invalid mutation reasons must fail before either store is changed. + for (const reason of [undefined, "", " ", "x".repeat(2001)]) { + expect((await post("learning-bridge-session", { + action: "remove", scope: "user", target_id: "anything", reason, + })).status).toBe(400); + const skillResponse = await fetch(`${baseUrl}/internal/tools/skill-manager`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" }, + body: JSON.stringify({ session_id: "learning-bridge-session", input: { + action: "write_skill", skill_path: "test", content: "must not be written", reason, + } }), + }); + expect(skillResponse.status).toBe(400); + } + expect(await memoryStore.list("user", "owner")).toHaveLength(1); expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404); expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400); expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403); diff --git a/tests/opencode/toolReason.test.ts b/tests/opencode/toolReason.test.ts new file mode 100644 index 0000000..87be4b3 --- /dev/null +++ b/tests/opencode/toolReason.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { optionalToolReason, toolReason } from "../../.opencode/tool-reason.js"; + +describe("tool reason contract", () => { + it("allows omitted purpose but requires a nonblank business reason", () => { + expect(optionalToolReason.parse(undefined)).toBeUndefined(); + expect(toolReason.safeParse(undefined).success).toBeFalse(); + for (const schema of [optionalToolReason, toolReason]) { + expect(schema.parse(" 核验压力异常 ")).toBe("核验压力异常"); + expect(schema.safeParse(" ").success).toBeFalse(); + expect(schema.safeParse("x".repeat(2001)).success).toBeFalse(); + } + }); +}); diff --git a/tests/routes/toolReason.test.ts b/tests/routes/toolReason.test.ts new file mode 100644 index 0000000..7773f88 --- /dev/null +++ b/tests/routes/toolReason.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test"; +import { buildToolProgressDetail, extractRequestReason } from "../../src/routes/chatStreamEvents.js"; + +describe("tool reason display", () => { + it("omits absent explanations and displays canonical or legacy reasons once", () => { + const plain = buildToolProgressDetail("query", "running", { id: "n1" }, ""); + expect(plain).not.toContain("说明:"); + expect(plain).not.toContain("原因:"); + expect(plain).toContain("id=n1"); + for (const input of [{ reason: " 核验压力异常 " }, { request_reason: "核验压力异常" }]) { + const detail = buildToolProgressDetail("query", "running", input, extractRequestReason(input)); + expect(detail).toContain(";说明:核验压力异常"); + expect(detail.match(/核验压力异常/g)).toHaveLength(1); + } + expect(extractRequestReason({ reason: "当前说明", purpose: "旧说明" })).toBe("当前说明"); + }); +});