fix: scope proposals to chat users
This commit is contained in:
+105
-77
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user