Initial commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { profileToLlmConfig } from "../config/api-profiles.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
resolveActiveProfile,
|
||||
} from "../config/settings.js";
|
||||
import { resolveActivePreset } from "../preset/store.js";
|
||||
import {
|
||||
createMockMainAgentResponse,
|
||||
MockLlmProvider,
|
||||
OpenAiCompatibleProvider,
|
||||
type LlmProvider,
|
||||
} from "../llm/client.js";
|
||||
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
|
||||
import {
|
||||
TokenTrackingProvider,
|
||||
type LlmTrackingContext,
|
||||
} from "../llm/token-tracker.js";
|
||||
|
||||
export type LlmTrackingRef = { current: LlmTrackingContext };
|
||||
|
||||
function buildInnerLlm(): LlmProvider {
|
||||
ensureActiveProfileDefault();
|
||||
const profile = resolveActiveProfile();
|
||||
|
||||
if (profile?.apiKey?.trim()) {
|
||||
return new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||
}
|
||||
|
||||
return new MockLlmProvider([
|
||||
createMockMainAgentResponse({
|
||||
action: "run_worker",
|
||||
reason: "信息已足够,建议运行 outline-worker 生成大纲。",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
createMockMainAgentResponse({
|
||||
action: "finish",
|
||||
reason: "创作流程结束",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function wrapWithPreset(inner: LlmProvider): LlmProvider {
|
||||
const settings = loadAppSettings();
|
||||
const preset = resolveActivePreset(settings.activePresetId);
|
||||
if (!preset) return inner;
|
||||
return new PresetLlmProvider(inner, () =>
|
||||
resolveActivePreset(loadAppSettings().activePresetId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */
|
||||
export function createDefaultMainAgentLlm(
|
||||
trackingRef?: LlmTrackingRef,
|
||||
): LlmProvider {
|
||||
const llm = wrapWithPreset(buildInnerLlm());
|
||||
if (!trackingRef) return llm;
|
||||
return new TokenTrackingProvider(llm, () => trackingRef.current);
|
||||
}
|
||||
|
||||
export function hasRealLlmConfig(): boolean {
|
||||
ensureActiveProfileDefault();
|
||||
return Boolean(resolveActiveProfile()?.apiKey?.trim());
|
||||
}
|
||||
|
||||
/** @deprecated 包装层下 instanceof 不可靠,请用 hasRealLlmConfig */
|
||||
export function isMockLlm(_llm: LlmProvider): boolean {
|
||||
return !hasRealLlmConfig();
|
||||
}
|
||||
|
||||
/** 设置页切换 profile / preset 后调用,返回新 LLM 实例 */
|
||||
export function reloadDefaultMainAgentLlm(
|
||||
trackingRef?: LlmTrackingRef,
|
||||
): LlmProvider {
|
||||
return createDefaultMainAgentLlm(trackingRef);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @deprecated 请使用 PhaseRuntime(已含 Skill 流程 + 总管 LLM 循环)。
|
||||
* 本文件保留供对照测试,新入口见 phase-demo / web / run.ts。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import {
|
||||
DEFAULT_WORKERS,
|
||||
MainAgent,
|
||||
} from "../main-agent/main-agent.js";
|
||||
import {
|
||||
applyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
} from "../runtime/phase-machine.js";
|
||||
import type {
|
||||
AcceptanceMode,
|
||||
MainAgentDecision,
|
||||
PhaseEffect,
|
||||
RuntimeEvent,
|
||||
RuntimeSession,
|
||||
} from "../types/runtime.js";
|
||||
|
||||
export type RuntimeOrchestratorOptions = {
|
||||
presetId?: string;
|
||||
flowId?: string;
|
||||
llm: LlmProvider;
|
||||
blackboard?: Blackboard;
|
||||
acceptanceMode?: AcceptanceMode;
|
||||
onMessage?: (message: string) => void;
|
||||
};
|
||||
|
||||
export class RuntimeOrchestrator {
|
||||
private session: RuntimeSession;
|
||||
private readonly blackboard: Blackboard;
|
||||
private readonly mainAgent: MainAgent;
|
||||
private readonly acceptanceMode: AcceptanceMode;
|
||||
private readonly onMessage: (message: string) => void;
|
||||
|
||||
constructor(options: RuntimeOrchestratorOptions) {
|
||||
this.session = createSession(options.presetId ?? "default", options.flowId);
|
||||
this.blackboard = options.blackboard ?? new Blackboard();
|
||||
this.mainAgent = new MainAgent(options.llm);
|
||||
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
|
||||
this.onMessage = options.onMessage ?? (() => {});
|
||||
}
|
||||
|
||||
getSession(): RuntimeSession {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
getBlackboard(): Blackboard {
|
||||
return this.blackboard;
|
||||
}
|
||||
|
||||
async start(): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "session_started",
|
||||
payload: {
|
||||
presetId: this.session.presetId,
|
||||
flowId: this.session.flowId,
|
||||
availableSkills: [{ name: "basic", description: "fallback", category: "novel" }],
|
||||
},
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async submitUserInput(text: string): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "user_submitted_input",
|
||||
payload: { text },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async approveNextStep(): Promise<RuntimeSession> {
|
||||
const decisionId = this.session.pendingDecision?.id;
|
||||
if (!decisionId) {
|
||||
throw new Error("No pending decision to approve");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_approved_next_step",
|
||||
payload: { decisionId },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async rejectNextStep(reason?: string): Promise<RuntimeSession> {
|
||||
const decisionId = this.session.pendingDecision?.id;
|
||||
if (!decisionId) {
|
||||
throw new Error("No pending decision to reject");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_rejected_next_step",
|
||||
payload: { decisionId, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) {
|
||||
throw new Error("No pending artifact to accept");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: id },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) {
|
||||
throw new Error("No pending artifact to reject");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_rejected_artifact",
|
||||
payload: { artifactId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async completeFlow(): Promise<RuntimeSession> {
|
||||
await this.dispatch({ type: "flow_completed", payload: {} });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private async dispatch(event: RuntimeEvent): Promise<void> {
|
||||
let result = applyEvent(this.session, event);
|
||||
this.session = result.session;
|
||||
|
||||
if (result.error && event.type !== "runtime_failed") {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||
for (const effect of effects) {
|
||||
switch (effect.type) {
|
||||
case "emit_message":
|
||||
this.onMessage(effect.message);
|
||||
break;
|
||||
case "run_worker":
|
||||
await this.runStubWorker(effect);
|
||||
break;
|
||||
case "resume_worker":
|
||||
await this.resumeStubWorker();
|
||||
break;
|
||||
case "run_programmatic_review":
|
||||
await this.runProgrammaticReview(effect.artifactId);
|
||||
break;
|
||||
case "invoke_main_agent":
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = await this.mainAgent.decide({
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers: [...DEFAULT_WORKERS],
|
||||
});
|
||||
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = applyEvent(this.session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
this.session = result.session;
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runStubWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
): Promise<void> {
|
||||
await this.executeStubWorker(effect.workerId);
|
||||
}
|
||||
|
||||
private async resumeStubWorker(): Promise<void> {
|
||||
const ctx = this.session.resumeContext;
|
||||
if (!ctx) {
|
||||
throw new Error("No resume context");
|
||||
}
|
||||
await this.executeStubWorker(ctx.workerId);
|
||||
}
|
||||
|
||||
private async executeStubWorker(workerId: string): Promise<void> {
|
||||
const stepId = this.session.currentStepId;
|
||||
|
||||
this.session = applyEvent(this.session, {
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId,
|
||||
stepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
}).session;
|
||||
|
||||
this.onMessage(`[Worker 占位] 正在执行 ${workerId} ...`);
|
||||
|
||||
const lastInput = String(this.session.slots.lastUserInput ?? "");
|
||||
const summary = `${workerId} 针对用户输入生成的占位产物`;
|
||||
const value = `[${workerId} 占位输出]\n用户输入: ${lastInput}\n说明: 这是第一版 stub worker,后续会替换为真实 LLM worker。`;
|
||||
|
||||
const outputTags = ["output.草稿"];
|
||||
|
||||
for (const tag of outputTags) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: value,
|
||||
source: workerId,
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId,
|
||||
stepId,
|
||||
outputTags,
|
||||
summary,
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
artifacts: [...this.session.artifacts, artifact],
|
||||
};
|
||||
|
||||
const completed = applyEvent(this.session, {
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
this.session = completed.session;
|
||||
await this.processEffects(completed.effects);
|
||||
|
||||
if (completed.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runProgrammaticReview(artifactId: string): Promise<void> {
|
||||
this.session = applyEvent(this.session, {
|
||||
type: "programmatic_review_started",
|
||||
payload: { artifactId },
|
||||
}).session;
|
||||
|
||||
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
|
||||
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
|
||||
|
||||
const reviewEvent: RuntimeEvent = passed
|
||||
? { type: "programmatic_review_passed", payload: { artifactId } }
|
||||
: {
|
||||
type: "programmatic_review_failed",
|
||||
payload: { artifactId, reason: "产物 summary 为空" },
|
||||
};
|
||||
|
||||
const result = applyEvent(this.session, reviewEvent);
|
||||
this.session = result.session;
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDecision(
|
||||
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||
Partial<Pick<MainAgentDecision, "id">>,
|
||||
): MainAgentDecision {
|
||||
return {
|
||||
id: partial.id ?? randomUUID(),
|
||||
statePatchAllowed: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
/**
|
||||
* 纯函数运行阶段机。
|
||||
*
|
||||
* - 无副作用:不读写文件、不调用 LLM、不启动 worker
|
||||
* - 输入 (session, event),输出 { session, effects }
|
||||
* - phase 只能由 applyEvent 改变;非法事件返回 error 并进入 phase=error
|
||||
*
|
||||
* 上层调用方:PhaseRuntime.dispatch() → applyEvent → processEffects()
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ApplyEventResult,
|
||||
ArtifactRecord,
|
||||
PhaseEffect,
|
||||
ResumeContext,
|
||||
RuntimeEvent,
|
||||
RuntimePhase,
|
||||
RuntimeSession,
|
||||
WaitingReason,
|
||||
} from "../types/runtime.js";
|
||||
import type { ActiveSkillSnapshot } from "../types/runtime.js";
|
||||
import {
|
||||
buildIntakeProgress,
|
||||
buildIntakeFollowUpMessage,
|
||||
readIntakeValues,
|
||||
synthesizeDemandText,
|
||||
} from "../intake/intake.js";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** 更新 updatedAt 时间戳 */
|
||||
function touch(session: RuntimeSession): RuntimeSession {
|
||||
return { ...session, updatedAt: nowIso() };
|
||||
}
|
||||
|
||||
/** 将用户补充文本追加到 slot(用于合并多轮 ask_user / worker 答复到需求 tag) */
|
||||
function mergeSlotText(
|
||||
slots: Record<string, unknown>,
|
||||
key: string,
|
||||
text: string,
|
||||
): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const prev = String(slots[key] ?? "").trim();
|
||||
slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
/** 追加事件到 history 并 touch */
|
||||
function appendHistory(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): RuntimeSession {
|
||||
return touch({ ...session, history: [...session.history, event] });
|
||||
}
|
||||
|
||||
/** 进入 waiting_user,并设置 waitingReason */
|
||||
function waiting(session: RuntimeSession, reason: WaitingReason): RuntimeSession {
|
||||
return touch({ ...session, phase: "waiting_user", waitingReason: reason });
|
||||
}
|
||||
|
||||
/** 进入 running,清除 waitingReason */
|
||||
function running(session: RuntimeSession): RuntimeSession {
|
||||
return touch({ ...session, phase: "running", waitingReason: undefined });
|
||||
}
|
||||
|
||||
function findArtifact(
|
||||
session: RuntimeSession,
|
||||
artifactId: string,
|
||||
): ArtifactRecord | undefined {
|
||||
return session.artifacts.find((a) => a.id === artifactId);
|
||||
}
|
||||
|
||||
function updateArtifact(
|
||||
session: RuntimeSession,
|
||||
artifactId: string,
|
||||
patch: Partial<ArtifactRecord>,
|
||||
): RuntimeSession {
|
||||
return {
|
||||
...session,
|
||||
artifacts: session.artifacts.map((a) =>
|
||||
a.id === artifactId ? { ...a, ...patch, updatedAt: nowIso() } : a,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建 idle 状态的新会话 */
|
||||
export function createSession(
|
||||
presetId: string,
|
||||
flowId?: string,
|
||||
): RuntimeSession {
|
||||
const ts = nowIso();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
phase: "idle",
|
||||
presetId,
|
||||
flowId,
|
||||
slots: {},
|
||||
artifacts: [],
|
||||
history: [],
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前 phase 返回允许的事件类型列表(第一层校验)。
|
||||
* waitingReason 的细粒度匹配见 canApplyEvent。
|
||||
*/
|
||||
export function getAllowedEvents(
|
||||
session: RuntimeSession,
|
||||
): RuntimeEvent["type"][] {
|
||||
switch (session.phase) {
|
||||
case "idle":
|
||||
return ["session_started", "runtime_failed"];
|
||||
case "running":
|
||||
return [
|
||||
"main_agent_decision_created",
|
||||
"worker_started",
|
||||
"worker_completed",
|
||||
"worker_needs_input",
|
||||
"programmatic_review_started",
|
||||
"programmatic_review_passed",
|
||||
"programmatic_review_failed",
|
||||
"flow_completed",
|
||||
"runtime_failed",
|
||||
];
|
||||
case "waiting_user":
|
||||
return [
|
||||
"skill_selected",
|
||||
"user_submitted_input",
|
||||
"user_confirmed_intake",
|
||||
"user_approved_next_step",
|
||||
"user_rejected_next_step",
|
||||
"user_accepted_artifact",
|
||||
"user_rejected_artifact",
|
||||
"user_requested_revision",
|
||||
"main_agent_decision_created",
|
||||
"runtime_failed",
|
||||
];
|
||||
case "done":
|
||||
case "error":
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断事件是否可在当前会话状态下应用。
|
||||
* 除 phase 白名单外,waiting_user 还需 waitingReason 与事件类型匹配。
|
||||
*/
|
||||
export function canApplyEvent(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): boolean {
|
||||
if (!getAllowedEvents(session).includes(event.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reason = session.waitingReason;
|
||||
|
||||
switch (event.type) {
|
||||
case "skill_selected":
|
||||
return reason?.kind === "skill_selection";
|
||||
case "user_submitted_input":
|
||||
return (
|
||||
reason?.kind === "intake" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision"
|
||||
);
|
||||
case "user_confirmed_intake":
|
||||
return reason?.kind === "intake";
|
||||
case "user_approved_next_step":
|
||||
case "user_rejected_next_step":
|
||||
return reason?.kind === "approve_step";
|
||||
case "user_accepted_artifact":
|
||||
case "user_rejected_artifact":
|
||||
return reason?.kind === "review_artifact";
|
||||
case "user_requested_revision":
|
||||
return (
|
||||
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
|
||||
);
|
||||
case "main_agent_decision_created":
|
||||
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
|
||||
return session.phase === "running" || reason?.kind === "input";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验失败或 runtime_failed:进入 error phase 并 emit_message */
|
||||
function fail(session: RuntimeSession, reason: string): ApplyEventResult {
|
||||
return {
|
||||
session: touch({ ...session, phase: "error", waitingReason: undefined }),
|
||||
effects: [{ type: "emit_message", message: reason }],
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* worker_completed 的核心分支:按 acceptanceMode 决定下一步。
|
||||
* user_confirmed 时 caller 需再包一层 waiting(review_artifact)。
|
||||
*/
|
||||
function handleWorkerCompleted(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent & { type: "worker_completed" },
|
||||
): ApplyEventResult {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
|
||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||
let next = appendHistory(session, event);
|
||||
|
||||
if (mode === "user_confirmed") {
|
||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||
return {
|
||||
session: touch({
|
||||
...next,
|
||||
pendingArtifactId: artifact.id,
|
||||
currentWorkerId: undefined,
|
||||
resumeContext: undefined,
|
||||
}),
|
||||
effects: [],
|
||||
// worker_completed case 外层会补 waiting(review_artifact)
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "no_confirmation") {
|
||||
next = updateArtifact(next, artifact.id, { status: "accepted" });
|
||||
return {
|
||||
session: touch({
|
||||
...running(next),
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
resumeContext: undefined,
|
||||
}),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// programmatic_review
|
||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||
return {
|
||||
session: touch({
|
||||
...running(next),
|
||||
pendingArtifactId: artifact.id,
|
||||
currentWorkerId: undefined,
|
||||
}),
|
||||
effects: [{ type: "run_programmatic_review", artifactId: artifact.id }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单个 RuntimeEvent,返回新会话与副作用列表。
|
||||
* 不合法的事件不会抛异常,而是返回 error 并将 phase 置为 error。
|
||||
*/
|
||||
export function applyEvent(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): ApplyEventResult {
|
||||
if (!canApplyEvent(session, event)) {
|
||||
return fail(
|
||||
session,
|
||||
`Event ${event.type} is not allowed in phase ${session.phase}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
// ── 启动:选 skill ──
|
||||
case "session_started": {
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
presetId: event.payload.presetId,
|
||||
flowId: event.payload.flowId,
|
||||
},
|
||||
event,
|
||||
);
|
||||
const skills = event.payload.availableSkills;
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "skill_selection",
|
||||
availableSkills: skills,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: formatSkillSelectionPrompt(skills),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "skill_selected": {
|
||||
const { skill } = event.payload;
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
flowId: skill.defaultFlowId ?? session.flowId,
|
||||
slots: {
|
||||
...session.slots,
|
||||
activeSkill: skill,
|
||||
},
|
||||
},
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "intake",
|
||||
prompt: skill.startupPrompt,
|
||||
}),
|
||||
effects: [{ type: "emit_message", message: skill.startupPrompt }],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_confirmed_intake": {
|
||||
if (session.waitingReason?.kind !== "intake") {
|
||||
return fail(session, "user_confirmed_intake requires intake waiting");
|
||||
}
|
||||
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
if (!activeSkill?.startupTargetKey || !activeSkill.intakeFields?.length) {
|
||||
return fail(session, "No intake fields on active skill");
|
||||
}
|
||||
const values = readIntakeValues(session.slots);
|
||||
const progress = buildIntakeProgress(activeSkill.intakeFields, values);
|
||||
if (!progress.ready) {
|
||||
return fail(session, "必要项尚未填完,无法确认");
|
||||
}
|
||||
const demandText = synthesizeDemandText(activeSkill.intakeFields, values);
|
||||
const slots: Record<string, unknown> = {
|
||||
...session.slots,
|
||||
intakeValues: values,
|
||||
startupCompleted: true,
|
||||
[activeSkill.startupTargetKey]: demandText,
|
||||
lastUserInput: demandText,
|
||||
};
|
||||
const next = appendHistory({ ...session, slots, resumeContext: undefined }, event);
|
||||
return {
|
||||
session: touch(running(next)),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户输入 ──
|
||||
case "user_submitted_input": {
|
||||
const hadResume = Boolean(session.resumeContext);
|
||||
const activeSkill = session.slots.activeSkill as
|
||||
| { startupTargetKey?: string }
|
||||
| undefined;
|
||||
const slots: Record<string, unknown> = {
|
||||
...session.slots,
|
||||
lastUserInput: event.payload.text,
|
||||
userInputs: [
|
||||
...((session.slots.userInputs as string[] | undefined) ?? []),
|
||||
event.payload.text,
|
||||
],
|
||||
};
|
||||
const demandKey = activeSkill?.startupTargetKey;
|
||||
const text = event.payload.text.trim();
|
||||
|
||||
if (session.waitingReason?.kind === "intake") {
|
||||
const skill = activeSkill as ActiveSkillSnapshot | undefined;
|
||||
const intakeValues =
|
||||
event.payload.intakeValues ??
|
||||
readIntakeValues(session.slots);
|
||||
slots.intakeValues = intakeValues;
|
||||
slots.intakeSubmitCount =
|
||||
((session.slots.intakeSubmitCount as number) ?? 0) + 1;
|
||||
if (skill?.intakeFields?.length) {
|
||||
const preview = synthesizeDemandText(skill.intakeFields, intakeValues);
|
||||
if (preview.trim()) {
|
||||
slots[demandKey ?? "intake.preview"] = preview;
|
||||
} else if (text) {
|
||||
mergeSlotText(slots, demandKey ?? "intake.preview", text);
|
||||
}
|
||||
} else if (text && demandKey) {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
|
||||
const progress = skill?.intakeFields?.length
|
||||
? buildIntakeProgress(skill.intakeFields, intakeValues)
|
||||
: null;
|
||||
const followUpSent = Boolean(session.slots.intakeFollowUpSent);
|
||||
const effects: PhaseEffect[] = [];
|
||||
if (
|
||||
progress &&
|
||||
!progress.ready &&
|
||||
!followUpSent &&
|
||||
(slots.intakeSubmitCount as number) >= 1
|
||||
) {
|
||||
const msg = buildIntakeFollowUpMessage(progress);
|
||||
if (msg) {
|
||||
slots.intakeFollowUpSent = true;
|
||||
effects.push({ type: "emit_message", message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
resumeContext: hadResume ? session.resumeContext : undefined,
|
||||
},
|
||||
event,
|
||||
);
|
||||
const prompt =
|
||||
session.waitingReason.prompt ?? skill?.startupPrompt ?? "";
|
||||
return {
|
||||
session: waiting(touch(next), { kind: "intake", prompt }),
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "worker_questions") {
|
||||
slots["用户.worker答复"] = text;
|
||||
if (demandKey && text) {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
} else if (demandKey && session.waitingReason?.kind === "input" && text) {
|
||||
if (!session.slots.startupCompleted) {
|
||||
slots[demandKey] = text;
|
||||
slots.startupCompleted = true;
|
||||
} else {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
}
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
resumeContext: hadResume ? session.resumeContext : undefined,
|
||||
},
|
||||
event,
|
||||
);
|
||||
|
||||
if (hadResume) {
|
||||
// worker 提问后的回复 → 恢复 worker
|
||||
return {
|
||||
session: touch(running({ ...next, resumeContext: session.resumeContext })),
|
||||
effects: [{ type: "resume_worker" }],
|
||||
};
|
||||
}
|
||||
|
||||
// 常规定稿用户输入 → 等总管下一步
|
||||
return {
|
||||
session: touch(running({ ...next, resumeContext: undefined })),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 总管决策 ──
|
||||
case "main_agent_decision_created": {
|
||||
const { decision } = event.payload;
|
||||
if (decision.statePatchAllowed !== false) {
|
||||
return fail(session, "Main Agent decision must set statePatchAllowed to false");
|
||||
}
|
||||
|
||||
const next = appendHistory({ ...session, pendingDecision: decision }, event);
|
||||
|
||||
switch (decision.action) {
|
||||
case "ask_user":
|
||||
case "review_blackboard":
|
||||
return {
|
||||
session: waiting(
|
||||
{ ...next, pendingDecision: undefined },
|
||||
{ kind: "input", message: decision.reason },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
case "finish":
|
||||
return {
|
||||
session: touch({
|
||||
...next,
|
||||
phase: "done",
|
||||
waitingReason: undefined,
|
||||
pendingDecision: undefined,
|
||||
}),
|
||||
effects: [
|
||||
{ type: "emit_message", message: decision.reason || "流程已完成。" },
|
||||
],
|
||||
};
|
||||
case "create_temp_worker":
|
||||
case "run_worker": {
|
||||
if (!decision.workerId) {
|
||||
return fail(session, "run_worker requires workerId");
|
||||
}
|
||||
if (decision.requiresApproval) {
|
||||
// 需用户确认后才真正 run_worker
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "approve_step",
|
||||
decisionId: decision.id,
|
||||
}),
|
||||
effects: [
|
||||
{ type: "emit_message", message: `等待确认:${decision.reason}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: touch(running({ ...next, currentWorkerId: decision.workerId })),
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: decision.workerId,
|
||||
workerContext: decision.workerContext,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return fail(session, "Unknown decision action");
|
||||
}
|
||||
}
|
||||
|
||||
case "user_approved_next_step": {
|
||||
const decision = session.pendingDecision;
|
||||
if (!decision || decision.id !== event.payload.decisionId) {
|
||||
return fail(session, "No matching pending decision to approve");
|
||||
}
|
||||
if (!decision.workerId) {
|
||||
return fail(session, "Approved decision has no workerId");
|
||||
}
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, currentWorkerId: decision.workerId },
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: decision.workerId,
|
||||
workerContext: decision.workerContext,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_rejected_next_step": {
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, pendingDecision: undefined },
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Worker 生命周期 ──
|
||||
case "worker_started": {
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: event.payload.workerId,
|
||||
currentStepId: event.payload.stepId,
|
||||
acceptanceMode: event.payload.acceptanceMode,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "worker_needs_input": {
|
||||
const questions = event.payload.questions
|
||||
.map((q) => q.trim())
|
||||
.filter(Boolean);
|
||||
const normalized =
|
||||
questions.length > 0
|
||||
? questions
|
||||
: ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
|
||||
const ctx: ResumeContext = {
|
||||
workerId: event.payload.workerId,
|
||||
stepId: event.payload.stepId ?? session.currentStepId,
|
||||
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
|
||||
questions: normalized,
|
||||
};
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory(
|
||||
{ ...session, resumeContext: ctx, currentWorkerId: event.payload.workerId },
|
||||
event,
|
||||
),
|
||||
{
|
||||
kind: "worker_questions",
|
||||
workerId: event.payload.workerId,
|
||||
questions: normalized,
|
||||
},
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "worker_completed": {
|
||||
const result = handleWorkerCompleted(session, event);
|
||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
|
||||
return {
|
||||
...result,
|
||||
session: waiting(result.session, {
|
||||
kind: "review_artifact",
|
||||
artifactId: result.session.pendingArtifactId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 产物验收 ──
|
||||
case "user_accepted_artifact": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({
|
||||
...next,
|
||||
pendingArtifactId: undefined,
|
||||
pendingDecision: undefined,
|
||||
}),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_rejected_artifact": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "rejected" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_requested_revision": {
|
||||
if (session.waitingReason?.kind === "approve_step") {
|
||||
// 在确认步骤时发修改意见,等同拒绝并回到总管
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory({ ...session, pendingDecision: undefined }, event),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory({ ...session, pendingArtifactId: undefined }, event),
|
||||
{ kind: "revision", instruction: event.payload.instruction },
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `返工请求:${event.payload.instruction}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 程序验收 ──
|
||||
case "programmatic_review_started":
|
||||
return { session: appendHistory(running(session), event), effects: [] };
|
||||
|
||||
case "programmatic_review_passed": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({ ...next, pendingArtifactId: undefined }),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
case "programmatic_review_failed": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "revision_requested" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `程序验收失败:${event.payload.reason}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 终止 ──
|
||||
case "flow_completed":
|
||||
return {
|
||||
session: touch({
|
||||
...appendHistory(session, event),
|
||||
phase: "done",
|
||||
waitingReason: undefined,
|
||||
}),
|
||||
effects: [],
|
||||
};
|
||||
|
||||
case "runtime_failed":
|
||||
return fail(appendHistory(session, event), event.payload.reason);
|
||||
|
||||
default:
|
||||
return fail(session, "Unhandled event type");
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建 worker 产物记录(drafted 状态),由 runtime 层在 worker_complete 前写入 session.artifacts */
|
||||
export function createArtifact(params: {
|
||||
workerId: string;
|
||||
stepId?: string;
|
||||
outputTags: string[];
|
||||
summary?: string;
|
||||
}): ArtifactRecord {
|
||||
const ts = nowIso();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
workerId: params.workerId,
|
||||
stepId: params.stepId,
|
||||
outputTags: params.outputTags,
|
||||
status: "drafted",
|
||||
summary: params.summary,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
}
|
||||
|
||||
export { type RuntimePhase, type WaitingReason };
|
||||
|
||||
function formatSkillSelectionPrompt(
|
||||
skills: Array<{ name: string; description: string }>,
|
||||
): string {
|
||||
const lines = skills.map((s, i) => ` ${i + 1}. ${s.name} — ${s.description}`);
|
||||
return ["请选择创作 skill(输入 name 或编号):", ...lines].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* 阶段机运行层(有副作用的执行器)。
|
||||
*
|
||||
* 职责:
|
||||
* - 持有可变 session,统一 dispatch(event) 入口
|
||||
* - 调用纯函数 applyEvent,再 processEffects 执行 IO(消息、worker、验收)
|
||||
* - 启动时从 skills/ 加载 registry,驱动 skill 选择与 SKILL.md 启动询问
|
||||
*
|
||||
* 与 phase-machine.ts 的分工:前者「算状态」,本文件「跑状态」。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
applyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
getAllowedEvents,
|
||||
} from "./phase-machine.js";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
|
||||
import { listSkills, listWorkerSkills, loadSkill, loadWorkerSkill, resolveSkillId } from "../skills/loader.js";
|
||||
import { toActiveSkillSnapshot } from "../skills/snapshot.js";
|
||||
import { runWorkerSkill } from "../worker/executor.js";
|
||||
import { resolveWorkerId } from "../worker/resolve-id.js";
|
||||
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
|
||||
import { extractIntakeFromMessage } from "../intake/extract.js";
|
||||
import { readIntakeValues } from "../intake/intake.js";
|
||||
import type {
|
||||
AcceptanceMode,
|
||||
ActiveSkillSnapshot,
|
||||
ApplyEventResult,
|
||||
MainAgentDecision,
|
||||
PhaseEffect,
|
||||
RuntimeEvent,
|
||||
RuntimeSession,
|
||||
SkillIndexEntry,
|
||||
} from "../types/runtime.js";
|
||||
import type { BlackboardItem } from "../types/blackboard.js";
|
||||
|
||||
export type PhaseRuntimeOptions = {
|
||||
presetId?: string;
|
||||
flowId?: string;
|
||||
/** worker 完成后的默认验收模式,会写入 worker_started 事件 */
|
||||
acceptanceMode?: AcceptanceMode;
|
||||
/** true 时 run_worker 自动走占位 worker(演示 / 测试用) */
|
||||
autoStubWorker?: boolean;
|
||||
/** 传入后 invoke_main_agent 会自动调用总管 LLM */
|
||||
llm?: LlmProvider;
|
||||
/** 运行时黑板;worker 产出写入此处 */
|
||||
blackboard?: Blackboard;
|
||||
/** 副作用 emit_message 与用户提示的回调(Web / CLI 接入点) */
|
||||
onMessage?: (message: string) => void;
|
||||
/** 从磁盘恢复时使用,跳过 createSession */
|
||||
initialSession?: RuntimeSession;
|
||||
/** 与 initialSession 一并恢复黑板 */
|
||||
initialBlackboardItems?: BlackboardItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 阶段机运行层:包装纯函数 phase-machine,处理副作用。
|
||||
* 启动时加载 skills/,先选 skill,再读 SKILL.md 启动询问。
|
||||
*/
|
||||
export class PhaseRuntime {
|
||||
private session: RuntimeSession;
|
||||
private readonly acceptanceMode: AcceptanceMode;
|
||||
private autoStubWorker: boolean;
|
||||
private readonly onMessage: (message: string) => void;
|
||||
private readonly blackboard: Blackboard;
|
||||
private llm?: LlmProvider;
|
||||
private mainAgent?: MainAgent;
|
||||
/** autoStubWorker=false 时,run_worker effect 暂存于此,等 startPendingWorker() */
|
||||
private pendingWorkerEffect: Extract<PhaseEffect, { type: "run_worker" }> | null =
|
||||
null;
|
||||
/** start() 时从 registry 加载,供 skill_selection 展示与编号解析 */
|
||||
private availableSkills: SkillIndexEntry[] = [];
|
||||
|
||||
constructor(options: PhaseRuntimeOptions = {}) {
|
||||
this.session =
|
||||
options.initialSession ??
|
||||
createSession(options.presetId ?? "default", options.flowId);
|
||||
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
|
||||
this.autoStubWorker = options.autoStubWorker ?? false;
|
||||
this.onMessage = options.onMessage ?? (() => {});
|
||||
this.blackboard = options.blackboard ?? new Blackboard();
|
||||
if (options.initialBlackboardItems?.length) {
|
||||
this.blackboard.seed(options.initialBlackboardItems);
|
||||
}
|
||||
if (options.llm) {
|
||||
this.llm = options.llm;
|
||||
this.mainAgent = new MainAgent(options.llm);
|
||||
}
|
||||
}
|
||||
|
||||
getBlackboard(): Blackboard {
|
||||
return this.blackboard;
|
||||
}
|
||||
|
||||
hasMainAgent(): boolean {
|
||||
return Boolean(this.mainAgent);
|
||||
}
|
||||
|
||||
/** 设置变更后热更新 LLM(API profile / preset) */
|
||||
reloadLlm(llm: LlmProvider, autoStubWorker?: boolean): void {
|
||||
this.llm = llm;
|
||||
this.mainAgent = new MainAgent(llm);
|
||||
if (autoStubWorker !== undefined) {
|
||||
this.autoStubWorker = autoStubWorker;
|
||||
}
|
||||
}
|
||||
|
||||
getLlm(): LlmProvider | undefined {
|
||||
return this.llm;
|
||||
}
|
||||
|
||||
getSession(): RuntimeSession {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
setLifecycleStage(stage: "design" | "play"): void {
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, uiLifecycleStage: stage },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableSkills(): SkillIndexEntry[] {
|
||||
return this.availableSkills;
|
||||
}
|
||||
|
||||
/** 恢复会话或首次 start 前加载 skill 列表(供 UI 展示) */
|
||||
async ensureAvailableSkills(): Promise<void> {
|
||||
if (this.availableSkills.length === 0) {
|
||||
this.availableSkills = await listSkills();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSkill(): ActiveSkillSnapshot | undefined {
|
||||
return this.session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
}
|
||||
|
||||
getAllowedEventTypes(): RuntimeEvent["type"][] {
|
||||
return getAllowedEvents(this.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于「running 且无事可做,等总管决策」状态。
|
||||
* Web 层据此显示「生成大纲」等按钮。
|
||||
*/
|
||||
needsMainAgentDecision(): boolean {
|
||||
return (
|
||||
this.session.phase === "running" &&
|
||||
!this.session.currentWorkerId &&
|
||||
!this.pendingWorkerEffect
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载 skills/registry → 触发 session_started → waiting_user(skill_selection) */
|
||||
async start(): Promise<RuntimeSession> {
|
||||
this.availableSkills = await listSkills();
|
||||
if (this.availableSkills.length === 0) {
|
||||
throw new Error("skills/ 下没有找到任何 skill 文件(novel/*.md 或 dialogue/*.md)");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "session_started",
|
||||
payload: {
|
||||
presetId: this.session.presetId,
|
||||
flowId: this.session.flowId,
|
||||
availableSkills: this.availableSkills,
|
||||
},
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 启动并预选总管,跳过 skill_selection */
|
||||
async startWithOrchestrator(orchestratorId: string): Promise<RuntimeSession> {
|
||||
await this.start();
|
||||
if (this.session.waitingReason?.kind === "skill_selection") {
|
||||
await this.selectSkill(orchestratorId);
|
||||
}
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 解析并加载 SKILL.md → 触发 skill_selected → waiting_user(input) */
|
||||
async selectSkill(skillIdOrName: string): Promise<RuntimeSession> {
|
||||
const dir = await resolveSkillId(skillIdOrName);
|
||||
if (!dir) {
|
||||
throw new Error(`未找到 skill: ${skillIdOrName}`);
|
||||
}
|
||||
const parsed = await loadSkill(dir);
|
||||
await this.dispatch({
|
||||
type: "skill_selected",
|
||||
payload: { skill: toActiveSkillSnapshot(parsed) },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
|
||||
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
|
||||
const result = applyEvent(this.session, event);
|
||||
this.session = result.session;
|
||||
|
||||
if (
|
||||
event.type === "user_submitted_input" ||
|
||||
event.type === "user_confirmed_intake"
|
||||
) {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
}
|
||||
|
||||
if (result.error && event.type !== "runtime_failed") {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 将 slots 中的启动目标等同步为黑板 tag(迁移期 tag 名可与旧 key 相同) */
|
||||
private syncSlotsToBlackboard(session: RuntimeSession): void {
|
||||
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
if (activeSkill?.startupTargetKey) {
|
||||
const tag = activeSkill.startupTargetKey;
|
||||
const content = session.slots[tag];
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: content.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
const workerReply = session.slots["用户.worker答复"];
|
||||
if (typeof workerReply === "string" && workerReply.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.worker答复",
|
||||
content: workerReply.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
const revision = session.slots.revisionInstruction;
|
||||
if (typeof revision === "string" && revision.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.修改说明",
|
||||
content: revision.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户文本输入的统一入口。
|
||||
* skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。
|
||||
*/
|
||||
async submitInput(text: string): Promise<RuntimeSession> {
|
||||
if (this.session.waitingReason?.kind === "skill_selection") {
|
||||
const picked = await this.resolveSkillFromUserInput(text);
|
||||
if (!picked) {
|
||||
throw new Error(`无法识别 skill: ${text}。请输入 name 或列表编号。`);
|
||||
}
|
||||
return this.selectSkill(picked);
|
||||
}
|
||||
if (this.session.waitingReason?.kind === "intake") {
|
||||
const skill = this.getActiveSkill();
|
||||
const fields = skill?.intakeFields ?? [];
|
||||
const current = readIntakeValues(this.session.slots);
|
||||
const intakeValues = await extractIntakeFromMessage(
|
||||
text,
|
||||
fields,
|
||||
current,
|
||||
this.llm,
|
||||
);
|
||||
await this.dispatch({
|
||||
type: "user_submitted_input",
|
||||
payload: { text, intakeValues },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
await this.dispatch({ type: "user_submitted_input", payload: { text } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 必要项已填完:确认进入实例化 / 下一阶段 */
|
||||
async confirmIntake(): Promise<RuntimeSession> {
|
||||
if (this.session.waitingReason?.kind !== "intake") {
|
||||
throw new Error("当前不在填空收集阶段");
|
||||
}
|
||||
await this.dispatch({ type: "user_confirmed_intake", payload: {} });
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 支持按编号(1-based)或 skill name 解析 */
|
||||
private async resolveSkillFromUserInput(text: string): Promise<string | null> {
|
||||
const trimmed = text.trim();
|
||||
const num = Number(trimmed);
|
||||
if (Number.isInteger(num) && num >= 1 && num <= this.availableSkills.length) {
|
||||
const entry = this.availableSkills[num - 1];
|
||||
return resolveSkillId(entry.name);
|
||||
}
|
||||
return resolveSkillId(trimmed);
|
||||
}
|
||||
|
||||
/** 提交总管决策(通常来自 Main Agent / 手动按钮) */
|
||||
async submitDecision(decision: MainAgentDecision): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户确认 pendingDecision(approve_step) */
|
||||
async approve(): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待确认的决策");
|
||||
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingDecision,回到总管 */
|
||||
async rejectStep(reason?: string): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待拒绝的决策");
|
||||
await this.dispatch({
|
||||
type: "user_rejected_next_step",
|
||||
payload: { decisionId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户接受 pendingArtifact */
|
||||
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) throw new Error("当前没有待验收的产物");
|
||||
await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingArtifact,进入 revision */
|
||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) throw new Error("当前没有待拒绝的产物");
|
||||
await this.dispatch({
|
||||
type: "user_rejected_artifact",
|
||||
payload: { artifactId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** worker 调用 ask_user 能力时,由外层触发此事件 */
|
||||
async workerAsk(questions: string[]): Promise<RuntimeSession> {
|
||||
const workerId = this.session.currentWorkerId;
|
||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||
await this.dispatch({
|
||||
type: "worker_needs_input",
|
||||
payload: { workerId, stepId: this.session.currentStepId, questions },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动标记 worker 完成:先创建 artifact 写入 session,再 dispatch worker_completed。
|
||||
* 真实 worker 集成时,产物内容应在此之前写入 slots / blackboard。
|
||||
*/
|
||||
async workerComplete(summary?: string): Promise<RuntimeSession> {
|
||||
const workerId = this.session.currentWorkerId;
|
||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: ["output.草稿"],
|
||||
summary: summary ?? `${workerId} 产物`,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 非 autoStub 模式下,手动启动 pendingWorkerEffect */
|
||||
async startPendingWorker(): Promise<RuntimeSession> {
|
||||
const effect = this.pendingWorkerEffect;
|
||||
if (!effect) throw new Error("没有待启动的 worker");
|
||||
this.pendingWorkerEffect = null;
|
||||
await this.runStubWorker(effect, false);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
|
||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||
for (const effect of effects) {
|
||||
switch (effect.type) {
|
||||
case "emit_message":
|
||||
this.onMessage(effect.message);
|
||||
break;
|
||||
case "invoke_main_agent":
|
||||
if (!this.mainAgent) {
|
||||
this.onMessage(
|
||||
"[阶段机] running:等待总管决策。请用 /decide 提交 run_worker 或 finish。",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "run_worker":
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(effect);
|
||||
} else if (this.llm) {
|
||||
await this.runRealWorker(effect);
|
||||
} else {
|
||||
this.pendingWorkerEffect = effect;
|
||||
this.onMessage(
|
||||
`[阶段机] 待启动 worker: ${effect.workerId}。输入 /worker-start`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "resume_worker": {
|
||||
const ctx = this.session.resumeContext;
|
||||
if (!ctx) break;
|
||||
const workerEffect: Extract<PhaseEffect, { type: "run_worker" }> = {
|
||||
type: "run_worker",
|
||||
workerId: ctx.workerId,
|
||||
};
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(workerEffect);
|
||||
} else if (this.llm) {
|
||||
await this.runRealWorker(workerEffect);
|
||||
} else {
|
||||
this.pendingWorkerEffect = workerEffect;
|
||||
this.onMessage(`[阶段机] 恢复 worker: ${ctx.workerId}。输入 /worker-start`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run_programmatic_review":
|
||||
await this.runProgrammaticReview(effect.artifactId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runRealWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
): Promise<void> {
|
||||
const activeSkill = this.getActiveSkill();
|
||||
if (!activeSkill?.name) {
|
||||
throw new Error("当前没有 active skill,无法运行 worker");
|
||||
}
|
||||
if (!this.llm) {
|
||||
throw new Error("未配置 LLM,无法运行 worker");
|
||||
}
|
||||
|
||||
const workerId = resolveWorkerId(effect.workerId);
|
||||
let slots = { ...this.session.slots };
|
||||
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
|
||||
if (effect.workerContext?.roleId?.trim()) {
|
||||
const roleId = effect.workerContext.roleId.trim();
|
||||
slots["世界.当前角色.id"] = roleId;
|
||||
this.blackboard.write({
|
||||
tag: "世界.当前角色.id",
|
||||
content: roleId,
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = { ...this.session, slots };
|
||||
}
|
||||
|
||||
this.session = (
|
||||
await this.dispatch({
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
})
|
||||
).session;
|
||||
|
||||
const skill = await loadSkill(activeSkill.name);
|
||||
const worker = await loadWorkerSkill(activeSkill.name, effect.workerId);
|
||||
const workerLlm = resolveWorkerLlmProvider({
|
||||
worker,
|
||||
bindings: skill.workerLlmBindings,
|
||||
slots,
|
||||
fallbackLlm: this.llm,
|
||||
});
|
||||
|
||||
this.onMessage(`[Worker] ${workerId} 执行中…`);
|
||||
|
||||
const result = await runWorkerSkill({
|
||||
skillName: activeSkill.name,
|
||||
workerId: effect.workerId,
|
||||
slots,
|
||||
blackboard: this.blackboard,
|
||||
llm: workerLlm,
|
||||
});
|
||||
|
||||
if (result.askUser?.length) {
|
||||
await this.workerAsk(result.askUser);
|
||||
return;
|
||||
}
|
||||
|
||||
slots = { ...this.session.slots };
|
||||
for (const [tag, content] of Object.entries(result.outputs)) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content,
|
||||
source: workerId,
|
||||
});
|
||||
slots[tag] = content;
|
||||
}
|
||||
this.session = { ...this.session, slots };
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: Object.keys(result.outputs),
|
||||
summary: result.summary,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
this.onMessage(
|
||||
`[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`,
|
||||
);
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位 worker:worker_started → 可选自动 worker_completed。
|
||||
* 演示与单测用,真实环境应替换为真实 worker 调度。
|
||||
*/
|
||||
private async runStubWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
autoComplete = true,
|
||||
): Promise<void> {
|
||||
this.session = (
|
||||
await this.dispatch({
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
})
|
||||
).session;
|
||||
|
||||
this.onMessage(`[Worker 占位] ${effect.workerId} 已开始。`);
|
||||
|
||||
if (!autoComplete) return;
|
||||
|
||||
const lastInput = String(this.session.slots.lastUserInput ?? "");
|
||||
const value = `[${effect.workerId} 占位输出]\n${lastInput ? `输入: ${lastInput}\n` : ""}说明: stub worker,后续替换为真实 LLM worker。`;
|
||||
|
||||
const outputTags = await this.resolveWorkerOutputTags(effect.workerId);
|
||||
const tagsToWrite = outputTags.length > 0 ? outputTags : ["output.草稿"];
|
||||
|
||||
for (const tag of tagsToWrite) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: value,
|
||||
source: effect.workerId,
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: tagsToWrite,
|
||||
summary: `${effect.workerId} 占位产物`,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWorkerOutputTags(workerId: string): Promise<string[]> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return [];
|
||||
try {
|
||||
const worker = await loadWorkerSkill(active.name, workerId);
|
||||
return worker.outputTags;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 简易程序验收:summary 非空则通过,否则 revision */
|
||||
private async runProgrammaticReview(artifactId: string): Promise<void> {
|
||||
await this.dispatch({ type: "programmatic_review_started", payload: { artifactId } });
|
||||
|
||||
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
|
||||
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
|
||||
|
||||
await this.dispatch(
|
||||
passed
|
||||
? { type: "programmatic_review_passed", payload: { artifactId } }
|
||||
: {
|
||||
type: "programmatic_review_failed",
|
||||
payload: { artifactId, reason: "产物 summary 为空" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
|
||||
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
|
||||
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (!this.mainAgent || this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWorkers = await this.resolveAvailableWorkers();
|
||||
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = applyEvent(this.session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
this.session = result.session;
|
||||
|
||||
if (result.error) {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
|
||||
private readBlackboardForAgent(tags: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const pattern of tags) {
|
||||
const items = this.blackboard.queryByPatterns([pattern], "latest");
|
||||
if (items.length === 0) {
|
||||
const slotVal = this.session.slots[pattern];
|
||||
if (typeof slotVal === "string" && slotVal.trim()) {
|
||||
out[pattern] = slotVal.trim();
|
||||
} else {
|
||||
out[pattern] = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const item of items) {
|
||||
out[item.tag] = item.content;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async resolveAvailableWorkers(): Promise<
|
||||
Array<{ id: string; description: string }>
|
||||
> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return [...DEFAULT_WORKERS];
|
||||
try {
|
||||
const workers = await listWorkerSkills(active.name);
|
||||
if (workers.length === 0) return [...DEFAULT_WORKERS];
|
||||
return workers.map((w) => ({ id: w.id, description: w.description }));
|
||||
} catch {
|
||||
return [...DEFAULT_WORKERS];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造总管决策,强制 statePatchAllowed=false */
|
||||
export function createDecision(
|
||||
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||
Partial<Pick<MainAgentDecision, "id">>,
|
||||
): MainAgentDecision {
|
||||
return {
|
||||
id: partial.id ?? randomUUID(),
|
||||
statePatchAllowed: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/** CLI 调试:单行摘要当前会话状态 */
|
||||
export function formatSession(session: RuntimeSession): string {
|
||||
const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
const lines = [
|
||||
`phase: ${session.phase}`,
|
||||
session.waitingReason ? `waitingReason: ${session.waitingReason.kind}` : null,
|
||||
active ? `skill: ${active.name}` : null,
|
||||
session.slots.startupCompleted ? "startup: done" : null,
|
||||
session.slots["book.brief"] ? "book.brief: yes" : null,
|
||||
session.currentWorkerId ? `worker: ${session.currentWorkerId}` : null,
|
||||
`artifacts: ${session.artifacts.length}`,
|
||||
`events: ${session.history.length}`,
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** 集成测试用:从 start 到 finish 的最小闭环脚本 */
|
||||
export async function runMinimalClosedLoop(
|
||||
runtime: PhaseRuntime,
|
||||
): Promise<RuntimeSession> {
|
||||
await runtime.start();
|
||||
await runtime.selectSkill("basic");
|
||||
await runtime.submitInput("科幻长篇,第三人称,约 20 万字");
|
||||
await runtime.confirmIntake();
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
reason: "生成大纲",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
});
|
||||
await runtime.submitDecision(decision);
|
||||
await runtime.approve();
|
||||
|
||||
if (runtime.getSession().currentWorkerId) {
|
||||
await runtime.workerComplete();
|
||||
}
|
||||
|
||||
await runtime.acceptArtifact();
|
||||
await runtime.submitDecision(
|
||||
createDecision({ action: "finish", reason: "完成", requiresApproval: false }),
|
||||
);
|
||||
|
||||
return runtime.getSession();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @deprecated 请使用 phase-machine.ts */
|
||||
export {
|
||||
applyEvent,
|
||||
canApplyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
getAllowedEvents,
|
||||
} from "./phase-machine.js";
|
||||
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ParsedToolCall } from "../llm/client.js";
|
||||
import type { MainAgentDecision } from "../types/runtime.js";
|
||||
import {
|
||||
isMainAgentLoopTool,
|
||||
isMainAgentTerminalTool,
|
||||
type MainAgentToolName,
|
||||
} from "../types/tools.js";
|
||||
|
||||
export class ToolValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new ToolValidationError("Tool arguments must be a JSON object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
if (e instanceof ToolValidationError) throw e;
|
||||
throw new ToolValidationError(`Invalid tool arguments JSON: ${raw.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
): string {
|
||||
const val = args[key];
|
||||
if (typeof val !== "string" || !val.trim()) {
|
||||
throw new ToolValidationError(`Tool parameter "${key}" must be a non-empty string`);
|
||||
}
|
||||
return val.trim();
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const val = args[key];
|
||||
if (typeof val !== "string") return undefined;
|
||||
const trimmed = val.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
/** 将终止 tool call 转为 MainAgentDecision */
|
||||
export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision {
|
||||
const name = call.name;
|
||||
if (!isMainAgentTerminalTool(name)) {
|
||||
throw new ToolValidationError(`Not a terminal tool: ${name}`);
|
||||
}
|
||||
|
||||
const args = parseArgs(call.arguments);
|
||||
|
||||
switch (name as MainAgentToolName) {
|
||||
case "ask_user": {
|
||||
const reason = requireString(args, "reason");
|
||||
const message = optionalString(args, "message");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "ask_user",
|
||||
reason: message ? `${reason}\n${message}` : reason,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "run_worker": {
|
||||
const workerId = requireString(args, "workerId");
|
||||
const reason = requireString(args, "reason");
|
||||
const requiresApproval = Boolean(args.requiresApproval);
|
||||
const roleId = optionalString(args, "roleId");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "run_worker",
|
||||
reason,
|
||||
workerId,
|
||||
workerContext: roleId ? { roleId } : undefined,
|
||||
requiresApproval,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "review_blackboard": {
|
||||
const reason = requireString(args, "reason");
|
||||
const summary = requireString(args, "summary");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "review_blackboard",
|
||||
reason: `${reason}\n${summary}`,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "finish": {
|
||||
const reason = requireString(args, "reason");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "finish",
|
||||
reason,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new ToolValidationError(`Unknown terminal tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLoopToolCall(call: ParsedToolCall): MainAgentToolName {
|
||||
if (!isMainAgentLoopTool(call.name)) {
|
||||
throw new ToolValidationError(`Unknown loop tool: ${call.name}`);
|
||||
}
|
||||
parseArgs(call.arguments);
|
||||
return call.name;
|
||||
}
|
||||
Reference in New Issue
Block a user