2026-08-14 16:21:09 +08:00

398 lines
13 KiB
TypeScript

import type { AgentRun, RunEvent } from "../domain/agent-run";
import type { UserInteraction } from "../domain/user-interaction";
import { CoreError } from "../errors/core-error";
import type { ModelPort, ModelRequest } from "../ports/model-port";
import type { RunRepository } from "../ports/run-repository";
import type { ClockPort, IdPort } from "../ports/system-ports";
import type { ConversationService } from "./conversation-service";
import type { InteractionService } from "./interaction-service";
import type { ProjectService } from "./project-service";
import type { WorkspaceService } from "./workspace-service";
import { isWorkspaceTool, workspaceTools } from "./workspace-tools";
import { interactionTool } from "./interaction-tool";
import {
ensureNoActiveRun,
lastSequence,
messagesThroughTrigger,
persistRunEvent,
prepareRetryRun,
requireRun,
recoverInterruptedRun,
safeErrorMessage,
} from "./run-service-helpers";
import type { StartedRun, StartRunInput } from "./run-types";
import { executeWorkspaceTool, prepareRunConversation } from "./run-workspace";
export class AgentRunService {
private readonly controllers = new Map<string, AbortController>();
private startQueue: Promise<void> = Promise.resolve();
constructor(
private readonly conversations: ConversationService,
private readonly model: ModelPort,
private readonly runs: RunRepository,
private readonly clock: ClockPort,
private readonly ids: IdPort,
private readonly projects: ProjectService,
private readonly interactions: InteractionService,
private readonly workspace?: WorkspaceService,
) {}
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
return this.withStartLock(async () => {
await ensureNoActiveRun(this.runs);
const runId = this.ids.create();
const conversation = await prepareRunConversation(
input,
runId,
this.conversations,
this.projects,
this.workspace,
);
const timestamp = this.clock.now().toISOString();
const triggerMessage = conversation.messages.at(-1);
if (triggerMessage?.role !== "user")
throw new CoreError(
"RUN_TRIGGER_INVALID",
"无法确定本次运行的用户消息",
);
const run: AgentRun = {
id: runId,
conversationId: conversation.id,
triggerMessageId: triggerMessage.id,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
};
await this.runs.create(run);
return this.started(run, signal, 0);
});
}
async resume(
interaction: UserInteraction,
signal: AbortSignal,
): Promise<StartedRun> {
const current = await requireRun(this.runs, interaction.runId);
if (current.status !== "waiting_user")
throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答");
if (!interaction.answer)
throw new CoreError("INTERACTION_INVALID", "交互答案不存在");
const run = {
...current,
status: "running" as const,
updatedAt: this.clock.now().toISOString(),
};
await this.runs.update(run);
const sequence = lastSequence(await this.runs.listEvents(run.id));
const continuation: NonNullable<ModelRequest["continuations"]>[number] = {
toolCallId: interaction.toolCallId,
toolName: "request_user_interaction",
toolArguments: interaction.toolArguments,
result: JSON.stringify(interaction.answer),
};
return this.started(run, signal, sequence, [continuation], interaction);
}
async cancel(runId: string): Promise<readonly RunEvent[]> {
const run = await requireRun(this.runs, runId);
if (
run.status === "cancelled" ||
run.status === "completed" ||
run.status === "failed"
)
return [];
if (run.status === "running") {
const controller = this.controllers.get(runId);
if (!controller)
throw new CoreError(
"RUN_NOT_CANCELLABLE",
"任务正在恢复中,请稍后重试",
);
controller.abort();
return [];
}
const interaction = await this.interactions.cancelPending(runId);
if (!interaction)
throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在");
let sequence = lastSequence(await this.runs.listEvents(run.id));
const cancelled = await persistRunEvent(
this.runs,
this.clock,
run.id,
++sequence,
"interaction.cancelled",
{ interactionId: interaction.id },
);
await this.runs.update({
...run,
status: "cancelled",
updatedAt: this.clock.now().toISOString(),
});
const finished = await persistRunEvent(
this.runs,
this.clock,
run.id,
++sequence,
"run.cancelled",
{},
);
return [cancelled, finished];
}
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
return this.withStartLock(async () => {
const run = await prepareRetryRun(
runId,
this.conversations,
this.runs,
this.clock,
this.ids,
);
return this.started(run, signal, 0);
});
}
async lastSequence(runId: string): Promise<number> {
await requireRun(this.runs, runId);
return lastSequence(await this.runs.listEvents(runId));
}
getRun(runId: string): Promise<AgentRun> {
return requireRun(this.runs, runId);
}
async listEvents(runId: string): Promise<readonly RunEvent[]> {
await requireRun(this.runs, runId);
return this.runs.listEvents(runId);
}
async recoverInterrupted(): Promise<readonly RunEvent[]> {
const active = await this.runs.findActive();
if (
active?.status === "waiting_user" &&
(await this.interactions.findPendingByRun(active.id))
)
return [];
return recoverInterruptedRun(this.runs, this.clock);
}
private started(
run: AgentRun,
externalSignal: AbortSignal,
sequence: number,
continuations?: NonNullable<ModelRequest["continuations"]>,
interaction?: UserInteraction,
): StartedRun {
const controller = new AbortController();
this.controllers.set(run.id, controller);
const signal = AbortSignal.any([externalSignal, controller.signal]);
return {
run,
events: this.execute(run, signal, sequence, continuations, interaction),
};
}
private async withStartLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.startQueue;
let release = () => {};
this.startQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
private async *execute(
run: AgentRun,
signal: AbortSignal,
initialSequence: number,
initialContinuations: NonNullable<ModelRequest["continuations"]> = [],
resolvedInteraction?: UserInteraction,
): AsyncIterable<RunEvent> {
let sequence = initialSequence;
let messageId: string | undefined;
let content = "";
let messagePersisted = false;
const event = async (
type: RunEvent["type"],
payload: Record<string, unknown>,
) =>
persistRunEvent(this.runs, this.clock, run.id, ++sequence, type, payload);
try {
if (initialContinuations.length === 0)
yield await event("run.started", {
conversationId: run.conversationId,
});
if (resolvedInteraction)
yield await event("interaction.resolved", {
interactionId: resolvedInteraction.id,
answer: resolvedInteraction.answer,
});
messageId = this.ids.create();
yield await event("message.started", { messageId });
const conversation = await this.conversations.getConversation(
run.conversationId,
);
const modelMessages = run.retryOfRunId
? messagesThroughTrigger(conversation, run.triggerMessageId)
: conversation.messages;
signal.throwIfAborted();
const continuations = [...initialContinuations];
let toolCount = 0;
modelLoop: while (true) {
let requestedTool = false;
for await (const modelEvent of this.model.stream(
{
model: "default",
messages: modelMessages,
tools: [interactionTool, ...workspaceTools],
...(continuations.length ? { continuations } : {}),
},
signal,
)) {
signal.throwIfAborted();
if (modelEvent.type === "text.delta") {
content += modelEvent.delta;
yield await event("message.delta", {
messageId,
delta: modelEvent.delta,
});
continue;
}
if (modelEvent.type === "tool.requested") {
requestedTool = true;
if (isWorkspaceTool(modelEvent.name)) {
if (++toolCount > 12)
throw new CoreError(
"TOOL_LIMIT_EXCEEDED",
"文件工具调用次数过多",
);
yield await event("tool.started", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
});
const tool = await executeWorkspaceTool(
this.workspace,
run.conversationId,
modelEvent.name,
modelEvent.arguments,
);
if (!tool.failed) {
yield await event("tool.completed", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
summary: tool.summary,
});
} else {
yield await event("tool.failed", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
code: tool.failed.code,
message: tool.failed.message,
});
}
continuations.push({
toolCallId: modelEvent.toolCallId,
toolName: modelEvent.name,
toolArguments: modelEvent.arguments,
result: tool.result,
});
continue modelLoop;
}
if (modelEvent.name !== interactionTool.name)
throw new CoreError(
"TOOL_NOT_SUPPORTED",
"模型请求了尚未支持的工具",
);
await this.conversations.appendAssistantMessage(
run.conversationId,
messageId,
run.id,
content,
);
messagePersisted = true;
yield await event("message.completed", { messageId, content });
const interaction = await this.interactions.create({
runId: run.id,
conversationId: run.conversationId,
messageId,
toolCallId: modelEvent.toolCallId,
toolArguments: modelEvent.arguments,
});
await this.runs.update({
...run,
status: "waiting_user",
updatedAt: this.clock.now().toISOString(),
});
yield await event("interaction.requested", { interaction });
return;
}
}
if (!requestedTool) break;
}
signal.throwIfAborted();
if (!content)
throw new CoreError("MODEL_RESPONSE_INVALID", "模型没有返回有效内容");
await this.conversations.appendAssistantMessage(
run.conversationId,
messageId,
run.id,
content,
);
messagePersisted = true;
yield await event("message.completed", { messageId, content });
await this.runs.update({
...run,
status: "completed",
updatedAt: this.clock.now().toISOString(),
});
yield await event("run.completed", {});
} catch (cause) {
if (signal.aborted) {
if (content && messageId && !messagePersisted) {
await this.conversations.appendAssistantMessage(
run.conversationId,
messageId,
run.id,
content,
);
yield await event("message.completed", {
messageId,
content,
cancelled: true,
});
}
await this.runs.update({
...run,
status: "cancelled",
updatedAt: this.clock.now().toISOString(),
});
yield await event("run.cancelled", {});
return;
}
const code =
cause instanceof CoreError ? cause.code : "MODEL_UNAVAILABLE";
await this.runs.update({
...run,
status: "failed",
errorCode: code,
updatedAt: this.clock.now().toISOString(),
});
yield await event("run.failed", {
code,
message: safeErrorMessage(cause),
});
} finally {
this.controllers.delete(run.id);
}
}
}