fix: scope proposals to chat users

This commit is contained in:
zenord
2026-08-18 20:58:08 +08:00
parent b4121c9fd6
commit 4af5049605
10 changed files with 289 additions and 122 deletions
+105 -77
View File
@@ -99,42 +99,42 @@ export class AssistantManager implements ConversationRuntime {
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const reply = await this.enqueueChat(chatKey, () => this.runAssistantTurn(chatKey, request));
const conversationKey = assistantKeyFor(chatKeyFor(request.platform, request.chatId), request.userId);
const reply = await this.enqueueChat(conversationKey, () => this.runAssistantTurn(conversationKey, request));
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
}
async cancel(platform: string, chatId: string): Promise<boolean> {
async cancel(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.cancelLocked(chatKey));
return this.scheduler(() => this.cancelLocked(chatKey, userId));
}
async confirm(platform: string, chatId: string): Promise<boolean> {
async confirm(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.confirmLocked(chatKey));
return this.scheduler(() => this.confirmLocked(chatKey, userId));
}
async stop(platform: string, chatId: string): Promise<boolean> {
async stop(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.stopActiveLocked(chatKey));
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
}
listProposals(platform: string, chatId: string): Proposal[] {
listProposals(platform: string, chatId: string, userId: string): Proposal[] {
const chatKey = chatKeyFor(platform, chatId);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
const worker = this.assistantWorkers.get(chatKey);
async reset(platform: string, chatId: string, userId: string): Promise<void> {
const conversationKey = assistantKeyFor(chatKeyFor(platform, chatId), userId);
const worker = this.assistantWorkers.get(conversationKey);
if (worker) {
this.assistantWorkers.delete(chatKey);
this.assistantWorkers.delete(conversationKey);
await worker.terminate();
}
await this.store.deleteBinding(chatKey);
await this.store.deleteBinding(conversationKey);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
const proposals = this.proposals.list();
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
@@ -148,7 +148,7 @@ export class AssistantManager implements ConversationRuntime {
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
workerRunning: Boolean(active?.worker.inFlight),
owner: Boolean(activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId))
owner: Boolean(userId && activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId) && activeProposal.requesterUserId === userId)
};
}
@@ -181,17 +181,17 @@ export class AssistantManager implements ConversationRuntime {
await Promise.allSettled([...this.chatChains.values()]);
}
private async runAssistantTurn(chatKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(chatKey);
private async runAssistantTurn(conversationKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(conversationKey);
try {
const reply = await worker.prompt(this.assistantUserPrompt(request));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
await this.executeActions(chatKey, request, parsed.actions);
await this.store.touchBinding(conversationKey);
await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
throw error;
}
}
@@ -214,23 +214,23 @@ export class AssistantManager implements ConversationRuntime {
requesterUserId: request.userId
});
} else if (action.type === "confirm") {
await this.scheduler(() => this.confirmLocked(chatKey, action.id, action.answer));
await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
} else if (action.type === "start_next") {
await this.scheduler(() => this.tryStartLocked());
await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
} else if (action.type === "cancel") {
await this.scheduler(() => this.cancelLocked(chatKey, action.id));
await this.scheduler(() => this.cancelLocked(chatKey, request.userId, action.id));
} else if (action.type === "stop") {
await this.scheduler(() => this.stopActiveLocked(chatKey));
await this.scheduler(() => this.stopActiveLocked(chatKey, request.userId));
}
}
}
private async confirmLocked(chatKey: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "awaiting_user_confirmation"]);
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "awaiting_user_confirmation"]);
if (!proposal) return false;
if (proposal.status === "proposed") {
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
await this.tryStartLocked();
await this.tryStartLocked(chatKey, userId);
return true;
}
const pending = proposal.pending;
@@ -255,31 +255,47 @@ export class AssistantManager implements ConversationRuntime {
return true;
}
private async cancelLocked(chatKey: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "queued"]);
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued"]);
if (!proposal) return false;
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
return true;
}
private async stopActiveLocked(chatKey: string): Promise<boolean> {
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
const active = this.active;
if (!active) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.ownerChatKey !== chatKey) return false;
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(proposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
if (active && activeProposal && activeProposal.ownerChatKey === chatKey && activeProposal.requesterUserId === userId) {
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(activeProposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
return true;
}
const waiting = this.resolveTargetProposal(chatKey, userId, undefined, ["awaiting_user_confirmation"]);
if (!waiting?.pending) return false;
await this.terminateActiveLocked(waiting.id);
if (waiting.pending.kind === "success") {
await this.proposals.update(waiting.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
} else if (waiting.pending.kind === "failure") {
await this.proposals.update(waiting.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
} else {
await this.proposals.update(waiting.id, {
status: "cancelled",
pending: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
}
return true;
}
@@ -294,23 +310,25 @@ export class AssistantManager implements ConversationRuntime {
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
}
private async tryStartLocked(): Promise<void> {
private async tryStartLocked(chatKey: string, userId: string): Promise<void> {
if (this.active) return;
if (this.proposals.list({ status: "working" }).length > 0) return;
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
const next = this.proposals.list({ status: "queued" })[0];
const next = this.proposals.list({ status: "queued" })
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
if (!next) return;
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
this.launchWorker(next.id);
}
private resolveTargetProposal(chatKey: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
private resolveTargetProposal(chatKey: string, userId: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
if (id) {
const proposal = this.proposals.get(id);
return proposal && proposal.ownerChatKey === chatKey && statuses.includes(proposal.status) ? proposal : undefined;
return proposal && owned(proposal) && statuses.includes(proposal.status) ? proposal : undefined;
}
return this.proposals.list()
.filter((proposal) => proposal.ownerChatKey === chatKey && statuses.includes(proposal.status))
.filter((proposal) => owned(proposal) && statuses.includes(proposal.status))
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
}
@@ -437,10 +455,11 @@ export class AssistantManager implements ConversationRuntime {
const sink = this.eventSink;
if (!proposal || !sink) return;
const chatKey = proposal.ownerChatKey;
void this.enqueueChat(chatKey, async () => {
const conversationKey = assistantKeyFor(chatKey, proposal.requesterUserId);
void this.enqueueChat(conversationKey, async () => {
if (this.shuttingDown) return;
try {
const reply = await this.runAssistantEvent(chatKey, proposal.id);
const reply = await this.runAssistantEvent(conversationKey, proposal.id);
if (reply) await sink(chatKey, reply);
} catch (error) {
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
@@ -450,37 +469,37 @@ export class AssistantManager implements ConversationRuntime {
}).catch(() => undefined);
}
private async runAssistantEvent(chatKey: string, proposalId: string): Promise<string> {
private async runAssistantEvent(conversationKey: string, proposalId: string): Promise<string> {
const proposal = this.proposals.get(proposalId);
if (!proposal?.pending) return "";
const worker = await this.acquireAssistantWorker(chatKey);
const worker = await this.acquireAssistantWorker(conversationKey);
try {
const reply = await worker.prompt(assistantEventPrompt(proposal));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
await this.store.touchBinding(conversationKey);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
throw error;
}
}
private async acquireAssistantWorker(chatKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(chatKey);
private async acquireAssistantWorker(conversationKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(conversationKey);
if (existing) {
existing.lastUsedAt = Date.now();
return existing;
}
await this.reserveAssistantCapacity();
const persisted = this.store.getBinding(chatKey);
const persisted = this.store.getBinding(conversationKey);
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
if (persisted && !binding) await this.store.deleteBinding(chatKey);
const cwd = this.prepareAssistantWorkspace(chatKey, binding);
if (persisted && !binding) await this.store.deleteBinding(conversationKey);
const cwd = this.prepareAssistantWorkspace(conversationKey, binding);
const spawnWorker = async (nativeSessionId?: string): Promise<AcpWorker> => {
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (this.assistantWorkers.get(chatKey) === crashed) this.assistantWorkers.delete(chatKey);
if (this.assistantWorkers.get(conversationKey) === crashed) this.assistantWorkers.delete(conversationKey);
console.error(`ACP assistant worker crash: ${error.message}`);
}, {
kind: "assistant",
@@ -488,7 +507,7 @@ export class AssistantManager implements ConversationRuntime {
env: this.assistantEnv(),
policy: ASSISTANT_POLICY,
onIsolationViolation: (violating) => {
if (this.assistantWorkers.get(chatKey) === violating) this.assistantWorkers.delete(chatKey);
if (this.assistantWorkers.get(conversationKey) === violating) this.assistantWorkers.delete(conversationKey);
},
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
});
@@ -501,17 +520,17 @@ export class AssistantManager implements ConversationRuntime {
worker = await spawnWorker(binding?.nativeSessionId);
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
await this.store.deleteBinding(conversationKey);
resumed = false;
worker = await spawnWorker();
}
this.assistantWorkers.set(chatKey, worker);
this.assistantWorkers.set(conversationKey, worker);
if (!resumed) {
try {
await worker.prompt(this.bot.assistantBootstrap, "initializing");
const now = Date.now();
await this.store.setBinding({
chatKey,
chatKey: conversationKey,
agentId: this.bot.agent.id,
nativeSessionId: worker.nativeSessionId!,
assistantWorkspace: cwd,
@@ -520,7 +539,7 @@ export class AssistantManager implements ConversationRuntime {
updatedAt: now
});
} catch (error) {
if (this.assistantWorkers.get(chatKey) === worker) this.assistantWorkers.delete(chatKey);
if (this.assistantWorkers.get(conversationKey) === worker) this.assistantWorkers.delete(conversationKey);
await worker.terminate().catch(() => undefined);
throw error;
}
@@ -573,20 +592,22 @@ export class AssistantManager implements ConversationRuntime {
}
private assistantUserPrompt(request: ConversationRequest): string {
const chatKey = chatKeyFor(request.platform, request.chatId);
return [
"[User message]",
request.text,
"",
"[Proposal states]",
...this.proposalSummaries(),
...this.proposalSummaries(chatKey, request.userId),
"",
"[Worker state]",
this.workerStateSummary()
this.workerStateSummary(chatKey, request.userId)
].join("\n");
}
private proposalSummaries(): string[] {
const proposals = this.proposals.list();
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) => {
const pending = proposal.pending
@@ -596,9 +617,13 @@ export class AssistantManager implements ConversationRuntime {
});
}
private workerStateSummary(): string {
private workerStateSummary(chatKey: string, userId: string): string {
const active = this.active;
if (!active) return "no active worker";
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.ownerChatKey !== chatKey || proposal.requesterUserId !== userId) {
return "busy: a worker is executing another user's proposal; details hidden";
}
const worker = active.worker;
const runningSeconds = worker.turnStartedAt ? Math.max(0, Math.floor((Date.now() - worker.turnStartedAt) / 1_000)) : 0;
return `proposal=${active.proposalId} phase=${worker.phase} inFlight=${worker.inFlight} runningSeconds=${runningSeconds}`;
@@ -620,8 +645,11 @@ export class AssistantManager implements ConversationRuntime {
}
}
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
const match = ASSISTANT_ENVELOPE.exec(text);
export function assistantKeyFor(chatKey: string, userId: string): string {
return `${chatKey}#${userId}`;
}
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined { const match = ASSISTANT_ENVELOPE.exec(text);
if (!match?.groups) return undefined;
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
+6 -6
View File
@@ -34,14 +34,14 @@ export type RuntimeEventSink = (chatKey: string, text: string) => Promise<void>;
export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>;
reset(platform: string, chatId: string): Promise<void>;
status(platform: string, chatId: string): Record<string, string | number | boolean>;
cancel(platform: string, chatId: string, userId: string): Promise<boolean>;
reset(platform: string, chatId: string, userId: string): Promise<void>;
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean>;
stats(): RuntimeStats;
shutdown(): Promise<void>;
listProposals?(platform: string, chatId: string): Proposal[];
confirm?(platform: string, chatId: string): Promise<boolean>;
stop?(platform: string, chatId: string): Promise<boolean>;
listProposals?(platform: string, chatId: string, userId: string): Proposal[];
confirm?(platform: string, chatId: string, userId: string): Promise<boolean>;
stop?(platform: string, chatId: string, userId: string): Promise<boolean>;
setEventSink?(sink: RuntimeEventSink): void;
}
+5 -5
View File
@@ -139,25 +139,25 @@ export class Gateway {
switch (command.kind) {
case "help": return HELP_TEXT;
case "status": {
const status = this.runtime.status(message.platform, message.chatId);
const status = this.runtime.status(message.platform, message.chatId, message.userId);
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
}
case "list": {
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
const proposals = this.runtime.listProposals(message.platform, message.chatId);
const proposals = this.runtime.listProposals(message.platform, message.chatId, message.userId);
if (proposals.length === 0) return "当前没有提案。";
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
}
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
}
case "stop": {
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.stop(message.platform, message.chatId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
}
case "cancel":
return await this.runtime.cancel(message.platform, message.chatId) ? "已取消最近的提案。" : "没有可取消的提案。";
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
}
}
+6 -1
View File
@@ -101,7 +101,7 @@ export class QqAdapter implements PlatformAdapter {
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
const chatId = chatIdFor(data, isGroup);
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid;
const userId = userIdFor(data, isGroup);
if (!chatId || !userId) return undefined;
return {
@@ -141,6 +141,11 @@ export class QqAdapter implements PlatformAdapter {
}
}
function userIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (isGroup) return data.author?.member_openid || data.member_openid || data.author?.user_openid || data.user_openid || data.author?.id;
return data.author?.user_openid || data.user_openid || data.author?.id || data.member_openid || data.author?.member_openid;
}
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (data.group_openid) return `group:${data.group_openid}`;
if (data.group_id) return `group:${data.group_id}`;
+4 -2
View File
@@ -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 = 4;
const BOOTSTRAP_SCHEMA_VERSION = 5;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[];
@@ -44,7 +44,9 @@ function buildAssistantBootstrap(bot: BotConfig): string {
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses."
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses.",
"In a group chat each proposal is owned by the user who requested it: only that user can confirm, stop, or cancel it. If another group member asks you to confirm, stop, or cancel a proposal they did not create, explain that only the proposal's initiator can do that and emit no action.",
"When a worker reported SUCCESS and the user says something like \"够了\", \"停止\", \"不用继续\" or \"that's enough\", treat it as accepting the finished work: emit a \"confirm\" action so the proposal settles as completed. Use \"stop\" only to abort work that is still running or waiting on an answer."
].join("\n\n");
}