feat: pending-finish proposals and QQ image input
Proposal semantics: worker results no longer distinguish success/fail; only an explicit finish settles a pending proposal. Pending owner input follows up by resuming the original worker session. Dirty pending blocks start_next globally; clean pending only blocks its owner. QQ adapter now downloads image attachments and passes them as ACP image content blocks; video/file attachments degrade to link text.
This commit is contained in:
+265
-159
@@ -4,17 +4,22 @@ import path from "node:path";
|
||||
import type { AcpConfig, PermissionPolicy } from "../config.js";
|
||||
import { chatKeyFor, type AssistantBinding, type DurableSessionStore } from "../core/durable-session-store.js";
|
||||
import type { Proposal, ProposalPending, ProposalStore } from "../core/proposal-store.js";
|
||||
import type { IncomingAttachment } from "../core/types.js";
|
||||
import type { ResolvedBot } from "../roles/role-registry.js";
|
||||
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeEventSink, RuntimeStats } from "./types.js";
|
||||
import type { AcpPromptContent } from "./client.js";
|
||||
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
|
||||
|
||||
const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] };
|
||||
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/;
|
||||
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V1>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V1>\s*$/;
|
||||
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V2>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/;
|
||||
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V2>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V2>\s*$/;
|
||||
|
||||
export type AssistantAction =
|
||||
| { type: "create_proposal"; title: string; goal: string; steps: string[] }
|
||||
| { type: "confirm"; id?: string; answer?: string }
|
||||
| { type: "confirm"; id?: string }
|
||||
| { type: "adjust_proposal"; id?: string; title?: string; goal?: string; steps?: string[] }
|
||||
| { type: "follow_up"; id?: string; instruction: string }
|
||||
| { type: "finish"; id?: string; note?: string }
|
||||
| { type: "start_next" }
|
||||
| { type: "cancel"; id?: string }
|
||||
| { type: "stop" };
|
||||
@@ -24,14 +29,13 @@ export interface ParsedAssistantEnvelope {
|
||||
actions: AssistantAction[];
|
||||
}
|
||||
|
||||
export type WorkerResultStatus = "SUCCESS" | "NEEDS_CONFIRMATION" | "FAILED";
|
||||
export type WorkerResultStatus = "PENDING";
|
||||
|
||||
export interface ParsedWorkerResult {
|
||||
status: WorkerResultStatus;
|
||||
summary: string;
|
||||
question?: string;
|
||||
nextStep?: string;
|
||||
dirty?: boolean;
|
||||
workspaceDirty?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveWorker {
|
||||
@@ -41,6 +45,14 @@ interface ActiveWorker {
|
||||
run?: Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkerLaunchOptions {
|
||||
resumeSessionId?: string;
|
||||
resumeContext?: string;
|
||||
firstTurn?: string;
|
||||
freshSessionNote?: string;
|
||||
images?: IncomingAttachment[];
|
||||
}
|
||||
|
||||
export interface AssistantManagerOptions {
|
||||
assistantWorkspaceHome?: string;
|
||||
kimiCodeHome?: string;
|
||||
@@ -82,13 +94,12 @@ export class AssistantManager implements ConversationRuntime {
|
||||
console.error(`Persisted worker cleanup failed for proposal ${proposal.id} (${error instanceof Error ? error.name : "unknown error"})`);
|
||||
}
|
||||
}
|
||||
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the proposal was marked failed";
|
||||
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the workspace may be dirty";
|
||||
await this.proposals.update(proposal.id, {
|
||||
status: "failed",
|
||||
pending: { kind: "failure", summary },
|
||||
status: "pending",
|
||||
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
|
||||
lastWorkerSummary: summary,
|
||||
workerProcessGroup: undefined,
|
||||
finishedAt: Date.now()
|
||||
workerProcessGroup: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,6 +125,11 @@ export class AssistantManager implements ConversationRuntime {
|
||||
return this.scheduler(() => this.confirmLocked(chatKey, userId));
|
||||
}
|
||||
|
||||
async finish(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||
const chatKey = chatKeyFor(platform, chatId);
|
||||
return this.scheduler(() => this.finishLocked(chatKey, userId));
|
||||
}
|
||||
|
||||
async stop(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||
const chatKey = chatKeyFor(platform, chatId);
|
||||
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
|
||||
@@ -141,19 +157,22 @@ export class AssistantManager implements ConversationRuntime {
|
||||
const mine = proposals.filter(owned);
|
||||
const active = this.active;
|
||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||
const awaiting = proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation");
|
||||
const pending = proposals.filter((proposal) => proposal.status === "pending");
|
||||
const working = proposals.filter((proposal) => proposal.status === "working");
|
||||
const myQueued = mine.filter((proposal) => proposal.status === "queued").length;
|
||||
const myPending = mine.filter((proposal) => proposal.status === "awaiting_user_confirmation").length;
|
||||
const myPending = mine.filter((proposal) => proposal.status === "pending").length;
|
||||
const myProposed = mine.filter((proposal) => proposal.status === "proposed").length;
|
||||
const schedulerState = active || working.length > 0 ? "working" : awaiting.length > 0 ? "awaiting_confirmation" : "idle";
|
||||
const blockedReason = awaiting.length > 0
|
||||
? (awaiting.some(owned) ? "your proposal is awaiting your confirmation" : "another proposal is awaiting owner confirmation")
|
||||
: active || working.length > 0
|
||||
? (working.some(owned) || (activeProposal && owned(activeProposal)) ? "your proposal is working" : "another proposal is working")
|
||||
: "none";
|
||||
const dirtyPending = pending.some((proposal) => proposal.pending?.workspaceDirty);
|
||||
const schedulerState = active || working.length > 0 ? "working" : pending.length > 0 ? "pending" : "idle";
|
||||
const blockedReason = active || working.length > 0
|
||||
? (working.some(owned) || (activeProposal && owned(activeProposal)) ? "your proposal is working" : "another proposal is working")
|
||||
: myPending > 0
|
||||
? "your proposal is waiting for your decision"
|
||||
: dirtyPending
|
||||
? "a pending proposal left a dirty workspace"
|
||||
: "none";
|
||||
const nextAction = myPending > 0
|
||||
? "confirm your pending proposal"
|
||||
? "finish your pending proposal or follow up with new instructions"
|
||||
: myProposed > 0
|
||||
? "confirm your proposed proposal to queue it"
|
||||
: myQueued > 0
|
||||
@@ -167,11 +186,11 @@ export class AssistantManager implements ConversationRuntime {
|
||||
proposals: proposals.length,
|
||||
queuedProposals: proposals.filter((proposal) => proposal.status === "queued").length,
|
||||
workingProposals: working.length,
|
||||
awaitingConfirmation: awaiting.length,
|
||||
pendingProposals: pending.length,
|
||||
workerRunning: Boolean(active?.worker.inFlight),
|
||||
owner: Boolean(userId && activeProposal && owned(activeProposal)),
|
||||
myQueuedProposals: myQueued,
|
||||
myPendingConfirmations: myPending,
|
||||
myPendingProposals: myPending,
|
||||
schedulerState,
|
||||
blockedReason,
|
||||
nextAction
|
||||
@@ -210,22 +229,43 @@ export class AssistantManager implements ConversationRuntime {
|
||||
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 reply = await worker.prompt(this.assistantPromptContent(worker, request));
|
||||
const parsed = await this.parseAssistantReply(worker, reply);
|
||||
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
||||
await this.store.touchBinding(conversationKey);
|
||||
const corrections = await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
|
||||
return corrections.length > 0 ? `${parsed.reply}\n${corrections.join("\n")}` : parsed.reply;
|
||||
const chatKey = chatKeyFor(request.platform, request.chatId);
|
||||
const corrections = await this.executeActions(chatKey, request, parsed.actions);
|
||||
const reminders = this.pendingReminders(chatKey, request.userId, parsed.reply);
|
||||
const extras = [...corrections, ...reminders];
|
||||
return extras.length > 0 ? `${parsed.reply}\n${extras.join("\n")}` : parsed.reply;
|
||||
} catch (error) {
|
||||
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private pendingReminders(chatKey: string, userId: string, reply: string): string[] {
|
||||
return this.proposals.list({ status: "pending" })
|
||||
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId)
|
||||
.filter((proposal) => !reply.includes(proposal.title))
|
||||
.map((proposal) => `提醒:你还有一个待确认的任务「${proposal.title}」,说 finish 结束,或直接继续说要求继续改。`);
|
||||
}
|
||||
|
||||
private assistantPromptContent(worker: AcpWorker, request: ConversationRequest): string | AcpPromptContent[] {
|
||||
const text = this.assistantUserPrompt(request);
|
||||
const images = request.attachments || [];
|
||||
if (images.length === 0) return text;
|
||||
if (!worker.supportsImages) return `${text}\n\n[Attachments]\n${images.map(() => "[图片,当前 agent 不支持图片输入]").join("\n")}`;
|
||||
return [
|
||||
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||
{ type: "text" as const, text }
|
||||
];
|
||||
}
|
||||
|
||||
private async parseAssistantReply(worker: AcpWorker, reply: string): Promise<ParsedAssistantEnvelope> {
|
||||
let parsed = parseAssistantActions(reply);
|
||||
if (!parsed) parsed = parseAssistantActions(await worker.prompt(assistantRepairPrompt()));
|
||||
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V1 envelope after one repair attempt");
|
||||
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V2 envelope after one repair attempt");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -241,8 +281,17 @@ export class AssistantManager implements ConversationRuntime {
|
||||
requesterUserId: request.userId
|
||||
});
|
||||
} else if (action.type === "confirm") {
|
||||
const ok = await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
|
||||
const ok = await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id));
|
||||
if (!ok) corrections.push("没有可确认的提案;你只能操作自己发起的任务。");
|
||||
} else if (action.type === "adjust_proposal") {
|
||||
const ok = await this.scheduler(() => this.adjustProposalLocked(chatKey, request.userId, action));
|
||||
if (!ok) corrections.push("没有可调整的提案;只有还没开始执行的任务可以调整,且你只能操作自己发起的任务。");
|
||||
} else if (action.type === "follow_up") {
|
||||
const ok = await this.scheduler(() => this.followUpLocked(chatKey, request.userId, action.id, action.instruction, request.attachments));
|
||||
if (!ok) corrections.push("现在没法继续:只有待确认的任务可以继续,且你只能操作自己发起的任务;如果有别的任务正在执行,也要先等它落定。");
|
||||
} else if (action.type === "finish") {
|
||||
const ok = await this.scheduler(() => this.finishLocked(chatKey, request.userId, action.id, action.note));
|
||||
if (!ok) corrections.push("没有可结束的任务;只有待确认的任务可以 finish,且你只能操作自己发起的任务。");
|
||||
} else if (action.type === "start_next") {
|
||||
const started = await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
|
||||
if (!started) corrections.push(this.startNextCorrection(chatKey, request.userId));
|
||||
@@ -258,89 +307,99 @@ export class AssistantManager implements ConversationRuntime {
|
||||
}
|
||||
|
||||
private startNextCorrection(chatKey: string, userId: string): string {
|
||||
const queuedMine = this.proposals.list({ status: "queued" })
|
||||
.some((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
const queuedMine = this.proposals.list({ status: "queued" }).some(owned);
|
||||
const kept = queuedMine ? "你的任务已保留在队列里。" : "";
|
||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) {
|
||||
return `还不能开始:还有一个任务在等发起人确认。${kept}`.trim();
|
||||
}
|
||||
if (this.active || this.proposals.list({ status: "working" }).length > 0) {
|
||||
return `还不能开始:还有一个任务正在执行。${kept}`.trim();
|
||||
}
|
||||
if (this.proposals.list({ status: "pending" }).some(owned)) {
|
||||
return `还不能开始:你有任务还在等你确认,先 finish 或直接继续说要求。${kept}`.trim();
|
||||
}
|
||||
if (this.proposals.list({ status: "pending" }).some((proposal) => proposal.pending?.workspaceDirty)) {
|
||||
return `还不能开始:有任务留下了未整理的工作区,需要先处理 dirty 工作区。${kept}`.trim();
|
||||
}
|
||||
return "还不能开始:你没有已确认并排队的任务。";
|
||||
}
|
||||
|
||||
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "awaiting_user_confirmation"]);
|
||||
private async confirmLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed"]);
|
||||
if (!proposal) return false;
|
||||
if (proposal.status === "proposed") {
|
||||
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
|
||||
await this.tryStartLocked(chatKey, userId);
|
||||
return true;
|
||||
}
|
||||
const pending = proposal.pending;
|
||||
if (!pending) return false;
|
||||
if (pending.kind === "success") {
|
||||
await this.terminateActiveLocked(proposal.id);
|
||||
await this.proposals.update(proposal.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
|
||||
return true;
|
||||
}
|
||||
if (pending.kind === "failure") {
|
||||
await this.terminateActiveLocked(proposal.id);
|
||||
if (answer && answer.trim().toLowerCase() === "retry") {
|
||||
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
|
||||
this.launchWorker(proposal.id);
|
||||
} else {
|
||||
await this.proposals.update(proposal.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
await this.proposals.update(proposal.id, { status: "working", pending: undefined });
|
||||
this.continueWorker(proposal.id, answer || "Confirmed. Please continue.", pending.question);
|
||||
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
|
||||
await this.tryStartLocked(chatKey, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async adjustProposalLocked(chatKey: string, userId: string, action: { id?: string; title?: string; goal?: string; steps?: string[] }): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, action.id, ["proposed", "queued"]);
|
||||
if (!proposal) return false;
|
||||
const patch: { title?: string; goal?: string; steps?: string[] } = {};
|
||||
if (action.title !== undefined) patch.title = action.title;
|
||||
if (action.goal !== undefined) patch.goal = action.goal;
|
||||
if (action.steps !== undefined) patch.steps = action.steps;
|
||||
if (Object.keys(patch).length === 0) return false;
|
||||
await this.proposals.update(proposal.id, patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async followUpLocked(chatKey: string, userId: string, id: string | undefined, instruction: string, attachments?: IncomingAttachment[]): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["pending"]);
|
||||
if (!proposal) return false;
|
||||
if (this.active || this.proposals.list({ status: "working" }).length > 0) return false;
|
||||
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
|
||||
this.launchWorker(proposal.id, {
|
||||
resumeSessionId: proposal.workerNativeSessionId,
|
||||
firstTurn: workerFollowUpPrompt(instruction, proposal.pending?.question),
|
||||
freshSessionNote: "The previous worker session could not be resumed; this is a fresh session continuing the same proposal.",
|
||||
images: attachments
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async finishLocked(chatKey: string, userId: string, id?: string, note?: string): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["pending"]);
|
||||
if (!proposal) return false;
|
||||
await this.terminateActiveLocked(proposal.id);
|
||||
await this.proposals.update(proposal.id, {
|
||||
status: "finished",
|
||||
finishKind: "done",
|
||||
finishNote: note,
|
||||
pending: undefined,
|
||||
finishedAt: Date.now()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued"]);
|
||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued", "pending"]);
|
||||
if (!proposal) return false;
|
||||
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
|
||||
await this.terminateActiveLocked(proposal.id);
|
||||
await this.proposals.update(proposal.id, {
|
||||
status: "finished",
|
||||
finishKind: "cancelled",
|
||||
pending: undefined,
|
||||
finishedAt: Date.now()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
|
||||
const active = this.active;
|
||||
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()
|
||||
});
|
||||
}
|
||||
if (!active || !activeProposal || activeProposal.ownerChatKey !== chatKey || activeProposal.requesterUserId !== userId) 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);
|
||||
const summary = "被用户中止";
|
||||
await this.proposals.update(activeProposal.id, {
|
||||
status: "pending",
|
||||
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
|
||||
workerProcessGroup: undefined,
|
||||
lastWorkerSummary: summary
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -358,7 +417,9 @@ export class AssistantManager implements ConversationRuntime {
|
||||
private async tryStartLocked(chatKey: string, userId: string): Promise<boolean> {
|
||||
if (this.active) return false;
|
||||
if (this.proposals.list({ status: "working" }).length > 0) return false;
|
||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return false;
|
||||
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
if (this.proposals.list({ status: "pending" }).some(owned)) return false;
|
||||
if (this.proposals.list({ status: "pending" }).some((proposal) => proposal.pending?.workspaceDirty)) return false;
|
||||
const next = this.proposals.list({ status: "queued" })
|
||||
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
if (!next) return false;
|
||||
@@ -378,10 +439,18 @@ export class AssistantManager implements ConversationRuntime {
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
||||
}
|
||||
|
||||
private launchWorker(proposalId: string, resumeContext?: string): void {
|
||||
private launchWorker(proposalId: string, options: WorkerLaunchOptions = {}): void {
|
||||
const proposal = this.proposals.get(proposalId);
|
||||
if (!proposal || proposal.status !== "working") return;
|
||||
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
|
||||
const active: ActiveWorker = { proposalId, worker: this.spawnProposalWorker(proposalId), settled: false };
|
||||
this.active = active;
|
||||
const run = this.startWorkerRun(active, options);
|
||||
active.run = run;
|
||||
void run.catch((error) => this.handleWorkerRunFailure(active, error));
|
||||
}
|
||||
|
||||
private spawnProposalWorker(proposalId: string): AcpWorker {
|
||||
return new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
|
||||
kind: "worker",
|
||||
cwd: this.bot.workspace,
|
||||
onSpawn: async (group) => {
|
||||
@@ -389,42 +458,37 @@ export class AssistantManager implements ConversationRuntime {
|
||||
await this.proposals.update(proposalId, { workerProcessGroup: group });
|
||||
}
|
||||
});
|
||||
const active: ActiveWorker = { proposalId, worker, settled: false };
|
||||
this.active = active;
|
||||
const run = this.startWorkerRun(active, resumeContext);
|
||||
active.run = run;
|
||||
void run.catch((error) => this.handleWorkerRunFailure(active, error));
|
||||
}
|
||||
|
||||
private continueWorker(proposalId: string, answer: string, question?: string): void {
|
||||
const active = this.active;
|
||||
if (active && active.proposalId === proposalId && active.worker.nativeSessionId) {
|
||||
active.settled = false;
|
||||
const run = this.runWorkerTurn(active, workerAnswerPrompt(answer, question));
|
||||
active.run = run;
|
||||
void run.catch((error) => this.handleWorkerRunFailure(active, error));
|
||||
return;
|
||||
private async startWorkerRun(active: ActiveWorker, options: WorkerLaunchOptions): Promise<void> {
|
||||
if (options.resumeSessionId) {
|
||||
try {
|
||||
await active.worker.start(options.resumeSessionId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AcpSessionRestoreError) || this.active !== active || active.settled) throw error;
|
||||
active.worker = this.spawnProposalWorker(active.proposalId);
|
||||
await active.worker.start();
|
||||
options = {
|
||||
...options,
|
||||
resumeContext: [options.resumeContext, options.freshSessionNote].filter(Boolean).join("\n"),
|
||||
firstTurn: options.firstTurn && options.freshSessionNote ? `${options.firstTurn}\n\n${options.freshSessionNote}` : options.firstTurn
|
||||
};
|
||||
}
|
||||
} else {
|
||||
await active.worker.start();
|
||||
}
|
||||
const proposal = this.proposals.get(proposalId);
|
||||
const context = proposal
|
||||
? `The previous worker was lost. Its last summary: ${proposal.lastWorkerSummary || "unknown"}. Its question: ${question || "unknown"}. The user answered: ${answer}`
|
||||
: answer;
|
||||
this.launchWorker(proposalId, context);
|
||||
}
|
||||
|
||||
private async startWorkerRun(active: ActiveWorker, resumeContext?: string): Promise<void> {
|
||||
const nativeSessionId = await active.worker.start();
|
||||
if (this.active !== active || active.settled) throw new Error("Worker was superseded before its session started");
|
||||
await this.proposals.update(active.proposalId, { workerNativeSessionId: nativeSessionId });
|
||||
await this.proposals.update(active.proposalId, { workerNativeSessionId: active.worker.nativeSessionId });
|
||||
await active.worker.prompt(this.bot.workerBootstrap, "initializing");
|
||||
if (this.active !== active || active.settled) throw new Error("Worker was superseded during bootstrap");
|
||||
const proposal = this.proposals.get(active.proposalId);
|
||||
if (!proposal) throw new Error("Worker proposal no longer exists");
|
||||
await this.runWorkerTurn(active, workerTaskPrompt(proposal, resumeContext));
|
||||
const firstTurn = options.firstTurn || workerTaskPrompt(proposal, options.resumeContext);
|
||||
await this.runWorkerTurn(active, firstTurn, options.images);
|
||||
}
|
||||
|
||||
private async runWorkerTurn(active: ActiveWorker, promptText: string): Promise<void> {
|
||||
const reply = await active.worker.prompt(withWorkerResultProtocol(promptText));
|
||||
private async runWorkerTurn(active: ActiveWorker, promptText: string, images?: IncomingAttachment[]): Promise<void> {
|
||||
const reply = await active.worker.prompt(withWorkerResultProtocol(this.workerPromptContent(active.worker, promptText, images)));
|
||||
if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn");
|
||||
let result = parseWorkerResult(reply);
|
||||
if (!result) {
|
||||
@@ -432,10 +496,19 @@ export class AssistantManager implements ConversationRuntime {
|
||||
if (this.active !== active || active.settled) throw new Error("Worker was superseded during result repair");
|
||||
result = parseWorkerResult(repaired);
|
||||
}
|
||||
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V1 envelope after one repair attempt");
|
||||
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V2 envelope after one repair attempt");
|
||||
await this.settleWorkerResult(active, result);
|
||||
}
|
||||
|
||||
private workerPromptContent(worker: AcpWorker, text: string, images?: IncomingAttachment[]): string | AcpPromptContent[] {
|
||||
if (!images || images.length === 0) return text;
|
||||
if (!worker.supportsImages) return `${text}\n\n[Attachments]\n${images.map(() => "[图片,当前 agent 不支持图片输入]").join("\n")}`;
|
||||
return [
|
||||
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||
{ type: "text" as const, text }
|
||||
];
|
||||
}
|
||||
|
||||
private async settleWorkerResult(active: ActiveWorker, result: ParsedWorkerResult): Promise<void> {
|
||||
if (this.active !== active || active.settled || this.shuttingDown) return;
|
||||
const transitioned = await this.scheduler(async () => {
|
||||
@@ -443,21 +516,20 @@ export class AssistantManager implements ConversationRuntime {
|
||||
const proposal = this.proposals.get(active.proposalId);
|
||||
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
|
||||
active.settled = true;
|
||||
const pending: ProposalPending = result.status === "SUCCESS"
|
||||
? { kind: "success", summary: result.summary }
|
||||
: result.status === "FAILED"
|
||||
? { kind: "failure", summary: result.summary }
|
||||
: { kind: "step", summary: result.summary, question: result.question, nextStep: result.nextStep };
|
||||
this.active = undefined;
|
||||
const pending: ProposalPending = {
|
||||
summary: result.summary,
|
||||
question: result.question,
|
||||
workspaceDirty: result.workspaceDirty,
|
||||
receivedAt: Date.now()
|
||||
};
|
||||
await this.proposals.update(active.proposalId, {
|
||||
status: "awaiting_user_confirmation",
|
||||
status: "pending",
|
||||
pending,
|
||||
lastWorkerSummary: result.summary
|
||||
});
|
||||
if (result.status !== "NEEDS_CONFIRMATION") {
|
||||
this.active = undefined;
|
||||
await active.worker.terminate().catch(() => undefined);
|
||||
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
|
||||
}
|
||||
await active.worker.terminate().catch(() => undefined);
|
||||
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
|
||||
return true;
|
||||
});
|
||||
if (transitioned) await this.notifyOwner(active.proposalId);
|
||||
@@ -475,8 +547,8 @@ export class AssistantManager implements ConversationRuntime {
|
||||
this.active = undefined;
|
||||
const summary = `worker_error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
await this.proposals.update(active.proposalId, {
|
||||
status: "awaiting_user_confirmation",
|
||||
pending: { kind: "failure", summary },
|
||||
status: "pending",
|
||||
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
|
||||
lastWorkerSummary: summary,
|
||||
workerProcessGroup: undefined
|
||||
});
|
||||
@@ -646,6 +718,9 @@ export class AssistantManager implements ConversationRuntime {
|
||||
"[Proposal states]",
|
||||
...this.proposalSummaries(chatKey, request.userId),
|
||||
"",
|
||||
"[Pending proposals]",
|
||||
...this.pendingCards(chatKey, request.userId),
|
||||
"",
|
||||
"[Scheduler state]",
|
||||
this.schedulerStateSummary(chatKey, request.userId),
|
||||
"",
|
||||
@@ -654,15 +729,26 @@ export class AssistantManager implements ConversationRuntime {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private pendingCards(chatKey: string, userId: string): string[] {
|
||||
const pending = this.proposals.list({ status: "pending" })
|
||||
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
if (pending.length === 0) return ["none"];
|
||||
return pending.map((proposal) => {
|
||||
const card = proposal.pending!;
|
||||
return `- id=${proposal.id} title=${JSON.stringify(proposal.title)} summary=${JSON.stringify(card.summary)}${card.question ? ` question=${JSON.stringify(card.question)}` : ""}${card.workspaceDirty ? " workspaceDirty=true" : ""}`;
|
||||
});
|
||||
}
|
||||
|
||||
private schedulerStateSummary(chatKey: string, userId: string): string {
|
||||
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
const awaitingOther = this.proposals.list({ status: "awaiting_user_confirmation" }).some((proposal) => !owned(proposal));
|
||||
if (awaitingOther) return "blocked: another proposal is awaiting owner confirmation";
|
||||
const active = this.active;
|
||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||
const workingOther = this.proposals.list({ status: "working" }).some((proposal) => !owned(proposal))
|
||||
|| Boolean(activeProposal && !owned(activeProposal));
|
||||
if (workingOther) return "busy: another proposal is working";
|
||||
const dirtyOther = this.proposals.list({ status: "pending" })
|
||||
.some((proposal) => !owned(proposal) && proposal.pending?.workspaceDirty);
|
||||
if (dirtyOther) return "blocked: another proposal left a dirty workspace that needs attention";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
@@ -672,7 +758,7 @@ export class AssistantManager implements ConversationRuntime {
|
||||
if (proposals.length === 0) return ["none"];
|
||||
return proposals.map((proposal) => {
|
||||
const pending = proposal.pending
|
||||
? ` pending=${proposal.pending.kind} summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
|
||||
? ` 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}`;
|
||||
});
|
||||
@@ -733,8 +819,30 @@ function parseAssistantAction(value: unknown): AssistantAction | undefined {
|
||||
return { type: "create_proposal", title: value.title, goal: value.goal, steps: value.steps as string[] };
|
||||
}
|
||||
if (value.type === "confirm") {
|
||||
if ((value.id !== undefined && typeof value.id !== "string") || (value.answer !== undefined && typeof value.answer !== "string")) return undefined;
|
||||
return { type: "confirm", id: value.id as string | undefined, answer: value.answer as string | undefined };
|
||||
if (value.id !== undefined && typeof value.id !== "string") return undefined;
|
||||
return { type: "confirm", id: value.id as string | undefined };
|
||||
}
|
||||
if (value.type === "adjust_proposal") {
|
||||
if ((value.id !== undefined && typeof value.id !== "string")
|
||||
|| (value.title !== undefined && (typeof value.title !== "string" || value.title.length === 0))
|
||||
|| (value.goal !== undefined && (typeof value.goal !== "string" || value.goal.length === 0))
|
||||
|| (value.steps !== undefined && (!Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "string" || step.length === 0)))) return undefined;
|
||||
return {
|
||||
type: "adjust_proposal",
|
||||
id: value.id as string | undefined,
|
||||
title: value.title as string | undefined,
|
||||
goal: value.goal as string | undefined,
|
||||
steps: value.steps as string[] | undefined
|
||||
};
|
||||
}
|
||||
if (value.type === "follow_up") {
|
||||
if (typeof value.instruction !== "string" || value.instruction.length === 0
|
||||
|| (value.id !== undefined && typeof value.id !== "string")) return undefined;
|
||||
return { type: "follow_up", id: value.id as string | undefined, instruction: value.instruction };
|
||||
}
|
||||
if (value.type === "finish") {
|
||||
if ((value.id !== undefined && typeof value.id !== "string") || (value.note !== undefined && typeof value.note !== "string")) return undefined;
|
||||
return { type: "finish", id: value.id as string | undefined, note: value.note as string | undefined };
|
||||
}
|
||||
if (value.type === "start_next") return { type: "start_next" };
|
||||
if (value.type === "cancel") {
|
||||
@@ -751,29 +859,29 @@ export function parseWorkerResult(text: string): ParsedWorkerResult | undefined
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
|
||||
if (!isRecord(value) || typeof value.summary !== "string") return undefined;
|
||||
if (value.status !== "SUCCESS" && value.status !== "NEEDS_CONFIRMATION" && value.status !== "FAILED") return undefined;
|
||||
if (value.status !== "PENDING") return undefined;
|
||||
if ((value.question !== undefined && typeof value.question !== "string")
|
||||
|| (value.nextStep !== undefined && typeof value.nextStep !== "string")
|
||||
|| (value.dirty !== undefined && typeof value.dirty !== "boolean")) return undefined;
|
||||
|| (value.workspaceDirty !== undefined && typeof value.workspaceDirty !== "boolean")) return undefined;
|
||||
return {
|
||||
status: value.status,
|
||||
status: "PENDING",
|
||||
summary: value.summary,
|
||||
question: value.question as string | undefined,
|
||||
nextStep: value.nextStep as string | undefined,
|
||||
dirty: value.dirty as boolean | undefined
|
||||
workspaceDirty: value.workspaceDirty as boolean | undefined
|
||||
};
|
||||
}
|
||||
|
||||
function withWorkerResultProtocol(text: string): string {
|
||||
return `${text}\n\nWhen this turn is finished, end your response with exactly one hidden worker result envelope. Use <GORI_WORKER_RESULT_V1>{"status":"SUCCESS","summary":"..."}</GORI_WORKER_RESULT_V1> only when the proposal is fully complete, status "FAILED" with a summary when it cannot be completed, or status "NEEDS_CONFIRMATION" with a "question" when user confirmation is required before continuing. Optional fields: "nextStep", "dirty". Do not emit any other status value or any text after the envelope.`;
|
||||
function withWorkerResultProtocol(input: string | AcpPromptContent[]): string | AcpPromptContent[] {
|
||||
const instruction = "When this turn is finished, end your response with exactly one hidden worker result envelope: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. The summary is a short user-readable description of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing, and set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. PENDING is the only status; do not emit any other status value or any text after the envelope.";
|
||||
if (typeof input === "string") return `${input}\n\n${instruction}`;
|
||||
return [...input, { type: "text" as const, text: `\n\n${instruction}` }];
|
||||
}
|
||||
|
||||
function workerRepairPrompt(): string {
|
||||
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V1 envelope. Return exactly one valid envelope with status SUCCESS, NEEDS_CONFIRMATION, or FAILED and a summary. Do not perform more work.";
|
||||
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V2 envelope. Return exactly one valid envelope with status PENDING and a summary. Do not perform more work.";
|
||||
}
|
||||
|
||||
function assistantRepairPrompt(): string {
|
||||
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
|
||||
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
|
||||
}
|
||||
|
||||
function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
|
||||
@@ -785,15 +893,15 @@ function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
|
||||
...proposal.steps.map((step, index) => `${index + 1}. ${step}`),
|
||||
...(resumeContext ? ["", "Additional context:", resumeContext] : []),
|
||||
"",
|
||||
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report NEEDS_CONFIRMATION with a clear question instead of forcing the change."
|
||||
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report PENDING with a clear question instead of forcing the change."
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function workerAnswerPrompt(answer: string, question?: string): string {
|
||||
function workerFollowUpPrompt(instruction: string, question?: string): string {
|
||||
return [
|
||||
"The user answered your question about the current proposal.",
|
||||
`Your question was: ${question || "unknown"}`,
|
||||
`User answer: ${answer}`,
|
||||
"The user sent a follow-up instruction for the current proposal.",
|
||||
question ? `Your last question was: ${question}` : "You had no open question.",
|
||||
`User instruction: ${instruction}`,
|
||||
"Continue executing the same proposal accordingly."
|
||||
].join("\n");
|
||||
}
|
||||
@@ -802,18 +910,16 @@ function assistantEventPrompt(proposal: Proposal): string {
|
||||
const pending = proposal.pending!;
|
||||
return [
|
||||
"[Internal event from gori-agent. This is not a user message.]",
|
||||
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported ${pending.kind === "step" ? "NEEDS_CONFIRMATION" : pending.kind === "success" ? "SUCCESS" : "FAILED"}.`,
|
||||
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported its result and the proposal is now waiting for the user's decision.`,
|
||||
`Summary: ${pending.summary}`,
|
||||
pending.question ? `Question for the user: ${pending.question}` : "Question for the user: none",
|
||||
pending.nextStep ? `Suggested next step: ${pending.nextStep}` : "Suggested next step: none",
|
||||
"Tell the user, in your own words, what happened and what they can do next (confirm, answer the question, retry, or stop). End with the usual GORI_ASSISTANT_ACTION_V1 envelope; its actions array MUST be empty because actions are ignored for internal events."
|
||||
"Tell the user, in your own words, what happened and what they can do next (finish to close the task, follow up with new instructions to continue it, or stop). End with the usual GORI_ASSISTANT_ACTION_V2 envelope; its actions array MUST be empty because actions are ignored for internal events."
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function fallbackEventText(proposal: Proposal): string {
|
||||
const pending = proposal.pending!;
|
||||
const state = pending.kind === "step" ? "等待你回答一个问题" : pending.kind === "success" ? "执行完成,待你确认" : "执行失败,待你确认或重试";
|
||||
return `任务「${proposal.title}」${state}。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""}`;
|
||||
return `任务「${proposal.title}」等你确认。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""} 说 finish 结束,或直接说要求继续改。`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
+11
-2
@@ -6,6 +6,10 @@ import type { PermissionPolicy } from "../config.js";
|
||||
|
||||
export type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
|
||||
|
||||
export type AcpPromptContent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string };
|
||||
|
||||
export interface AcpClientOptions {
|
||||
initializeTimeoutMs: number;
|
||||
policy: PermissionPolicy;
|
||||
@@ -88,9 +92,10 @@ export class AcpClient {
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
|
||||
async prompt(input: string | AcpPromptContent[], cancellationSignal?: AbortSignal): Promise<string> {
|
||||
if (!this.activeSessionId) throw new Error("ACP session is not active");
|
||||
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
|
||||
const content: AcpPromptContent[] = typeof input === "string" ? [{ type: "text", text: input }] : input;
|
||||
this.chunks = [];
|
||||
this.collecting = true;
|
||||
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
|
||||
@@ -98,7 +103,7 @@ export class AcpClient {
|
||||
await Promise.race([
|
||||
this.connection.agent.request(acp.methods.agent.session.prompt, {
|
||||
sessionId: this.activeSessionId,
|
||||
prompt: [{ type: "text", text }]
|
||||
prompt: content
|
||||
}, cancellationSignal ? { cancellationSignal } : undefined),
|
||||
violation
|
||||
]);
|
||||
@@ -110,6 +115,10 @@ export class AcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
supportsImageInput(): boolean {
|
||||
return this.capabilities.promptCapabilities?.image === true;
|
||||
}
|
||||
|
||||
async settleIsolation(): Promise<void> {
|
||||
if (!this.options.forbidToolActivity) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PermissionPolicy } from "../config.js";
|
||||
import type { Proposal } from "../core/proposal-store.js";
|
||||
import type { IncomingAttachment } from "../core/types.js";
|
||||
|
||||
export interface AcpBackendSpec {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ export interface ConversationRequest {
|
||||
userId: string;
|
||||
text: string;
|
||||
messageId?: string;
|
||||
attachments?: IncomingAttachment[];
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
@@ -41,6 +43,7 @@ export interface ConversationRuntime {
|
||||
shutdown(): Promise<void>;
|
||||
listProposals?(platform: string, chatId: string, userId: string): Proposal[];
|
||||
confirm?(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||
finish?(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||
stop?(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||
setEventSink?(sink: RuntimeEventSink): void;
|
||||
}
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
||||
import type { AcpConfig, PermissionPolicy } from "../config.js";
|
||||
import type { WorkerProcessGroup } from "../core/proposal-store.js";
|
||||
import type { ResolvedBot } from "../roles/role-registry.js";
|
||||
import { AcpClient, type SafeActivityCategory } from "./client.js";
|
||||
import { AcpClient, type AcpPromptContent, type SafeActivityCategory } from "./client.js";
|
||||
|
||||
export class AcpSessionRestoreError extends Error {}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class AcpWorker {
|
||||
nativeSessionId?: string;
|
||||
processGroup?: WorkerProcessGroup;
|
||||
isolationViolated = false;
|
||||
supportsImages = false;
|
||||
lastUsedAt = Date.now();
|
||||
turnStartedAt?: number;
|
||||
lastActivityAt?: number;
|
||||
@@ -94,6 +95,7 @@ export class AcpWorker {
|
||||
this.processGroup = { pgid: this.child.pid, token: processToken };
|
||||
await this.options.onSpawn?.(this.processGroup);
|
||||
const initialized = await this.client.initialize();
|
||||
this.supportsImages = this.client.supportsImageInput();
|
||||
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
|
||||
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export class AcpWorker {
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(text: string, phase: Exclude<AcpWorkerPhase, "idle"> = "processing"): Promise<string> {
|
||||
async prompt(input: string | AcpPromptContent[], phase: Exclude<AcpWorkerPhase, "idle"> = "processing"): Promise<string> {
|
||||
if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available");
|
||||
if (this.inFlight) throw new Error("ACP worker already has an in-flight turn");
|
||||
const now = Date.now();
|
||||
@@ -131,7 +133,7 @@ export class AcpWorker {
|
||||
}, this.config.promptTimeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
|
||||
const result = await Promise.race([this.client.prompt(input, this.abort.signal), timeoutPromise]);
|
||||
if (this.kind === "assistant") await this.client.settleIsolation();
|
||||
return result;
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CommandKind = "help" | "status" | "list" | "confirm" | "stop" | "cancel";
|
||||
export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel";
|
||||
|
||||
export interface ParsedCommand {
|
||||
kind: CommandKind;
|
||||
@@ -14,6 +14,7 @@ export class CommandRouter {
|
||||
case "/status": return { kind: "status" };
|
||||
case "/list": return { kind: "list" };
|
||||
case "/confirm": return { kind: "confirm" };
|
||||
case "/finish": return { kind: "finish" };
|
||||
case "/stop": return { kind: "stop" };
|
||||
case "/cancel": return { kind: "cancel" };
|
||||
default: return undefined;
|
||||
|
||||
+53
-8
@@ -3,6 +3,7 @@ import type { GatewayPolicy } from "../config.js";
|
||||
import type { PlatformAdapter } from "./adapter.js";
|
||||
import { CommandRouter, type ParsedCommand } from "./command-router.js";
|
||||
import { chatKeyFor } from "./durable-session-store.js";
|
||||
import type { Proposal } from "./proposal-store.js";
|
||||
import type { IncomingMessage, MessageTarget } from "./types.js";
|
||||
|
||||
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
|
||||
@@ -114,7 +115,8 @@ export class Gateway {
|
||||
chatId: message.chatId,
|
||||
userId: message.userId,
|
||||
text: message.text,
|
||||
messageId: message.messageId
|
||||
messageId: message.messageId,
|
||||
attachments: message.attachments
|
||||
})).text;
|
||||
await this.replyStream(chatKey, message, adapter).enqueue(reply);
|
||||
return { ok: true, reply };
|
||||
@@ -237,7 +239,8 @@ export class Gateway {
|
||||
chatId: message.chatId,
|
||||
userId: message.userId,
|
||||
text: message.text,
|
||||
messageId: message.messageId
|
||||
messageId: message.messageId,
|
||||
attachments: message.attachments
|
||||
})).text;
|
||||
return { ok: true, reply };
|
||||
} catch (error) {
|
||||
@@ -256,17 +259,19 @@ export class Gateway {
|
||||
}
|
||||
case "list": {
|
||||
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
|
||||
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");
|
||||
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId));
|
||||
}
|
||||
case "confirm": {
|
||||
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
|
||||
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
|
||||
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,加入队列。" : "当前没有待确认的提案。";
|
||||
}
|
||||
case "finish": {
|
||||
if (!this.runtime.finish) return "当前运行时不支持结束操作。";
|
||||
return await this.runtime.finish(message.platform, message.chatId, message.userId) ? "已结束该任务。" : "当前没有待结束的任务。";
|
||||
}
|
||||
case "stop": {
|
||||
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
|
||||
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
|
||||
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务,任务转为待确认。" : "当前没有正在执行的任务。";
|
||||
}
|
||||
case "cancel":
|
||||
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
|
||||
@@ -282,6 +287,45 @@ export class Gateway {
|
||||
|
||||
}
|
||||
|
||||
function renderProposalPanel(proposals: Proposal[]): string {
|
||||
if (proposals.length === 0) return "当前没有提案。";
|
||||
const pending = proposals.filter((proposal) => proposal.status === "pending");
|
||||
const working = proposals.filter((proposal) => proposal.status === "working");
|
||||
const queued = proposals.filter((proposal) => proposal.status === "queued");
|
||||
const proposed = proposals.filter((proposal) => proposal.status === "proposed");
|
||||
const finished = proposals.filter((proposal) => proposal.status === "finished")
|
||||
.sort((left, right) => (right.finishedAt ?? right.updatedAt) - (left.finishedAt ?? left.updatedAt))
|
||||
.slice(0, 3);
|
||||
const sections: string[] = [];
|
||||
if (pending.length > 0) {
|
||||
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 结束,或直接说要求继续改)`);
|
||||
}
|
||||
}
|
||||
if (working.length > 0) {
|
||||
sections.push("进行中:");
|
||||
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」正在执行(/stop 可中止)`);
|
||||
}
|
||||
if (queued.length > 0) {
|
||||
sections.push("排队中:");
|
||||
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」`);
|
||||
}
|
||||
if (proposed.length > 0) {
|
||||
sections.push("未确认(proposed):");
|
||||
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」(/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})` : ""}`);
|
||||
}
|
||||
}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
function safeEventError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const http = /HTTP (\d{3})/.exec(message);
|
||||
@@ -296,7 +340,8 @@ const HELP_TEXT = [
|
||||
"兜底命令:",
|
||||
"/list 列出当前提案",
|
||||
"/confirm 确认最近待确认的提案",
|
||||
"/finish 结束最近待确认的任务",
|
||||
"/stop 停止正在执行的任务",
|
||||
"/cancel 取消最近未开始的提案",
|
||||
"/cancel 取消最近未开始或待确认的提案",
|
||||
"/status 查看运行状态"
|
||||
].join("\n");
|
||||
|
||||
+96
-16
@@ -16,18 +16,18 @@ export type ProposalStatus =
|
||||
| "proposed"
|
||||
| "queued"
|
||||
| "working"
|
||||
| "awaiting_user_confirmation"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
| "pending"
|
||||
| "finished";
|
||||
|
||||
export interface ProposalPending {
|
||||
kind: "step" | "success" | "failure";
|
||||
summary: string;
|
||||
question?: string;
|
||||
nextStep?: string;
|
||||
workspaceDirty?: boolean;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export type ProposalFinishKind = "done" | "cancelled";
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -39,6 +39,8 @@ export interface Proposal {
|
||||
workerNativeSessionId?: string;
|
||||
workerProcessGroup?: WorkerProcessGroup;
|
||||
pending?: ProposalPending;
|
||||
finishKind?: ProposalFinishKind;
|
||||
finishNote?: string;
|
||||
lastWorkerSummary?: string;
|
||||
createdAt: number;
|
||||
confirmedAt?: number;
|
||||
@@ -63,6 +65,8 @@ export interface ProposalPatch {
|
||||
workerNativeSessionId?: string;
|
||||
workerProcessGroup?: WorkerProcessGroup;
|
||||
pending?: ProposalPending;
|
||||
finishKind?: ProposalFinishKind;
|
||||
finishNote?: string;
|
||||
lastWorkerSummary?: string;
|
||||
confirmedAt?: number;
|
||||
startedAt?: number;
|
||||
@@ -70,7 +74,7 @@ export interface ProposalPatch {
|
||||
}
|
||||
|
||||
interface StoreData {
|
||||
version: 1;
|
||||
version: 2;
|
||||
botId: string;
|
||||
platform: string;
|
||||
proposals: Record<string, Proposal>;
|
||||
@@ -116,11 +120,20 @@ export class ProposalStore {
|
||||
}
|
||||
try {
|
||||
const raw = await fs.promises.readFile(this.file, "utf8");
|
||||
const parsed = parseStoreData(JSON.parse(raw) as unknown);
|
||||
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(value) || typeof value.botId !== "string" || typeof value.platform !== "string") {
|
||||
throw new Error("unsupported proposal state; expected a versioned store file");
|
||||
}
|
||||
if (value.botId !== this.identity.botId || value.platform !== this.identity.platform) {
|
||||
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
|
||||
}
|
||||
this.data = parsed;
|
||||
if (value.version === 1) {
|
||||
await this.backupFile(raw);
|
||||
this.data = migrateV1(value);
|
||||
await this.persist();
|
||||
} else {
|
||||
this.data = parseStoreData(value);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
await this.releaseLock();
|
||||
@@ -170,6 +183,9 @@ export class ProposalStore {
|
||||
const proposal = this.data.proposals[id];
|
||||
if (!proposal) throw new Error(`unknown proposal '${id}'`);
|
||||
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
|
||||
for (const key of Object.keys(candidate) as (keyof Proposal)[]) {
|
||||
if (candidate[key] === undefined) delete candidate[key];
|
||||
}
|
||||
validateProposal(candidate, id);
|
||||
this.data.proposals[id] = candidate;
|
||||
await this.persist();
|
||||
@@ -196,6 +212,19 @@ export class ProposalStore {
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async backupFile(raw: string): Promise<void> {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = `${this.file}.v1-${stamp}.bak`;
|
||||
let handle: fs.promises.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.promises.open(backup, "wx", 0o600);
|
||||
await handle.writeFile(raw, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseLock(): Promise<void> {
|
||||
if (!this.lockFd) return;
|
||||
await this.lockFd.close();
|
||||
@@ -207,14 +236,15 @@ export class ProposalStore {
|
||||
}
|
||||
|
||||
function emptyData(identity: StoreIdentity): StoreData {
|
||||
return { version: 1, botId: identity.botId, platform: identity.platform, proposals: {} };
|
||||
return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} };
|
||||
}
|
||||
|
||||
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "awaiting_user_confirmation", "completed", "failed", "cancelled"];
|
||||
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "pending", "finished"];
|
||||
const FINISH_KINDS: readonly ProposalFinishKind[] = ["done", "cancelled"];
|
||||
|
||||
function parseStoreData(value: unknown): StoreData {
|
||||
if (!isRecord(value) || value.version !== 1 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
|
||||
throw new Error("unsupported proposal state version; expected version 1");
|
||||
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
|
||||
throw new Error("unsupported proposal state version; expected version 2");
|
||||
}
|
||||
for (const [id, proposal] of Object.entries(value.proposals)) {
|
||||
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
|
||||
@@ -223,6 +253,54 @@ function parseStoreData(value: unknown): StoreData {
|
||||
return value as unknown as StoreData;
|
||||
}
|
||||
|
||||
interface V1Pending {
|
||||
kind?: string;
|
||||
summary?: string;
|
||||
question?: string;
|
||||
}
|
||||
|
||||
// v1 → v2 migration: pending becomes a single "waiting for the user" state with no success/failure
|
||||
// distinction; terminal v1 statuses collapse into finished with a finishKind.
|
||||
function migrateV1(value: Record<string, unknown>): StoreData {
|
||||
if (!isRecord(value.proposals)) throw new Error("unsupported proposal state version; expected version 2");
|
||||
const proposals: Record<string, Proposal> = {};
|
||||
for (const [id, raw] of Object.entries(value.proposals)) {
|
||||
if (!isRecord(raw) || raw.id !== id) throw new Error(`invalid proposal '${id}'`);
|
||||
const source = structuredClone(raw) as unknown as Omit<Proposal, "status" | "pending"> & { status: string; pending?: V1Pending };
|
||||
const pending = isRecord(source.pending) ? source.pending as V1Pending : undefined;
|
||||
const migrated: Proposal = { ...source, pending: undefined, finishKind: undefined, finishNote: undefined } as Proposal;
|
||||
if (source.status === "awaiting_user_confirmation") {
|
||||
migrated.status = "pending";
|
||||
migrated.pending = {
|
||||
summary: typeof pending?.summary === "string" ? pending.summary : (source.lastWorkerSummary || "worker reported a result before migration"),
|
||||
receivedAt: typeof source.updatedAt === "number" ? source.updatedAt : Date.now()
|
||||
};
|
||||
if (pending?.kind === "failure") migrated.pending.workspaceDirty = true;
|
||||
if (pending?.kind === "step" && typeof pending.question === "string") migrated.pending.question = pending.question;
|
||||
} else if (source.status === "completed") {
|
||||
migrated.status = "finished";
|
||||
migrated.finishKind = "done";
|
||||
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
||||
} else if (source.status === "failed") {
|
||||
migrated.status = "finished";
|
||||
migrated.finishKind = "done";
|
||||
migrated.finishNote = pending?.summary || source.lastWorkerSummary || "failed before migration";
|
||||
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
||||
} else if (source.status === "cancelled") {
|
||||
migrated.status = "finished";
|
||||
migrated.finishKind = "cancelled";
|
||||
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
||||
} else if (source.status === "proposed" || source.status === "queued" || source.status === "working") {
|
||||
migrated.status = source.status;
|
||||
} else {
|
||||
throw new Error(`invalid proposal '${id}': status`);
|
||||
}
|
||||
validateProposal(migrated, id);
|
||||
proposals[id] = migrated;
|
||||
}
|
||||
return { version: 2, botId: value.botId as string, platform: value.platform as string, proposals };
|
||||
}
|
||||
|
||||
function validateProposal(proposal: Proposal, id: string): void {
|
||||
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
|
||||
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
|
||||
@@ -241,13 +319,15 @@ function validateProposal(proposal: Proposal, id: string): void {
|
||||
if (proposal.pending !== undefined) {
|
||||
const pending = proposal.pending;
|
||||
if (!isRecord(pending)
|
||||
|| !["step", "success", "failure"].includes(String(pending.kind))
|
||||
|| typeof pending.summary !== "string"
|
||||
|| (pending.question !== undefined && typeof pending.question !== "string")
|
||||
|| (pending.nextStep !== undefined && typeof pending.nextStep !== "string")) {
|
||||
|| (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean")
|
||||
|| typeof pending.receivedAt !== "number") {
|
||||
invalid("pending");
|
||||
}
|
||||
}
|
||||
if (proposal.finishKind !== undefined && !FINISH_KINDS.includes(proposal.finishKind)) invalid("finishKind");
|
||||
if (proposal.finishNote !== undefined && typeof proposal.finishNote !== "string") invalid("finishNote");
|
||||
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
|
||||
if (typeof proposal.createdAt !== "number") invalid("createdAt");
|
||||
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface MessageTarget {
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface IncomingAttachment {
|
||||
mimeType: string;
|
||||
data: string; // base64
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface IncomingMessage {
|
||||
platform: PlatformName;
|
||||
chatId: string;
|
||||
@@ -18,6 +24,7 @@ export interface IncomingMessage {
|
||||
messageId?: string;
|
||||
isGroup?: boolean;
|
||||
mentionsBot?: boolean;
|
||||
attachments?: IncomingAttachment[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { jsonResponse } from "../../core/adapter.js";
|
||||
import type { PlatformAdapter } from "../../core/adapter.js";
|
||||
import type { Gateway } from "../../core/gateway.js";
|
||||
import { stripBotMentions } from "../../core/text.js";
|
||||
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
|
||||
import type { IncomingAttachment, IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
|
||||
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
|
||||
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
|
||||
|
||||
@@ -12,6 +12,11 @@ const MESSAGE_EVENTS = new Set([
|
||||
"GROUP_AT_MESSAGE_CREATE",
|
||||
"C2C_MESSAGE_CREATE"
|
||||
]);
|
||||
// Inbound attachment limits: only images are downloaded (base64 passthrough to the agent);
|
||||
// everything else degrades to a text link.
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_IMAGES_PER_MESSAGE = 3;
|
||||
const ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class QqAdapter implements PlatformAdapter {
|
||||
readonly name = "qq";
|
||||
@@ -38,17 +43,17 @@ export class QqAdapter implements PlatformAdapter {
|
||||
return jsonResponse(QQ_CALLBACK_ACK);
|
||||
}
|
||||
|
||||
if (!this.handleDispatch(payload)) return jsonResponse(QQ_CALLBACK_ACK);
|
||||
await this.handleDispatch(payload);
|
||||
return jsonResponse(QQ_CALLBACK_ACK);
|
||||
}
|
||||
|
||||
handleDispatch(payload: QqWebhookPayload): boolean {
|
||||
const message = this.normalizeMessage(payload);
|
||||
async handleDispatch(payload: QqWebhookPayload): Promise<boolean> {
|
||||
const message = await this.normalizeMessage(payload);
|
||||
if (!message) {
|
||||
console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`);
|
||||
return false;
|
||||
}
|
||||
console.log(`QQ recv ${payload.t} accepted length=${message.text.length}`);
|
||||
console.log(`QQ recv ${payload.t} accepted length=${message.text.length} images=${message.attachments?.length || 0}`);
|
||||
void this.gateway.receive(message, this).catch((error) => {
|
||||
console.error("QQ gateway error", error);
|
||||
});
|
||||
@@ -98,13 +103,37 @@ export class QqAdapter implements PlatformAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined {
|
||||
private async normalizeMessage(payload: QqWebhookPayload): Promise<IncomingMessage | undefined> {
|
||||
const data = payload.d;
|
||||
if (!data) return undefined;
|
||||
|
||||
const rawText = data.content || data.text || "";
|
||||
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
|
||||
if (!text) return undefined;
|
||||
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
const linkLines: string[] = [];
|
||||
const imageSources = attachments.filter((attachment) => {
|
||||
const type = typeof attachment?.content_type === "string" ? attachment.content_type : "";
|
||||
if (type.startsWith("image/")) return typeof attachment.url === "string" && attachment.url.length > 0;
|
||||
if (typeof attachment?.url === "string" && attachment.url) {
|
||||
linkLines.push(type.startsWith("video/") ? `[视频] ${attachment.url}` : `[文件] ${attachment.url}`);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const images: IncomingAttachment[] = [];
|
||||
const downloadable = imageSources.filter((source) => !(typeof source.size === "number" && source.size > MAX_IMAGE_BYTES));
|
||||
let skippedImages = imageSources.length - downloadable.length + Math.max(0, downloadable.length - MAX_IMAGES_PER_MESSAGE);
|
||||
for (const source of downloadable.slice(0, MAX_IMAGES_PER_MESSAGE)) {
|
||||
const image = await downloadImage(source);
|
||||
if (image) images.push(image);
|
||||
else skippedImages++;
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
console.log(`QQ attachments: total=${attachments.length} images=${imageSources.length} downloaded=${images.length} skipped=${skippedImages} links=${linkLines.length}`);
|
||||
}
|
||||
|
||||
const parts = [text, ...linkLines].filter((part) => part.length > 0);
|
||||
if (parts.length === 0 && images.length > 0) parts.push(images.length > 1 ? `(发来 ${images.length} 张图片)` : "(发来一张图片)");
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
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);
|
||||
@@ -115,10 +144,11 @@ export class QqAdapter implements PlatformAdapter {
|
||||
platform: this.name,
|
||||
chatId,
|
||||
userId,
|
||||
text,
|
||||
text: parts.join("\n"),
|
||||
messageId: data.id || data.msg_id || data.message_id || payload.id,
|
||||
isGroup,
|
||||
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
|
||||
attachments: images.length > 0 ? images : undefined,
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
@@ -148,6 +178,29 @@ export class QqAdapter implements PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImage(attachment: { content_type?: string; filename?: string; url?: string }): Promise<IncomingAttachment | undefined> {
|
||||
const rawUrl = typeof attachment.url === "string" ? attachment.url : "";
|
||||
const url = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
|
||||
if (!/^https?:\/\//.test(url)) return undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ATTACHMENT_DOWNLOAD_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) return undefined;
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) return undefined;
|
||||
return {
|
||||
mimeType: attachment.content_type || "image/*",
|
||||
data: buffer.toString("base64"),
|
||||
filename: typeof attachment.filename === "string" ? attachment.filename : undefined
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function qqErrorDetail(data: QqSendMessageResponse | undefined): string {
|
||||
if (!data) return "";
|
||||
const parts = [data.code ? `code=${data.code}` : "", data.message || ""].filter(Boolean);
|
||||
|
||||
@@ -91,7 +91,7 @@ export class QqGatewayClient {
|
||||
return;
|
||||
}
|
||||
if (payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "C2C_MESSAGE_CREATE") {
|
||||
this.adapter.handleDispatch(payload);
|
||||
void this.adapter.handleDispatch(payload).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ export interface QqWebhookEventData {
|
||||
guild_id?: string;
|
||||
user_openid?: string;
|
||||
member_openid?: string;
|
||||
attachments?: {
|
||||
content_type?: string;
|
||||
filename?: string;
|
||||
id?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
}[];
|
||||
author?: {
|
||||
id?: string;
|
||||
user_openid?: string;
|
||||
|
||||
@@ -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 = 7;
|
||||
const BOOTSTRAP_SCHEMA_VERSION = 8;
|
||||
|
||||
export interface ResolvedBot extends BotConfig {
|
||||
loadedSkills: LoadedSkill[];
|
||||
@@ -44,13 +44,14 @@ function buildAssistantBootstrap(bot: BotConfig): string {
|
||||
`Workspace: ${bot.workspace}`,
|
||||
persona ? `Persona:\n${persona}` : "Persona: general coding assistant",
|
||||
"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.",
|
||||
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, awaiting_user_confirmation, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
|
||||
"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.",
|
||||
"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], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
|
||||
"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."
|
||||
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, pending, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
|
||||
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V2>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V2>. 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\"}; {\"type\":\"adjust_proposal\",\"id\":\"optional\",\"title\":\"optional\",\"goal\":\"optional\",\"steps\":\"optional\"}; {\"type\":\"follow_up\",\"id\":\"optional\",\"instruction\":\"...\"}; {\"type\":\"finish\",\"id\":\"optional\",\"note\":\"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. When a worker finishes a turn, the proposal becomes pending: it waits for the user's decision with a summary (and maybe a question). The worker never reports success or failure; treat every result as information for the user. \"finish\" closes a pending proposal as done; \"follow_up\" sends the user's new instruction to the same proposal and resumes its worker; \"cancel\" drops a proposed, queued, or pending proposal; \"stop\" aborts the running worker and leaves the proposal pending; \"start_next\" starts the oldest confirmed queued proposal. \"adjust_proposal\" edits a proposal that has not started yet. Never invent other actions or statuses.",
|
||||
"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.",
|
||||
"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");
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
|
||||
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
|
||||
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
|
||||
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
|
||||
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V1>{\"status\":\"SUCCESS\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V1>. Status is SUCCESS only when the proposal is fully done, FAILED when it cannot be completed, or NEEDS_CONFIRMATION when you must ask the user before continuing (for example a dirty or unexpected target). Optional fields: \"question\", \"nextStep\", \"dirty\". Always include a short user-readable \"summary\". Never emit any other status or text after the envelope.",
|
||||
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. PENDING is the only status: it hands the result back to the user. Always include a short user-readable \"summary\" of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing. Set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. Never emit any other status or text after the envelope.",
|
||||
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
|
||||
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
|
||||
].join("\n\n");
|
||||
|
||||
Reference in New Issue
Block a user