refactor: unify tool reason requirements
This commit is contained in:
@@ -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 可省略,其余修改操作必须说明长期价值或变更依据。多步调查在阶段说明中交代目的,不在每次查询重复。
|
||||
|
||||
围绕用户尚未完成的目标连续推进必要的查询、计算、核验和结果整理。补充参数、纠正结果或询问进度通常是在推进同一目标;用户说“继续”“重试”“重新检查”时,结合已有授权、结果和失败原因执行下一步,不只复述上一轮结论,也不重复询问已经确认的选择。用户明确缩小范围、更换目标或要求暂停时,以最新要求为准;“继续”不扩大权限,也不自动重启已结束的后台调查。
|
||||
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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); },
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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); },
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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); },
|
||||
});
|
||||
|
||||
@@ -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); },
|
||||
});
|
||||
|
||||
@@ -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` 继续承载业务事件。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,9 +119,11 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("当前说明");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user