fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+2 -1
View File
@@ -32,7 +32,7 @@
"plugins": [
{
"id": "tjwater.tools",
"description": "注册 20 个回答、展示、调查写入、通信、记忆/技能管理及待办工具;安装会话 Shell 沙箱、原生 MCP 连接和权限策略。查询由 MCP 提供,公开网页搜索使用原生 websearch。",
"description": "注册 21 个回答、展示、调查写入、通信、记忆/技能管理及待办工具;安装会话 Shell 沙箱、原生 MCP 连接和权限策略。查询由 MCP 提供,公开网页搜索使用原生 websearch。",
"package": "tjwater-plugin",
"entrypoint": "tjwater-plugin/index.ts",
"implementation": "plugins/tjwater-tools.ts",
@@ -41,6 +41,7 @@
"description": "本地交互式回答与完整最终答复;不代表后台任务完成。",
"tools": [
"present_answer",
"ask_user",
"final_answer"
]
},
+1 -1
View File
@@ -86,7 +86,7 @@ temperature: 0.2
一个界面块发布后可以继续查询或解释,再发布下一块;相同 block_id 更新本轮同一块。不要重复在文字中抄写整张表或图。每个 Chart 和 Metric 保留单位或来源;查询事实、用户事实、推断和演练数据分别标注。MapLocation 仅提供用户主动点击后的定位入口。FollowUpForm 只收集输入,用户提交后形成新的对话消息,不能在未提交时当作已确认事实。后台 task_execution 使用原任务进度与展示渠道,不调用 present_answer。
当前步骤确实依赖用户才能决定的范围、参数或取舍时,普通聊天与任务协作主动使用 `question`,简要说明该选择如何影响后续执行,并合并相关问题。可查询的事实、已确认的参数和工具 schema 已有的默认值先自行处理;工具权限审批仍走宿主流程。等待真实回答后接着执行,不先提交 `final_answer`;取消提问不表示确认任何选项。`question` 不可用时用简短文字提问,说明哪一步等待回答。FollowUpForm 用于最终报告后可选的资料补充,不代替执行中阻塞的提问;同一问题只使用一种收集方式。
当前步骤确实依赖用户才能决定的范围、参数或取舍时,普通聊天与任务协作优先使用当前目录提供的 `ask_user`。先完成其他工具,再单独提出最多三个问题,简要说明选择的影响;定义稳定的问题与选项 ID。一般澄清问题优先设置 allow_custom=true,允许用户直接填写回答或补充已选内容;只有确实必须限定为已有选项时才关闭。宿主确认问题已保存后,本轮结束,不继续工具、不猜测默认答案、不调用 `final_answer`。用户回答会以原会话新的用户输入送达,再继续执行。旧客户端没有 `ask_user` 时才使用可用的原生 `question`;二者都不可用时用简短文字提问。可查询的事实、已确认的参数和工具 schema 已有的默认值先自行处理;工具权限审批仍走宿主流程。取消或跳过不表示确认任何选项。FollowUpForm 用于最终报告后可选的资料补充,不代替执行中阻塞的提问;同一问题只使用一种收集方式。
核对目标完成情况或真实阻塞,并完成所有已启动工具、必要写回和界面更新后,普通聊天与任务协作调用一次 `final_answer` 结束本轮。阶段说明、等待回答和可继续执行的下一步留在过程里;最终答复后不继续查询、重复改写报告或调用 `present_answer`。后台调查执行按追加的调查指令和原有任务运行接口收尾。
+35
View File
@@ -0,0 +1,35 @@
import type { Plugin } from "@opencode/plugin";
import { getInternalBridgeConfig } from "./internalBridge.js";
export async function hostQuestionBridge(operation: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/chat-interactions/${operation}`, {
method: "POST", headers: { "content-type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error("Interaction gate rejected the operation. Finish existing tools before asking, then wait for the user.");
return response.json();
}
export async function registerHostQuestionPolicy(ctx: Parameters<Plugin.Plugin["setup"]>[0]) {
await ctx.session.hook("context", async (event) => {
const policy = await hostQuestionBridge("policy", { session_id: event.sessionID }) as { enabled: boolean; waiting: boolean };
if (!policy.enabled) delete event.tools.ask_user;
else {
delete event.tools.question;
if (policy.waiting) for (const name of Object.keys(event.tools)) delete event.tools[name];
}
});
// Wrap native, MCP and custom executors, including calls via Code Mode.
await ctx.tool.transform((editor) => {
for (const definition of editor.list()) editor.update(definition.id, (draft) => {
const execute = draft.execute;
draft.execute = async (input, context) => {
const identity = { session_id: context.sessionID, call_id: context.id, tool: definition.id };
await hostQuestionBridge("enter", identity, context.signal);
try { return await execute(input, context); }
finally { await hostQuestionBridge("leave", identity).catch(() => undefined); }
};
});
});
}
+5 -1
View File
@@ -1,3 +1,5 @@
import ask_user from "../tools/ask_user.js";
import { registerHostQuestionPolicy } from "../host-question-policy.js";
import final_answer from "../tools/final_answer.js";
import present_answer from "../tools/present_answer.js";
import locate_features from "../tools/locate_features.js";
@@ -29,6 +31,7 @@ import send_task_message from "../tools/send_task_message.js";
import reply_agent_message from "../tools/reply_agent_message.js";
const businessTools = {
ask_user,
final_answer,
present_answer,
locate_features,
@@ -57,7 +60,7 @@ export default Plugin.define({
await ctx.tool.transform((editor) => {
for (const [name, definition] of Object.entries(businessTools)) {
const object = z.object(definition.args);
const input = (AGENT_MESSAGE_TOOLS as readonly string[]).includes(name) ? object.strict() : object;
const input = name === "ask_user" || (AGENT_MESSAGE_TOOLS as readonly string[]).includes(name) ? object.strict() : object;
editor.add({
name,
description: definition.description,
@@ -99,6 +102,7 @@ export default Plugin.define({
const mcpReport = await registerMcpPolicy(ctx);
await registerTaskAgentPolicy(ctx);
await registerAgentMessagePolicy(ctx);
await registerHostQuestionPolicy(ctx);
await ctx.rpc.register(Rpc.define({
id: "tjwater.tools",
methods: {
+18
View File
@@ -0,0 +1,18 @@
import { tool } from "../tool.js";
import { interactionQuestionsSchema } from "../../src/chat/interaction-request-contract.js";
import { hostQuestionBridge } from "../host-question-policy.js";
export default tool({
description: "向用户提出最多三个澄清问题。先完成其他工具,再单独调用;问题由宿主持久保存,调用成功后本轮结束,等待用户回答。不要猜测答案,不要继续调用工具,不代替业务审批。选项使用稳定 id;支持单选、多选和自定义文字。",
args: { questions: tool.schema.array(tool.schema.object({
question_id: tool.schema.string().min(1).max(128), title: tool.schema.string().min(1).max(200), body: tool.schema.string().min(1).max(2000),
options: tool.schema.array(tool.schema.object({ id: tool.schema.string().min(1).max(128), label: tool.schema.string().min(1).max(200) }).strict()).max(12),
mode: tool.schema.enum(["single", "multiple"]), allow_custom: tool.schema.boolean(), required: tool.schema.boolean(),
}).strict()).min(1).max(3) },
async execute(args, context) {
if (!context.callID) throw new Error("Question requires a trusted tool call ID");
return JSON.stringify(await hostQuestionBridge("create", {
session_id: context.sessionID, call_id: context.callID, tool: "ask_user", questions: interactionQuestionsSchema.parse(args.questions),
}, context.signal));
},
});