feat: share the proposal board across chats
The unfinished-proposal board is now visible to every conversation, with entries marked scope=own/scope=other (chat type only, no IDs), so anyone can see what the single worker is busy with. All actions remain owner-only, and pending reminders still target only the owner.
This commit is contained in:
@@ -207,7 +207,7 @@ Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assi
|
||||
|
||||
## 5. Assistant / Proposal / Worker 与 state v3
|
||||
|
||||
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,只有发起人本人可 confirm/adjust/follow_up/finish/stop/cancel/list,`start_next` 也只启动发起人自己的 queued Proposal。
|
||||
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,confirm/adjust/follow_up/finish/stop/cancel 全部 owner-only(runtime 强校验),`start_next` 也只启动发起人自己的 queued Proposal。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(`scope=own` / `scope=other` + chat 类型,不暴露 openid),Assistant 可向任何用户如实描述全板,但 action 只能作用于 scope=own 条目。
|
||||
|
||||
Proposal 状态流:`proposed → queued → working → pending → finished`。`proposed` 必须用户确认才进入 `queued`;`pending` 就是「等用户决定」,不区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel`(proposed/queued/pending)落定为 `finished(cancelled)`;没有 working、owner 自己没有 pending、全局没有 workspaceDirty pending 时,最早确认的 queued Proposal 才可通过 confirm 或 start_next 开始。
|
||||
|
||||
@@ -227,7 +227,7 @@ Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 ke
|
||||
|
||||
state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 2)存 Proposal 记录。state v3 打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。proposals.json 唯一例外:v1 文件打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS → pending{summary};FAILED → pending{summary, workspaceDirty: true};NEEDS_CONFIRMATION → pending{summary, question};completed/failed → finished(done)(failed 保留失败说明为 finishNote);cancelled → finished(cancelled);proposed/queued/working 保留)。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona/assistantPersona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
|
||||
|
||||
命令 `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel` 旁路 Assistant;全部 owner-only(chat + user):`/confirm` 对 proposed,`/finish` 对 pending,`/stop` 仅对 working(→pending),`/cancel` 对 proposed/queued/pending;`/list` 面板按 pending → working → queued → proposed → 最近 finished 排列。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
|
||||
命令 `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel` 旁路 Assistant;除 `/list` 外全部 owner-only(chat + user):`/confirm` 对 proposed,`/finish` 对 pending,`/stop` 仅对 working(→pending),`/cancel` 对 proposed/queued/pending;`/list` 显示全局板(含最近 finished),按 pending → working → queued → proposed → 最近 finished 排列,自己的条目标注「你的」,他人的标注「其他成员」(不暴露 ID)。pending card 每轮强提醒与兜底追加维持 owner-only。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
|
||||
|
||||
出站图片(QQ):Worker 在 `GORI_WORKER_RESULT_V2` 的 `attachments`(最多 3 个 `{path, mimeType?}`)上报 workspace 内 png/jpg(建议 `.gori-outbox/`),Assistant 也可用 `send_image { path }` 主动发图;runtime 校验 resolved realpath 必须在 canonical workspace 内、png/jpg magic bytes、单张 ≤10MB,违规丢弃并在事件文本说明。发送走 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1` + base64 `file_data` + `srv_send_msg: false`)后 `msg_type: 7` + `media.file_info` 发送;群/私聊上传的 file_info 不通用,按目标分别上传。图片与文本共用 Gateway 的 `msg_id` + 递增 `msg_seq` 计数器;落定事件先文案后图;被动窗口过期时照旧记录并下次入站补发,补发按路径重读文件、文件缺失降级为文本说明;上传/发送失败不阻断文本,降级文本说明 + 安全日志。其他平台 adapter 无 `supportsImages` 标记时图片降级为 `[图片] <文件名>` 文本。
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ QQ 出站图片:Worker/Assistant 报告的 workspace 内图片(png/jpg、≤
|
||||
运行链路是三层:
|
||||
|
||||
- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。
|
||||
- **Proposal**:一份工作单,owner 是 chat + 发起用户;只有发起人本人可以 confirm/adjust/follow_up/finish/stop/cancel/list 它,`start_next` 也只启动发起人自己的 queued Proposal。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
|
||||
- **Proposal**:一份工作单,owner 是 chat + 发起用户;confirm/adjust/follow_up/finish/stop/cancel 全部只有发起人本人可操作(runtime 强校验),`start_next` 也只启动发起人自己的 queued Proposal;但 Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(自己的标 `scope=own`,他人的标 `scope=other` 并附 chat 类型,不暴露 openid 明文),Assistant 可如实向任何用户描述全板状态。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
|
||||
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。
|
||||
|
||||
确认语义是刻意的:
|
||||
@@ -239,7 +239,7 @@ Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona
|
||||
|
||||
- `/help`:显示自然语言使用说明和兜底命令列表。
|
||||
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingProposals`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
|
||||
- `/list`:当前用户拥有的 Proposal 面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列。
|
||||
- `/list`:全局 Proposal 板面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列;自己的条目标注「你的」,他人的标注「其他成员」(不暴露 chat/user ID);最近结束也全局显示。
|
||||
- `/confirm`:确认当前用户最近待确认的 `proposed` Proposal(进入队列)。
|
||||
- `/finish`:把当前用户最近的 `pending` Proposal 落定为 `finished(done)`。
|
||||
- `/stop`:停止当前用户正在执行的 Worker,Proposal 转为 `pending`(被用户中止、dirty);只有 Proposal 发起人可用。
|
||||
|
||||
@@ -152,9 +152,9 @@ export class AssistantManager implements ConversationRuntime {
|
||||
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
|
||||
}
|
||||
|
||||
listProposals(platform: string, chatId: string, userId: string): Proposal[] {
|
||||
const chatKey = chatKeyFor(platform, chatId);
|
||||
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
listProposals(_platform: string, _chatId: string, _userId: string): Proposal[] {
|
||||
// The board is globally visible; operation checks stay owner-only in the *Locked paths.
|
||||
return this.proposals.list();
|
||||
}
|
||||
|
||||
async reset(platform: string, chatId: string, userId: string): Promise<void> {
|
||||
@@ -835,14 +835,17 @@ export class AssistantManager implements ConversationRuntime {
|
||||
}
|
||||
|
||||
private proposalSummaries(chatKey: string, userId: string): string[] {
|
||||
const proposals = this.proposals.list()
|
||||
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
if (proposals.length === 0) return ["none"];
|
||||
return proposals.map((proposal) => {
|
||||
// The board is globally shared: every unfinished proposal is listed, tagged with its
|
||||
// ownership scope. Other entries expose only the chat type, never raw chat/user IDs.
|
||||
const unfinished = this.proposals.list().filter((proposal) => proposal.status !== "finished");
|
||||
if (unfinished.length === 0) return ["none"];
|
||||
return unfinished.map((proposal) => {
|
||||
const own = proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
const scope = own ? "scope=own" : `scope=other chat=${chatTypeOf(proposal.ownerChatKey)}`;
|
||||
const pending = proposal.pending
|
||||
? ` summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
|
||||
: "";
|
||||
return `- id=${proposal.id} status=${proposal.status} title=${JSON.stringify(proposal.title)}${pending}`;
|
||||
return `- id=${proposal.id} status=${proposal.status} ${scope} title=${JSON.stringify(proposal.title)}${pending}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -895,6 +898,14 @@ function proposalTag(proposalId: string): string {
|
||||
return proposalId.slice(0, 8);
|
||||
}
|
||||
|
||||
// chatKey is `${platform}:${chatId}`; only the chat type is ever shown, never the raw ID.
|
||||
function chatTypeOf(chatKey: string): string {
|
||||
const chatId = chatKey.slice(chatKey.indexOf(":") + 1);
|
||||
if (chatId.startsWith("group:") || chatId.startsWith("channel:") || chatId.startsWith("guild:")) return "group";
|
||||
if (chatId.startsWith("user:")) return "c2c";
|
||||
return "direct";
|
||||
}
|
||||
|
||||
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
|
||||
const direct = parseAssistantEnvelopeValue(strictEnvelopeJson(text, ASSISTANT_ENVELOPE));
|
||||
if (direct) return direct;
|
||||
|
||||
+11
-7
@@ -329,7 +329,9 @@ export class Gateway {
|
||||
}
|
||||
case "list": {
|
||||
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
|
||||
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId));
|
||||
const chatKey = chatKeyFor(message.platform, message.chatId);
|
||||
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === message.userId;
|
||||
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId), owned);
|
||||
}
|
||||
case "confirm": {
|
||||
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
|
||||
@@ -357,8 +359,10 @@ export class Gateway {
|
||||
|
||||
}
|
||||
|
||||
function renderProposalPanel(proposals: Proposal[]): string {
|
||||
function renderProposalPanel(proposals: Proposal[], owned: (proposal: Proposal) => boolean): string {
|
||||
if (proposals.length === 0) return "当前没有提案。";
|
||||
// The board is globally visible; ownership is annotated without exposing chat/user IDs.
|
||||
const scope = (proposal: Proposal) => owned(proposal) ? "(你的)" : "(其他成员)";
|
||||
const pending = proposals.filter((proposal) => proposal.status === "pending");
|
||||
const working = proposals.filter((proposal) => proposal.status === "working");
|
||||
const queued = proposals.filter((proposal) => proposal.status === "queued");
|
||||
@@ -371,26 +375,26 @@ function renderProposalPanel(proposals: Proposal[]): string {
|
||||
sections.push("待确认(等你处理):");
|
||||
for (const proposal of pending) {
|
||||
const card = proposal.pending;
|
||||
sections.push(`- ${proposal.id}「${proposal.title}」${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
|
||||
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
|
||||
}
|
||||
}
|
||||
if (working.length > 0) {
|
||||
sections.push("进行中:");
|
||||
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」正在执行(/stop 可中止)`);
|
||||
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}正在执行(/stop 可中止)`);
|
||||
}
|
||||
if (queued.length > 0) {
|
||||
sections.push("排队中:");
|
||||
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」`);
|
||||
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}`);
|
||||
}
|
||||
if (proposed.length > 0) {
|
||||
sections.push("未确认(proposed):");
|
||||
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」(/confirm 确认后排队)`);
|
||||
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}(/confirm 确认后排队)`);
|
||||
}
|
||||
if (finished.length > 0) {
|
||||
sections.push("最近结束:");
|
||||
for (const proposal of finished) {
|
||||
const state = proposal.finishKind === "cancelled" ? "已取消" : "已完成";
|
||||
sections.push(`- ${proposal.id}「${proposal.title}」${state}${proposal.finishNote ? `(${proposal.finishNote})` : ""}`);
|
||||
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${state}${proposal.finishNote ? `(${proposal.finishNote})` : ""}`);
|
||||
}
|
||||
}
|
||||
return sections.join("\n");
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
import type { AppConfig, BotConfig } from "../config.js";
|
||||
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
||||
|
||||
const BOOTSTRAP_SCHEMA_VERSION = 9;
|
||||
const BOOTSTRAP_SCHEMA_VERSION = 10;
|
||||
|
||||
export interface ResolvedBot extends BotConfig {
|
||||
loadedSkills: LoadedSkill[];
|
||||
@@ -51,6 +51,7 @@ function buildAssistantBootstrap(bot: BotConfig): string {
|
||||
"When a worker's summary says it saved image files inside the workspace (for example under .gori-outbox/) and the user asks to see one, emit \"send_image\" with that exact path. Only send paths a worker actually reported; never invent paths.",
|
||||
"Whenever the [Pending proposals] section in a prompt lists one of the user's proposals, your reply MUST acknowledge it: remind the user what is waiting and that they can say finish to close it or just keep talking to continue it.",
|
||||
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Pending proposals], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
|
||||
"The [Proposal states] board is shared globally: you see every unfinished proposal. Entries marked scope=own belong to the current user; scope=other entries belong to someone else. You may honestly describe the whole board to anyone (what the bot is working on, how many tasks are queued), but actions only ever apply to scope=own entries: never claim you confirmed, adjusted, finished, stopped, or cancelled another user's task.",
|
||||
"In a group chat each proposal is owned by the user who requested it: only that user can confirm, adjust, follow up, finish, stop, or cancel it. If another group member asks you to operate a proposal they did not create, explain that only the proposal's initiator can do that and emit no action.",
|
||||
"When a proposal is pending and the user says something like \"够了\", \"可以了\", \"结束吧\" or \"that's enough\", emit a \"finish\" action. When they instead ask for more changes or answer the pending question, emit \"follow_up\" with their instruction so the same worker continues."
|
||||
].join("\n\n");
|
||||
|
||||
@@ -397,18 +397,28 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
||||
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), false);
|
||||
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
||||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 0);
|
||||
|
||||
// The board is globally visible: both users see the same proposal, tagged by scope.
|
||||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 1);
|
||||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1);
|
||||
|
||||
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
||||
|
||||
await harness.manager.prompt(request("hello owner", "user-a"));
|
||||
const aPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello owner"));
|
||||
assert.ok(aPrompt?.text);
|
||||
assert.match(aPrompt.text!, /scope=own/);
|
||||
|
||||
await harness.manager.prompt(request("hello again", "user-b"));
|
||||
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello again"));
|
||||
assert.ok(bPrompt?.text);
|
||||
// Debugging phase: non-owners also see the live card, marked as another user's proposal.
|
||||
assert.match(bPrompt.text!, /Test proposal/);
|
||||
assert.match(bPrompt.text!, /another user's proposal/);
|
||||
// The shared board tags the entry scope=other with a chat-type label; no raw user ID leaks.
|
||||
assert.match(bPrompt.text!, /scope=other chat=direct/);
|
||||
assert.doesNotMatch(bPrompt.text!, /user-a/);
|
||||
|
||||
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
||||
|
||||
+20
-4
@@ -658,11 +658,27 @@ test("the list panel orders pending first, then working, queued, proposed, and r
|
||||
.map((section) => reply.indexOf(section));
|
||||
assert.ok(order.every((index) => index >= 0), reply);
|
||||
assert.deepEqual([...order].sort((a, b) => a - b), order);
|
||||
assert.match(reply, /p-pending「待确认任务」:做了一半(问你:继续吗?)/);
|
||||
assert.match(reply, /p-pending「待确认任务」(你的):做了一半(问你:继续吗?)/);
|
||||
assert.match(reply, /\/finish 结束/);
|
||||
assert.match(reply, /p-working「干活中」正在执行/);
|
||||
assert.match(reply, /p-cancelled「取消任务」已取消/);
|
||||
assert.match(reply, /p-finished「旧任务」已完成/);
|
||||
assert.match(reply, /p-working「干活中」(你的)正在执行/);
|
||||
assert.match(reply, /p-cancelled「取消任务」(你的)已取消/);
|
||||
assert.match(reply, /p-finished「旧任务」(你的)已完成/);
|
||||
});
|
||||
|
||||
test("the list panel shows other members' proposals without exposing their identity", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const adapter = recordingAdapter();
|
||||
runtime.proposals = [
|
||||
fakeProposal({ id: "p-mine", title: "我的任务", status: "working" }),
|
||||
fakeProposal({ id: "p-other", title: "别人的任务", status: "pending", ownerChatKey: "qq:group:g-secret-9", requesterUserId: "someone-else", pending: { summary: "做到一半", receivedAt: 5 } })
|
||||
];
|
||||
|
||||
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
const reply = list.reply || "";
|
||||
assert.match(reply, /p-other「别人的任务」(其他成员):做到一半/);
|
||||
assert.match(reply, /p-mine「我的任务」(你的)正在执行/);
|
||||
assert.doesNotMatch(reply, /someone-else/);
|
||||
assert.doesNotMatch(reply, /g-secret-9/);
|
||||
});
|
||||
|
||||
test("synchronous work sends no delivery messages", async () => {
|
||||
|
||||
Reference in New Issue
Block a user