引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:13:48 +08:00
co-authored by Cursor
parent ec474cb946
commit b82da4c5a5
95 changed files with 6781 additions and 1344 deletions
+12 -1
View File
@@ -1,5 +1,6 @@
import type { BlackboardItem } from "../types/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import { SLOT_PLAY_TURN_QUEUE, SLOT_PLAY_LAYER_ACTIVE } from "../skills/play-turn.js";
/**
* 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。
@@ -12,9 +13,14 @@ export function isRunPhaseBlackboardTag(tag: string): boolean {
if (/^book\./.test(tag)) return false;
if (/^情境\./.test(tag)) return false;
if (/^博弈\./.test(tag)) return false;
// 锁定开局:跟规格一起进 instance
if (tag === "输出.开场白" || tag === "运行.初始变量" || tag === "变量.当前") {
return false;
}
// run 层:剥离
// run 层:剥离(含聊天历史,不写进角色卡)
return (
tag === "对话.历史" ||
/^运行\./.test(tag) ||
/^世界\.(当前|裁决)/.test(tag) ||
/^场景\.公开/.test(tag) ||
@@ -35,6 +41,11 @@ export function prepareRuntimeSessionForInstance(session: RuntimeSession): Runti
copy.pendingDecision = undefined;
copy.currentWorkerId = undefined;
copy.waitingReason = undefined;
copy.slots = {
...copy.slots,
[SLOT_PLAY_TURN_QUEUE]: undefined,
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
copy.artifacts = copy.artifacts.filter((a) =>
a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)),
);
+62 -45
View File
@@ -1,6 +1,10 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
import { consumeOpenAiToolStream } from "./stream-complete.js";
import {
currentAbortSignal,
throwIfAborted,
} from "./run-abort.js";
export type ToolCallPayload = {
id: string;
@@ -58,6 +62,8 @@ export type CompleteOptions = {
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
/** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal;
};
export type CompleteWithToolsOptions = CompleteOptions & {
@@ -237,6 +243,24 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
return { content: "", toolCalls };
}
async function llmFetch(
url: string,
apiKey: string,
body: unknown,
options?: CompleteOptions,
): Promise<Response> {
throwIfAborted(options?.signal);
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: options?.signal ?? currentAbortSignal(),
});
}
export class OpenAiCompatibleProvider implements LlmProvider {
constructor(private readonly config: LlmConfig) {}
@@ -245,14 +269,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
options?: CompleteOptions,
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(buildRequestBody(this.config, messages, options)),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, options),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -282,16 +304,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, { ...options, stream: true }),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, { ...options, stream: true }),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -319,19 +337,15 @@ export class OpenAiCompatibleProvider implements LlmProvider {
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -362,20 +376,16 @@ export class OpenAiCompatibleProvider implements LlmProvider {
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
stream: true,
}),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
stream: true,
}),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -425,6 +435,7 @@ export class MockLlmProvider implements LlmProvider {
_messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
@@ -448,6 +459,7 @@ export class MockLlmProvider implements LlmProvider {
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
@@ -455,10 +467,12 @@ export class MockLlmProvider implements LlmProvider {
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
for (const ch of response) {
throwIfAborted(options?.signal);
callbacks.onContentDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
@@ -480,6 +494,7 @@ export class MockLlmProvider implements LlmProvider {
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const step = this.nextStep();
if (typeof step === "string") {
return {
@@ -507,9 +522,11 @@ export class MockLlmProvider implements LlmProvider {
_options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const reasoning =
"用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(_options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
+74 -34
View File
@@ -1,4 +1,5 @@
import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js";
import { loadAppSettings } from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import type { PresetPackage } from "../types/preset.js";
import type {
ChatMessage,
@@ -9,9 +10,35 @@ import type {
LlmProvider,
StreamCallbacks,
} from "./client.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "./token-tracker.js";
export type LlmTrackingRef = { current: LlmTrackingContext };
function readActivePreset(): PresetPackage | null {
return resolveActivePreset(loadAppSettings().activePresetId);
}
/**
* 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
* 当前没有选用预设时仍包一层,请求原样转发。
*/
export function wrapLlmForSession(
inner: LlmProvider,
trackingRef?: LlmTrackingRef,
getPreset: () => PresetPackage | null = readActivePreset,
): LlmProvider {
const tracked = trackingRef
? new TokenTrackingProvider(inner, () => trackingRef.current)
: inner;
return new PresetLlmProvider(tracked, getPreset);
}
/**
* 在所有 LLM 请求上合并当前 preset 的生成参数。
* prompt / marker 不在此前置,由 play worker 按扩展 prompt_order 填洞。
*/
export class PresetLlmProvider implements LlmProvider {
constructor(
@@ -19,41 +46,57 @@ export class PresetLlmProvider implements LlmProvider {
private readonly getPreset: () => PresetPackage | null,
) {}
private prepare(
messages: ChatMessage[],
options?: CompleteOptions,
): { messages: ChatMessage[]; options: CompleteOptions | undefined } {
const preset = this.getPreset();
if (!preset) return { messages, options };
// 生成参数全局合并;prompt/marker 只在 play worker 按 prompt_order 填洞,这里不再前置。
return {
messages,
options: {
...options,
generation: options?.generation ?? preset.generation,
},
};
}
async complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.complete(messages, options);
const prepared = this.prepare(messages, options);
return this.inner.complete(prepared.messages, prepared.options);
}
async completeStream(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
if (!this.inner.completeStream) {
const result = await this.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options?.generation ?? preset.generation;
return this.inner.complete(merged, {
...options,
generation,
});
const prepared = this.prepare(messages, options);
return this.inner.completeStream(
prepared.messages,
prepared.options,
callbacks,
);
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.completeWithTools(messages, options);
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options.generation ?? preset.generation;
return this.inner.completeWithTools(merged, {
const prepared = this.prepare(messages, options);
return this.inner.completeWithTools(prepared.messages, {
...options,
generation,
...prepared.options,
});
}
@@ -62,17 +105,14 @@ export class PresetLlmProvider implements LlmProvider {
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!this.inner.completeWithToolsStream) {
return this.completeWithTools(messages, options);
}
const presetMessages = preset ? assemblePresetMessages(preset) : [];
const merged = preset ? mergeMessages(presetMessages, messages) : messages;
const generation = options.generation ?? preset?.generation;
return this.inner.completeWithToolsStream(merged, {
...options,
generation,
}, callbacks);
const prepared = this.prepare(messages, options);
return this.inner.completeWithToolsStream(
prepared.messages,
{ ...options, ...prepared.options },
callbacks,
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
const storage = new AsyncLocalStorage<AbortSignal>();
export function runWithAbortSignal<T>(
signal: AbortSignal,
fn: () => Promise<T>,
): Promise<T> {
return storage.run(signal, fn);
}
export function currentAbortSignal(): AbortSignal | undefined {
return storage.getStore();
}
export function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message = "message" in err ? String(err.message) : "";
return /aborted|AbortError/i.test(message);
}
export function throwIfAborted(signal?: AbortSignal): void {
const active = signal ?? currentAbortSignal();
if (!active?.aborted) return;
const err = new Error("This operation was aborted");
err.name = "AbortError";
throw err;
}
+36 -5
View File
@@ -32,14 +32,21 @@ export type LlmTrackingContext = {
function capturePendingTrace(
ctx: LlmTrackingContext,
messages: Array<{ role: string; content: string }>,
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>,
caller: string | undefined,
model?: string,
generation?: CompleteOptions["generation"],
): void {
ctx.pendingContextTrace = buildContextTrace({
caller: caller ?? "unknown",
messages,
model,
generation,
});
}
@@ -130,7 +137,13 @@ export class TokenTrackingProvider implements LlmProvider {
async () => {
const result = await this.inner.complete(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
capturePendingTrace(
ctx,
messages,
options?.caller,
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -168,7 +181,13 @@ export class TokenTrackingProvider implements LlmProvider {
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
capturePendingTrace(
ctx,
messages,
options?.caller,
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -189,7 +208,13 @@ export class TokenTrackingProvider implements LlmProvider {
async () => {
const result = await this.inner.completeWithTools(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
capturePendingTrace(
ctx,
messages,
options.caller,
result.model,
options.generation,
);
recordUsage(ctx, options.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -224,7 +249,13 @@ export class TokenTrackingProvider implements LlmProvider {
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
capturePendingTrace(
ctx,
messages,
options.caller,
result.model,
options.generation,
);
recordUsage(ctx, options.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
+3
View File
@@ -77,6 +77,9 @@ const EFFECT_LABEL: Record<string, string> = {
run_programmatic_review: "程序验收",
emit_message: "发消息",
propose_next_creation_step: "提案下一步",
seal_creation_opening: "收口开场白",
run_play_turn: "开游玩回合",
continue_play_turn: "续游玩管线",
};
const TOOL_LABEL: Record<string, string> = {
+10 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { LlmProvider } from "../llm/client.js";
import { createLocalLlmDriver, type AgentDriver } from "../runtime/driver.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import type { BlackboardTagIndex } from "../types/blackboard.js";
import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js";
@@ -45,7 +46,14 @@ ${workerLines}
}
export class MainAgent {
constructor(private readonly llm: LlmProvider) {}
private readonly driver: AgentDriver;
constructor(
private readonly llm: LlmProvider,
driver?: AgentDriver,
) {
this.driver = driver ?? createLocalLlmDriver(llm);
}
/** @deprecated 单次 JSON 决策;请用 runToolLoop */
async decide(context: MainAgentContext): Promise<MainAgentDecision> {
@@ -70,6 +78,7 @@ export class MainAgent {
this.llm,
context,
options.handlers,
this.driver,
);
return decision;
}
+56 -111
View File
@@ -1,12 +1,15 @@
import type { ChatMessage, LlmProvider, ParsedToolCall, StreamCallbacks } from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js";
import type { LlmProvider, ParsedToolCall } from "../llm/client.js";
import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js";
import {
createLocalLlmDriver,
type AgentDriver,
type DriverToolCall,
} from "../runtime/driver.js";
import type { MainAgentDecision } from "../types/runtime.js";
import { isMainAgentTerminalTool } from "../types/tools.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
import type { MainAgentContext } from "./main-agent.js";
import { debugLog } from "../log.js";
export type ToolLoopHandlers = {
readBlackboard: (tags: string[]) => Record<string, string>;
@@ -31,8 +34,6 @@ export type ToolLoopResult = {
toolTrace: string[];
};
const MAX_TOOL_LOOP_ITERATIONS = 12;
function buildToolLoopSystemPrompt(
workers: Array<{ id: string; description: string }>,
): string {
@@ -54,35 +55,15 @@ ${workerLines}
选定节点后立即调用 run_worker。`;
}
async function completeToolsPreferStream(
llm: LlmProvider,
messages: ChatMessage[],
callbacks: StreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["completeWithTools"]>>> {
if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
}, callbacks);
}
const result = await llm.completeWithTools(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
});
if (result.reasoning) {
callbacks.onReasoningDelta?.(result.reasoning);
}
if (result.content) {
callbacks.onContentDelta?.(result.content);
}
return result;
}
function executeLoopTool(
call: ParsedToolCall,
call: DriverToolCall | ParsedToolCall,
handlers: ToolLoopHandlers,
): string {
const name = validateLoopToolCall(call);
const name = validateLoopToolCall({
id: call.id,
name: call.name,
arguments: call.arguments,
});
const args = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
switch (name) {
@@ -104,102 +85,66 @@ function executeLoopTool(
}
}
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: ParsedToolCall[],
): ChatMessage {
return {
role: "assistant",
content,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void {
const text = reasoning?.trim() ?? "";
if (text) handlers.onThinkingDone?.(text);
}
/**
* 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等,
* 直到调用终止 tool 并返回 MainAgentDecision。
*
* 循环本身交给 {@link AgentDriver};本函数只解释总管 tool 语义。
*/
export async function runMainAgentToolLoop(
llm: LlmProvider,
context: MainAgentContext,
handlers: ToolLoopHandlers,
driver: AgentDriver = createLocalLlmDriver(llm),
): Promise<ToolLoopResult> {
const messages: ChatMessage[] = [
{
role: "system",
content: buildToolLoopSystemPrompt(context.availableWorkers),
},
{ role: "user", content: buildMainAgentUserPrompt(context) },
];
const toolTrace: string[] = [];
const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
};
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
debugLog("llm", `总管循环 第${iteration}轮`);
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
emitThinkingDone(handlers, result.reasoning);
const run = await driver.run({
system: buildToolLoopSystemPrompt(context.availableWorkers),
messages: [{ role: "user", content: buildMainAgentUserPrompt(context) }],
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
label: "总管循环",
onThinkingDelta: (delta) => handlers.onThinkingDelta?.(delta),
onThinkingDone: (text) => handlers.onThinkingDone?.(text),
handleStep: (calls) => {
const terminalCalls = calls.filter((tc) => isMainAgentTerminalTool(tc.name));
const loopCalls = calls.filter((tc) => !isMainAgentTerminalTool(tc.name));
if (result.toolCalls.length === 0) {
if (result.content?.trim()) {
const decision = parseMainAgentDecision(result.content);
return { decision, iterations: iteration, toolTrace };
if (terminalCalls.length > 1) {
throw new Error(
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
);
}
throw new Error("Main Agent returned no tool calls and no content");
}
const terminalCalls = result.toolCalls.filter((tc) =>
isMainAgentTerminalTool(tc.name),
);
const loopCalls = result.toolCalls.filter(
(tc) => !isMainAgentTerminalTool(tc.name),
);
if (terminalCalls.length === 1) {
const terminal = terminalCalls[0];
handlers.onToolCall?.(terminal.name, terminal.arguments);
toolTrace.push(`${terminal.name} (terminal)`);
return { kind: "conclude" as const, call: terminal };
}
if (terminalCalls.length > 1) {
throw new Error(
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
);
}
messages.push(
assistantMessageFromToolCalls(result.content, result.toolCalls),
);
if (terminalCalls.length === 1) {
const terminal = terminalCalls[0];
handlers.onToolCall?.(terminal.name, terminal.arguments);
toolTrace.push(`${terminal.name} (terminal)`);
return {
decision: toolCallToDecision(terminal),
iterations: iteration,
toolTrace,
};
}
for (const call of loopCalls) {
const output = executeLoopTool(call, handlers);
handlers.onToolCall?.(call.name, output.slice(0, 200));
toolTrace.push(call.name);
messages.push({
role: "tool",
content: output,
tool_call_id: call.id,
const results = loopCalls.map((call) => {
const output = executeLoopTool(call, handlers);
handlers.onToolCall?.(call.name, output.slice(0, 200));
toolTrace.push(call.name);
return { callId: call.id, content: output };
});
}
return { kind: "continue" as const, results };
},
});
if (run.stop.kind === "text") {
return {
decision: parseMainAgentDecision(run.stop.content),
iterations: run.iterations,
toolTrace,
};
}
throw new Error(
`Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`,
);
return {
decision: toolCallToDecision(run.stop.call),
iterations: run.iterations,
toolTrace,
};
}
+7 -3
View File
@@ -1,12 +1,14 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { isStUnfilledMarker } from "./markers.js";
export type MarkerResolver = (identifier: string) => string | null;
const defaultMarkerResolver: MarkerResolver = () => null;
/**
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
* 按 prompt_order 装配 preset 消息。
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
*/
export function assemblePresetMessages(
preset: PresetPackage,
@@ -25,8 +27,10 @@ export function assemblePresetMessages(
if (!entry || !entry.enabled) continue;
let content = entry.content.trim();
if (!content && entry.marker) {
const resolved = resolveMarker(entry.sourceIdentifier);
const markerId = entry.sourceIdentifier || entry.id;
const skipFill = isStUnfilledMarker(markerId);
if (!content && entry.marker && !skipFill) {
const resolved = resolveMarker(markerId);
if (resolved?.trim()) content = resolved.trim();
}
+16 -16
View File
@@ -7,6 +7,7 @@ import type {
PresetPromptOrderItem,
UnsupportedPresetSection,
} from "../types/preset.js";
import { applyExtendedMarkers } from "./markers.js";
type StPrompt = {
identifier?: string;
@@ -186,17 +187,6 @@ export function importSillyTavernPreset(
});
});
const referenced = new Set(promptOrder.map((o) => o.promptId));
const unreferencedCount = prompts.filter(
(p) => !referenced.has(p.id),
).length;
const enabledCount = promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
const generation = readGeneration(raw);
const generationFields = Object.entries(generation)
.filter(([, v]) => v !== undefined)
@@ -211,7 +201,7 @@ export function importSillyTavernPreset(
? (rawInput as { name: string }).name
: "导入的预设");
const preset: PresetPackage = {
const preset: PresetPackage = applyExtendedMarkers({
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
name: presetName,
source: "sillytavern",
@@ -221,13 +211,23 @@ export function importSillyTavernPreset(
unsupported: collectUnsupported(raw),
importedAt: new Date().toISOString(),
raw: rawInput,
};
});
const framedReferenced = new Set(preset.promptOrder.map((o) => o.promptId));
const framedUnreferenced = preset.prompts.filter(
(p) => !framedReferenced.has(p.id),
).length;
const framedEnabled = preset.promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = preset.prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
return {
preset,
promptCount: prompts.length,
enabledCount,
unreferencedCount,
promptCount: preset.prompts.length,
enabledCount: framedEnabled,
unreferencedCount: framedUnreferenced,
missingIdentifiers,
generationFields,
warnings,
+132
View File
@@ -0,0 +1,132 @@
import type {
PresetPackage,
PresetPromptEntry,
PresetPromptOrderItem,
PresetPromptRole,
} from "../types/preset.js";
/** 酒馆角色卡 / WI marker:导入后保留位置,运行时不填我们的正文。 */
export const ST_UNFILLED_MARKERS = new Set([
"worldInfoBefore",
"personaDescription",
"charDescription",
"charPersonality",
"scenario",
"worldInfoAfter",
"dialogueExamples",
]);
export const CHAT_HISTORY_MARKER = "chatHistory";
export const WORLD_BOOK_BEFORE = "worldBookBefore";
export const WORLD_BOOK_AFTER = "worldBookAfter";
export const CURRENT_TURN = "currentTurn";
export const POST_TURN = "postTurn";
/** 导入时插入的自有洞(不含共用的 chatHistory)。 */
export const APP_INSERTED_MARKERS = [
WORLD_BOOK_BEFORE,
WORLD_BOOK_AFTER,
CURRENT_TURN,
POST_TURN,
] as const;
const APP_INSERTED_SET = new Set<string>(APP_INSERTED_MARKERS);
type MarkerDef = {
id: string;
name: string;
role: PresetPromptRole;
};
const MARKER_DEFS: MarkerDef[] = [
{ id: WORLD_BOOK_BEFORE, name: "世界书(历史前)", role: "system" },
{ id: CHAT_HISTORY_MARKER, name: "对话历史", role: "system" },
{ id: WORLD_BOOK_AFTER, name: "世界书(历史后)", role: "system" },
{ id: CURRENT_TURN, name: "本轮对话", role: "user" },
{ id: POST_TURN, name: "本轮之后", role: "system" },
];
function upsertMarkerEntry(
prompts: PresetPromptEntry[],
def: MarkerDef,
): PresetPromptEntry[] {
if (prompts.some((p) => p.id === def.id)) return prompts;
return [
...prompts,
{
id: def.id,
name: def.name,
enabled: true,
role: def.role,
content: "",
marker: true,
sourceIdentifier: def.id,
},
];
}
function reindex(
order: Omit<PresetPromptOrderItem, "orderIndex">[],
): PresetPromptOrderItem[] {
return order.map((item, orderIndex) => ({ ...item, orderIndex }));
}
/**
* 以 chatHistory 为锚点插入四个自有 marker。
* 可重复调用:先摘掉已插入的自有洞,再按规则放回。
*/
export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
let prompts = [...preset.prompts];
for (const def of MARKER_DEFS) {
prompts = upsertMarkerEntry(prompts, def);
}
const rawOrder = [...preset.promptOrder]
.sort((a, b) => a.orderIndex - b.orderIndex)
.filter((item) => !APP_INSERTED_SET.has(item.promptId));
const histIndex = rawOrder.findIndex(
(item) => item.promptId === CHAT_HISTORY_MARKER,
);
const inserted = (id: string): PresetPromptOrderItem => ({
promptId: id,
enabled: true,
orderIndex: 0,
});
let next: PresetPromptOrderItem[];
if (histIndex < 0) {
next = reindex([
...rawOrder,
inserted(WORLD_BOOK_BEFORE),
inserted(CHAT_HISTORY_MARKER),
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
inserted(POST_TURN),
]);
} else {
const before = rawOrder.slice(0, histIndex);
const history = rawOrder[histIndex]!;
const after = rawOrder.slice(histIndex + 1);
next = reindex([
...before,
inserted(WORLD_BOOK_BEFORE),
history,
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
...after,
inserted(POST_TURN),
]);
}
return {
...preset,
prompts,
promptOrder: next,
};
}
export function isStUnfilledMarker(identifier: string): boolean {
return ST_UNFILLED_MARKERS.has(identifier);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePresetMessages } from "./assembler.js";
import { applyExtendedMarkers } from "./markers.js";
import {
resolveWorldInfoMarker,
type WorldInfoPack,
} from "./world-info-pack.js";
/**
* Play worker:system 契约 + 按扩展 prompt_order 填洞。
* 不要再把同一份 context-order 正文叠成第二包 user。
*/
export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
preset: PresetPackage;
pack: WorldInfoPack;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const filled = assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(params.pack, id),
);
return [{ role: "system", content: params.systemPrompt }, ...filled];
}
+130
View File
@@ -0,0 +1,130 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePlayWorkerMessages } from "./play-frame.js";
import type { WorldInfoPack } from "./world-info-pack.js";
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
export const PROBE_SYSTEM_PROMPT =
"试跑本条预设:根据已拼好的上下文写一轮短回复。产物是对白或叙述,对应本轮用户输入。";
export const DEFAULT_PROBE_CONTEXT = {
loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。",
history: "店员:今晚只剩阁楼那间。\n你:好。",
loreAfter: "时间:深夜。地点:旅馆大厅。",
postTurn: "",
};
export type PresetProbeInput = {
message: string;
loreBefore?: string;
history?: string;
loreAfter?: string;
postTurn?: string;
};
export type PresetProbeMessage = {
role: string;
content: string;
};
export type PresetProbeResult = {
messages: PresetProbeMessage[];
reply: string | null;
reasoning: string | null;
completed: boolean;
skipReason?: string;
error?: string;
};
function optionalEntry(
id: string,
name: string,
content: string,
): WorldInfoPack["worldBookBefore"] {
const text = content.trim();
return text ? [{ id, name, content: text }] : [];
}
export function worldInfoPackFromProbeInput(
input: PresetProbeInput,
): WorldInfoPack {
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
const history = input.history ?? DEFAULT_PROBE_CONTEXT.history;
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
return {
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
history: history.trim(),
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
turn: input.message.trim(),
postTurn: optionalEntry("probe-post", "尾注", postTurn),
};
}
export function assemblePresetProbe(
preset: PresetPackage,
input: PresetProbeInput,
): ChatMessage[] {
return assemblePlayWorkerMessages({
systemPrompt: PROBE_SYSTEM_PROMPT,
preset,
pack: worldInfoPackFromProbeInput(input),
});
}
export function serializeProbeMessages(
messages: ChatMessage[],
): PresetProbeMessage[] {
return messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
}
export async function runPresetProbe(params: {
preset: PresetPackage;
input: PresetProbeInput;
complete?: (
messages: ChatMessage[],
) => Promise<{ content: string; reasoning?: string }>;
}): Promise<PresetProbeResult> {
const message = params.input.message.trim();
if (!message) {
throw new Error("缺少本轮输入");
}
const messages = assemblePresetProbe(params.preset, {
...params.input,
message,
});
const serialized = serializeProbeMessages(messages);
if (!params.complete) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
skipReason: "未请求生成",
};
}
try {
const result = await params.complete(messages);
return {
messages: serialized,
reply: result.content,
reasoning: result.reasoning ?? null,
completed: true,
};
} catch (err) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import {
isDialogueHistoryRef,
DIALOGUE_HISTORY_TAG,
} from "../skills/dialogue-history.js";
import {
renderContextSegmentBody,
type ContextSegmentDef,
} from "../skills/context-segments.js";
import {
CHAT_HISTORY_MARKER,
CURRENT_TURN,
POST_TURN,
WORLD_BOOK_AFTER,
WORLD_BOOK_BEFORE,
isStUnfilledMarker,
} from "./markers.js";
export const CURRENT_TURN_TAG = "用户.最新输入";
const CURRENT_TURN_ALIASES = new Set([
CURRENT_TURN_TAG,
"currentTurn",
"user.latest",
"用户最新输入",
]);
export function isCurrentTurnRef(ref: string): boolean {
return CURRENT_TURN_ALIASES.has(ref.trim());
}
export type WorldInfoEntry = {
id: string;
name: string;
content: string;
};
export type WorldInfoPack = {
worldBookBefore: WorldInfoEntry[];
history: string;
worldBookAfter: WorldInfoEntry[];
turn: string;
postTurn: WorldInfoEntry[];
};
function emptyPack(): WorldInfoPack {
return {
worldBookBefore: [],
history: "",
worldBookAfter: [],
turn: "",
postTurn: [],
};
}
function entryName(segment: ContextSegmentDef): string {
const raw = (segment.label ?? "").replace(/^#+\s*/, "").trim();
return raw || segment.id;
}
function toEntry(segment: ContextSegmentDef, content: string): WorldInfoEntry {
return {
id: segment.id,
name: entryName(segment),
content: content.trim(),
};
}
export function formatWorldInfoEntries(entries: WorldInfoEntry[]): string {
return entries
.filter((e) => e.content.trim())
.map((e) => {
const title = e.name.trim();
const body = e.content.trim();
return title ? `### ${title}\n\n${body}` : body;
})
.join("\n\n");
}
/**
* 按 contextSegments 相对「对话.历史」「用户.最新输入」切成四袋。
*/
export function worldInfoPackFromSegments(params: {
segments: ContextSegmentDef[];
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge?: BlackboardInputMerge;
}): WorldInfoPack {
const inputMerge = params.inputMerge ?? "latest";
const pack = emptyPack();
let phase: "before" | "after" | "post" = "before";
for (const segment of params.segments) {
const tags = segment.tags ?? [];
const isHist = tags.some((t) => isDialogueHistoryRef(t));
const isTurn = tags.some((t) => isCurrentTurnRef(t));
const body = renderContextSegmentBody(
segment,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (isHist) {
pack.history = body;
phase = "after";
continue;
}
if (isTurn) {
pack.turn = body;
phase = "post";
continue;
}
if (!body) continue;
const entry = toEntry(segment, body);
if (phase === "before") pack.worldBookBefore.push(entry);
else if (phase === "after") pack.worldBookAfter.push(entry);
else pack.postTurn.push(entry);
}
if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) {
pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim();
}
if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
}
return pack;
}
export function resolveWorldInfoMarker(
pack: WorldInfoPack,
identifier: string,
): string | null {
if (isStUnfilledMarker(identifier)) return null;
switch (identifier) {
case WORLD_BOOK_BEFORE: {
const text = formatWorldInfoEntries(pack.worldBookBefore);
return text || null;
}
case CHAT_HISTORY_MARKER:
return pack.history.trim() || null;
case WORLD_BOOK_AFTER: {
const text = formatWorldInfoEntries(pack.worldBookAfter);
return text || null;
}
case CURRENT_TURN:
return pack.turn.trim() || null;
case POST_TURN: {
const text = formatWorldInfoEntries(pack.postTurn);
return text || null;
}
default:
return null;
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 可替换的 agent 司机:问模型 → 有 tool-call 则交给调用方处理 → 再问,直到不再欠请求。
*
* 相位机、黑板、规格不走这里。换 dsh / 其它 harness 时只换 {@link AgentDriver} 实现。
*
* @module runtime/driver
*/
import type {
ChatMessage,
CompleteWithToolsResult,
LlmProvider,
StreamCallbacks,
ToolDefinition,
} from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js";
import { debugLog } from "../log.js";
export type DriverToolCall = {
id: string;
name: string;
arguments: string;
};
/** 一步里所有 tool-call 的处理结果:继续把结果写回,或本步结账停循环。 */
export type DriverStepOutcome =
| { kind: "continue"; results: Array<{ callId: string; content: string }> }
| { kind: "conclude"; call: DriverToolCall };
export type DriverRunInput = {
system: string;
messages: ChatMessage[];
tools: ToolDefinition[];
caller?: string;
/** 日志用短名,如「总管循环」 */
label?: string;
maxIterations?: number;
onThinkingDelta?: (delta: string) => void;
onThinkingDone?: (text: string) => void;
handleStep: (
calls: DriverToolCall[],
) => DriverStepOutcome | Promise<DriverStepOutcome>;
};
export type DriverStop =
| { kind: "text"; content: string }
| { kind: "conclude"; call: DriverToolCall };
export type DriverRunResult = {
iterations: number;
stop: DriverStop;
};
/** 司机:直到模型不再欠一次请求。 */
export type AgentDriver = {
run(input: DriverRunInput): Promise<DriverRunResult>;
};
export const DEFAULT_DRIVER_MAX_ITERATIONS = 12;
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: DriverToolCall[],
): ChatMessage {
return {
role: "assistant",
content,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
function emitThinkingDone(
input: DriverRunInput,
reasoning?: string,
): void {
const text = reasoning?.trim() ?? "";
if (text) input.onThinkingDone?.(text);
}
async function completeToolsPreferStream(
llm: LlmProvider,
messages: ChatMessage[],
tools: ToolDefinition[],
caller: string | undefined,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const options = { tools, caller };
if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, options, callbacks);
}
const result = await llm.completeWithTools(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
/** 现有 OpenAI 兼容 provider 上的司机,行为对齐旧 tool-loop。 */
export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
return {
async run(input: DriverRunInput): Promise<DriverRunResult> {
const maxIterations = input.maxIterations ?? DEFAULT_DRIVER_MAX_ITERATIONS;
const messages: ChatMessage[] = [
{ role: "system", content: input.system },
...input.messages,
];
const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => input.onThinkingDelta?.(delta),
};
const label = input.label ?? "driver";
for (let iteration = 1; iteration <= maxIterations; iteration++) {
debugLog("llm", `${label} 第${iteration}轮`);
const result = await completeToolsPreferStream(
llm,
messages,
input.tools,
input.caller,
streamCallbacks,
);
emitThinkingDone(input, result.reasoning);
if (result.toolCalls.length === 0) {
const content = result.content?.trim() ?? "";
if (content) {
return { iterations: iteration, stop: { kind: "text", content } };
}
throw new Error("Driver returned no tool calls and no content");
}
const calls: DriverToolCall[] = result.toolCalls.map((tc) => ({
id: tc.id,
name: tc.name,
arguments: tc.arguments,
}));
messages.push(assistantMessageFromToolCalls(result.content, calls));
const outcome = await input.handleStep(calls);
if (outcome.kind === "conclude") {
return {
iterations: iteration,
stop: { kind: "conclude", call: outcome.call },
};
}
for (const item of outcome.results) {
messages.push({
role: "tool",
content: item.content,
tool_call_id: item.callId,
});
}
}
throw new Error(`Driver exceeded ${maxIterations} iterations`);
},
};
}
+11 -19
View File
@@ -1,23 +1,21 @@
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 type { PresetPackage } from "../types/preset.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
export type LlmTrackingRef = { current: LlmTrackingContext };
export type { LlmTrackingRef };
function buildInnerLlm(): LlmProvider {
ensureActiveProfileDefault();
@@ -42,22 +40,11 @@ function buildInnerLlm(): LlmProvider {
]);
}
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);
return wrapLlmForSession(buildInnerLlm(), trackingRef);
}
export function hasRealLlmConfig(): boolean {
@@ -76,3 +63,8 @@ export function reloadDefaultMainAgentLlm(
): LlmProvider {
return createDefaultMainAgentLlm(trackingRef);
}
/** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */
export function createLlmForPreset(preset: PresetPackage): LlmProvider {
return wrapLlmForSession(buildInnerLlm(), undefined, () => preset);
}
+2
View File
@@ -158,6 +158,8 @@ export class RuntimeOrchestrator {
await this.runProgrammaticReview(effect.artifactId);
break;
case "invoke_main_agent":
case "propose_next_creation_step":
case "seal_creation_opening":
break;
}
}
+138 -34
View File
@@ -30,6 +30,12 @@ import {
isModuleOpeningQuestions,
normalizeQuestions,
} from "../skills/question-protocol.js";
import {
CREATION_SEALED_WAITING_MESSAGE,
SLOT_CREATION_SEALED_BY_OPENING,
isOpeningSealArtifact,
} from "../skills/opening-seal.js";
import { inferLifecycleStage } from "../skills/worker-declaration.js";
import type { QuestionItem } from "../types/questions.js";
function nowIso(): string {
@@ -81,7 +87,45 @@ function mergeSlotText(
const trimmed = text.trim();
if (!trimmed) return;
const prev = String(slots[key] ?? "").trim();
slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed;
if (!prev) {
slots[key] = trimmed;
return;
}
if (
prev === trimmed ||
prev.includes(`\n\n${trimmed}`) ||
prev.startsWith(`${trimmed}\n\n`)
) {
return;
}
slots[key] = `${prev}\n\n${trimmed}`;
}
/** 同一步内多次「按意见修改」叠意见;较新的写在后面 */
function accumulateRevisionInstruction(prev: unknown, next: string): string {
const trimmed = next.trim();
const old = typeof prev === "string" ? prev.trim() : "";
if (!old || old === trimmed) return trimmed;
if (old.includes(trimmed)) return old;
return `${old}\n\n——\n\n${trimmed}`;
}
/** 验收后把本步意见并入「用户.需求」按时间保留,再清掉本步槽位以免下一步误进修订 */
function archiveStepNotesIntoDemand(slots: Record<string, unknown>): void {
const reply =
typeof slots["用户.worker答复"] === "string"
? slots["用户.worker答复"].trim()
: "";
const note =
typeof slots["用户.修订说明"] === "string"
? slots["用户.修订说明"].trim()
: "";
if (reply) mergeSlotText(slots, "用户.需求", reply);
if (note) mergeSlotText(slots, "用户.需求", note);
delete slots["用户.修订说明"];
delete slots.revisionInstruction;
delete slots.revisionTargetArtifactId;
delete slots["用户.worker答复"];
}
/** 追加事件到 history 并 touch */
@@ -102,6 +146,30 @@ function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined });
}
/** 游玩连跑下一执行单元;创作则交总管 */
function afterPlayOrMainAgent(
session: RuntimeSession,
next: RuntimeSession,
): ApplyEventResult {
const cleared = {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
};
if (inferLifecycleStage(session) === "play") {
return {
session: touch(running(cleared)),
effects: [{ type: "continue_play_turn" }],
};
}
return {
session: touch(running(cleared)),
effects: [{ type: "invoke_main_agent" }],
};
}
function findArtifact(
session: RuntimeSession,
artifactId: string,
@@ -150,11 +218,14 @@ function beginRevisionRerun(
artifact: ArtifactRecord,
instruction: string,
): ApplyEventResult {
const trimmed = instruction.trim();
const trimmed = accumulateRevisionInstruction(
session.slots["用户.修订说明"] ?? session.slots.revisionInstruction,
instruction,
);
const slots: Record<string, unknown> = {
...session.slots,
"用户.修订说明": trimmed,
"用户.最新输入": trimmed,
"用户.最新输入": instruction.trim(),
revisionInstruction: trimmed,
// 本次重跑的底稿;重跑写不出新版时只许退回这一份
revisionTargetArtifactId: artifact.id,
@@ -319,7 +390,10 @@ function handleWorkerCompleted(
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const mode = session.acceptanceMode ?? "user_confirmed";
const mode =
inferLifecycleStage(session) === "play"
? "no_confirmation"
: (session.acceptanceMode ?? "user_confirmed");
let next = appendHistory(session, event);
if (next.slots.revisionTargetArtifactId) {
@@ -346,16 +420,13 @@ function handleWorkerCompleted(
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" }],
};
return afterPlayOrMainAgent(session, {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
});
}
// programmatic_review
@@ -587,9 +658,8 @@ export function applyEvent(
if (session.waitingReason?.kind === "worker_questions") {
slots["用户.worker答复"] = text;
if (text) slots["用户.最新输入"] = text;
if (demandKey && text) {
mergeSlotText(slots, demandKey, text);
}
// 开场/追问答复只进「用户.worker答复」,不追加进「用户.需求」
// (否则后续步骤的用户表述会把各步答复叠成一份长转录,修订意见被淹没)
} else if (session.waitingReason?.kind === "input" && text) {
const key = demandKey || "用户.需求";
applyUserTextToSlots(slots, text, key, {
@@ -615,6 +685,28 @@ export function applyEvent(
};
}
if (inferLifecycleStage(session) === "play") {
return {
session: touch(running({ ...next, resumeContext: undefined })),
effects: [{ type: "run_play_turn" }],
};
}
if (session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
return {
session: waiting(touch({ ...next, resumeContext: undefined }), {
kind: "input",
message: CREATION_SEALED_WAITING_MESSAGE,
}),
effects: [
{
type: "emit_message",
message: "创作已收口并保存。请切换到「游玩」开始。",
},
],
};
}
// 常规定稿用户输入 → 等总管下一步
return {
session: touch(running({ ...next, resumeContext: undefined })),
@@ -817,7 +909,10 @@ export function applyEvent(
case "worker_completed": {
const result = handleWorkerCompleted(session, event);
const mode = session.acceptanceMode ?? "user_confirmed";
const mode =
inferLifecycleStage(session) === "play"
? "no_confirmation"
: (session.acceptanceMode ?? "user_confirmed");
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
const sidecar = asOptionalSidecarQuestions(event.payload.questions);
const assessment =
@@ -908,6 +1003,7 @@ export function applyEvent(
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const slots: Record<string, unknown> = { ...session.slots };
archiveStepNotesIntoDemand(slots);
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集");
if (isFinalSet) {
@@ -924,24 +1020,29 @@ export function applyEvent(
pendingDecision: undefined,
currentWorkerId: undefined,
};
// 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑)
const gateNextIntent =
!isFinalSet &&
(artifact.workerId === "design-step" ||
artifact.workerId === "design-flow");
if (gateNextIntent) {
// 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。
if (isOpeningSealArtifact(artifact)) {
return {
session: waiting(cleared, {
kind: "next_intent",
afterWorkerId: artifact.workerId,
kind: "input",
message: CREATION_SEALED_WAITING_MESSAGE,
}),
effects: [
{
type: "emit_message",
message:
"这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。",
},
],
effects: [{ type: "seal_creation_opening" }],
};
}
if (inferLifecycleStage(session) === "play") {
return afterPlayOrMainAgent(session, cleared);
}
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
const proposeNext =
artifact.workerId === "design-step" ||
artifact.workerId === "design-flow";
if (proposeNext) {
return {
session: touch(running(cleared)),
effects: [{ type: "propose_next_creation_step" }],
};
}
return {
@@ -1029,7 +1130,10 @@ export function applyEvent(
session: touch(
running({ ...next, pendingArtifactId: undefined }),
),
effects: [{ type: "invoke_main_agent" }],
effects:
inferLifecycleStage(session) === "play"
? [{ type: "continue_play_turn" }]
: [{ type: "invoke_main_agent" }],
};
}
+239 -26
View File
@@ -62,6 +62,7 @@ import {
CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog,
isReviseStep,
mergeCreationFlowPreservingAccepted,
nextPendingStep,
parseCreationFlow,
@@ -71,10 +72,12 @@ import {
resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag,
missingRequiredStepParams,
stepUnitId,
stringifyCreationFlow,
stringifyModuleOpeningState,
type CreationFlowStepParams,
type ModuleCatalogEntry,
type ModuleParamSpec,
} from "../skills/creation-flow.js";
import {
@@ -86,6 +89,22 @@ import {
SLOT_ASKED_QUESTIONS,
} from "../skills/question-protocol.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import {
closeCreationFlowRaw,
CREATION_SEALED_WAITING_MESSAGE,
mergeOpeningTablePatch,
OPENING_CURRENT_VARS_TAG,
OPENING_INITIAL_VARS_TAG,
OPENING_OUTPUT_TAG,
OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload,
SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js";
import {
readPlayTurnQueue,
withPlayTurnQueue,
} from "../skills/play-turn.js";
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
import {
executeChance,
@@ -272,6 +291,26 @@ export class PhaseRuntime {
this.lastWorkerRunSnapshot = null;
}
/**
* 执行中卡住:有进行中的 worker 则按开跑快照重跑;否则重跑总管。
*/
async retryStuckRun(): Promise<RuntimeSession> {
const workerId = this.session.currentWorkerId;
const snap = this.lastWorkerRunSnapshot;
if (workerId && snap && snap.workerId === workerId) {
this.restoreFromCheckpoint(
snap.runtimeSession,
snap.blackboardItems,
);
return this.rerunWorker(workerId);
}
if (this.session.phase === "running" && !this.session.waitingReason) {
await this.runMainAgent();
return this.session;
}
throw new Error("当前没有可重试的执行");
}
/** 重 roll 指定 worker(刷新 Skill 回复) */
async rerunWorker(workerId: string): Promise<RuntimeSession> {
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
@@ -575,15 +614,38 @@ export class PhaseRuntime {
}
/** 用户接受 pendingArtifact;创作单位验收 vs 终稿验收分流 */
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
async acceptArtifact(
artifactId?: string,
opts?: { openingIndex?: number },
): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) throw new Error("当前没有待验收的产物");
const artifact = this.session.artifacts.find((a) => a.id === id);
if (artifact && opts?.openingIndex != null && Number.isFinite(opts.openingIndex)) {
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_OPENING_SELECTED_INDEX]: Math.trunc(opts.openingIndex),
},
};
}
const unitAccept = artifact ? isDesignUnitArtifact(artifact) : false;
// 细化终稿写「设计.worker集」,不是单位草稿,但仍须记入已验收步骤,
// 否则编排器会当成没做完再跑一遍。须在 dispatch 之前写入。
const shouldRecordStep = Boolean(
artifact && (unitAccept || artifact.workerId === "design-step"),
);
if (artifact && shouldRecordStep) {
const slots = { ...this.session.slots };
this.recordCreationUnitAccepted(slots, artifact);
this.session = { ...this.session, slots };
}
await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } });
this.persistInstanceWorkerDeclaration();
if (artifact) {
const unitAccept = isDesignUnitArtifact(artifact);
const result = compressAfterWorkerAccept({
blackboard: this.blackboard,
workerId: artifact.workerId,
@@ -598,8 +660,7 @@ export class PhaseRuntime {
lastCompressedWorkerId: artifact.workerId,
};
if (unitAccept) {
this.recordCreationUnitAccepted(slots, artifact);
if (shouldRecordStep) {
const unitId = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
this.onMessage(
`[创作单位已验收] ${unitId || artifact.workerId}:${
@@ -608,7 +669,8 @@ export class PhaseRuntime {
);
slots[SLOT_CREATION_CURRENT_UNIT] = undefined;
slots[SLOT_CREATION_UNIT_ANCHOR_AT] = undefined;
} else {
}
if (!unitAccept) {
this.onMessage(
`[上下文已压缩] ${artifact.workerId}:保留 ${result.finals.map((f) => f.tag).join("、") || "(无终产物)"};归档 ${result.archivedTags.length} 个过程 tag。下一阶段以定稿为准。`,
);
@@ -814,6 +876,15 @@ export class PhaseRuntime {
case "propose_next_creation_step":
await this.proposeNextCreationStep();
break;
case "seal_creation_opening":
this.sealCreationOpening();
break;
case "run_play_turn":
await this.startPlayTurn();
break;
case "continue_play_turn":
await this.continuePlayTurn();
break;
}
}
}
@@ -822,6 +893,10 @@ export class PhaseRuntime {
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
*/
private async proposeNextCreationStep(): Promise<void> {
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
return;
}
this.syncSlotsToBlackboard(this.session);
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
@@ -853,6 +928,9 @@ export class PhaseRuntime {
const active = this.getActiveSkill();
let paramSpecs: ModuleParamSpec[] = [];
let declaration = "";
let auto = false;
let kind: ModuleCatalogEntry["kind"];
let pendingModule: ModuleCatalogEntry | null = null;
if (active?.name) {
try {
const skill = await loadSkill(active.name);
@@ -861,6 +939,9 @@ export class PhaseRuntime {
const mod = catalog?.modules.find((m) => m.name === pending.name);
paramSpecs = mod?.params ?? [];
declaration = mod?.declaration?.trim() || "";
auto = mod?.auto === true;
kind = mod?.kind;
pendingModule = mod ?? null;
}
} catch {
/* catalog optional */
@@ -870,13 +951,7 @@ export class PhaseRuntime {
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
const params = pending.params ? { ...pending.params } : {};
const paramsMissing = paramSpecs
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
const intent =
typeof this.session.slots["用户.下一步意向"] === "string"
@@ -890,8 +965,9 @@ export class PhaseRuntime {
: paramsMissing.length
? `;待钉参数:${paramsMissing.join("、")}`
: "";
const revise = isReviseStep(pending);
const reason = [
`下一步:${pending.name}`,
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`,
declaration ? `—— ${declaration}` : "",
paramHint,
intent ? `(你的意向:${intent})` : "",
@@ -912,6 +988,9 @@ export class PhaseRuntime {
hint: p.hint,
})),
intent: intent || undefined,
...(kind ? { kind } : {}),
...(revise ? { mode: "revise" as const } : {}),
...(pending.revises ? { revises: pending.revises } : {}),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
@@ -930,7 +1009,8 @@ export class PhaseRuntime {
action: "run_worker",
workerId: "design-step",
reason,
requiresApproval: true,
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
requiresApproval: !(auto && paramsMissing.length === 0),
});
await this.dispatch({
type: "main_agent_decision_created",
@@ -938,6 +1018,81 @@ export class PhaseRuntime {
});
}
/**
* 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。
*/
private sealCreationOpening(): void {
const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX];
const selectedIndex =
typeof selectedRaw === "number"
? selectedRaw
: typeof selectedRaw === "string" && selectedRaw.trim()
? Number(selectedRaw)
: 0;
const fragmentRaw =
this.blackboard.getContentByTag(OPENING_SETUP_ARTIFACT_TAG) ?? "";
const payload = parseOpeningSealPayload(fragmentRaw, selectedIndex);
const existingOpening = this.blackboard.getContentByTag(OPENING_OUTPUT_TAG)?.trim();
const openingText = payload?.selectedText?.trim() || existingOpening || "";
if (openingText) {
this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup");
}
if (payload?.variables.length) {
const source = "worker:opening-setup";
const initialPatch = mergeOpeningTablePatch(
this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
payload.variables,
source,
);
if (initialPatch) {
this.writeWorkerTagContent(OPENING_INITIAL_VARS_TAG, initialPatch, "opening-setup");
}
const currentPatch = mergeOpeningTablePatch(
this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
payload.variables,
source,
);
if (currentPatch) {
this.writeWorkerTagContent(OPENING_CURRENT_VARS_TAG, currentPatch, "opening-setup");
}
}
const closed = closeCreationFlowRaw(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (closed) {
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content: closed,
source: "runtime",
});
}
const n = payload?.candidates.length ?? (openingText ? 1 : 0);
const which =
n > 1 && payload
? `第 ${payload.selectedIndex + 1}/${n} 条`
: n
? "开场白"
: "开场";
this.onMessage(
`[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`,
);
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_SEALED_BY_OPENING]: true,
[CREATION_FLOW_TAG]: closed ?? this.session.slots[CREATION_FLOW_TAG],
[OPENING_OUTPUT_TAG]: openingText || this.session.slots[OPENING_OUTPUT_TAG],
},
};
}
private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
@@ -947,6 +1102,7 @@ export class PhaseRuntime {
if (!raw?.trim()) return;
let snap: {
stepId?: string;
kind?: string;
paramSpecs?: Array<{ key: string; required?: boolean }>;
};
try {
@@ -967,13 +1123,16 @@ export class PhaseRuntime {
content,
source: "user",
});
const paramsMissing = (snap.paramSpecs ?? [])
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const paramsMissing =
snap.kind === "prior-artifact"
? []
: (snap.paramSpecs ?? [])
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const snapshot = {
...snap,
params,
@@ -1122,6 +1281,7 @@ export class PhaseRuntime {
this.onMessage(
`[Worker] ${workerId} 执行中…${resolved.source === "declaration" ? "(声明驱动)" : ""}`,
);
debugLog("step", `调用模型 ${workerId}`);
this.onWorkerStreamStart?.(workerId);
try {
@@ -1131,10 +1291,7 @@ export class PhaseRuntime {
slots,
blackboard: this.blackboard,
llm: workerLlm,
declared:
resolved.source === "declaration"
? { worker: resolved.worker, promptBody: resolved.promptBody }
: undefined,
declared: { worker: resolved.worker, promptBody: resolved.promptBody },
stream: {
onThinkingDelta: (delta) =>
this.onWorkerThinkingDelta?.(workerId, delta),
@@ -1193,8 +1350,13 @@ export class PhaseRuntime {
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
const hasPlayVisible = Object.keys(result.outputs).some(
(t) => t === "输出.用户展示" || t === "输出.开场白",
);
this.onMessage(
`[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`,
`[Worker] ${workerId} 已完成\n\n${result.preview}${
!hasPlayVisible && result.preview.length >= 4000 ? "\n\n…" : ""
}`,
);
await this.dispatch({
@@ -1907,8 +2069,59 @@ export class PhaseRuntime {
);
}
/** 游玩:按 playWorkerIds 开一轮 */
private async startPlayTurn(): Promise<void> {
this.syncSlotsToBlackboard(this.session);
const ids = this.getInstanceWorkerDeclaration().playWorkerIds;
if (!ids.length) {
this.onMessage(
"[游玩] 运行规格里没有可上场的执行单元,无法推进回合。",
);
this.waitForPlayInput();
return;
}
const [first, ...rest] = ids;
this.session = {
...this.session,
slots: withPlayTurnQueue(this.session.slots, rest),
};
await this.processEffects([{ type: "run_worker", workerId: first }]);
}
/** 游玩:跑管线下一个;队空则等用户 */
private async continuePlayTurn(): Promise<void> {
if (this.session.phase === "waiting_user") return;
const queue = readPlayTurnQueue(this.session.slots);
if (!queue.length) {
this.waitForPlayInput();
return;
}
const [next, ...rest] = queue;
this.session = {
...this.session,
slots: withPlayTurnQueue(this.session.slots, rest),
};
await this.processEffects([{ type: "run_worker", workerId: next }]);
}
private waitForPlayInput(): void {
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "input" },
currentWorkerId: undefined,
pendingArtifactId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
slots: withPlayTurnQueue(this.session.slots, undefined),
updatedAt: new Date().toISOString(),
};
}
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
if (inferLifecycleStage(this.session) === "play") return;
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
+9 -2
View File
@@ -399,7 +399,10 @@ export function buildFocus(
return {
actorType: "user",
actorLabel: "你",
action: "补充说明",
action:
reason.message && /创作已收口/.test(reason.message)
? "创作已收口"
: "补充说明",
detail: reason.message?.slice(0, 120),
};
}
@@ -412,6 +415,7 @@ export function buildFocus(
try {
return JSON.parse(String(session.slots["创作.待确认步骤"])) as {
name?: string;
mode?: string;
};
} catch {
return null;
@@ -423,7 +427,9 @@ export function buildFocus(
actorId: "orchestrator",
actorLabel: "编排",
action: proposed?.name
? `确认开始 · ${proposed.name}`
? proposed.mode === "revise"
? `回头修改 · ${proposed.name}`
: `确认开始 · ${proposed.name}`
: `建议调用 ${displayWorkerLabel(worker)}`,
detail: session.pendingDecision?.reason,
};
@@ -467,6 +473,7 @@ export function buildFocus(
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
outputTags: art?.outputTags,
});
const optionalQs = reason.questions?.length
? `;另有 ${reason.questions.length} 道可选追问`
+5 -2
View File
@@ -146,10 +146,13 @@ export type ReviewComposerCopy = {
*/
export function reviewComposerCopy(
workerId: string | undefined | null,
opts?: { hasQuestions?: boolean },
opts?: { hasQuestions?: boolean; outputTags?: readonly string[] },
): ReviewComposerCopy {
const hasQuestions = Boolean(opts?.hasQuestions);
const id = (workerId ?? "").trim();
const openingReview =
id === "opening-generator" ||
(opts?.outputTags ?? []).includes("设计.开场白与开场变量");
if (id === "design-flow") {
return {
@@ -169,7 +172,7 @@ export function reviewComposerCopy(
};
}
if (id === "opening-generator") {
if (id === "opening-generator" || openingReview) {
return {
chip: "开场",
kicker: "待选定",
+540 -78
View File
@@ -10,6 +10,22 @@ import {
import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js";
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
import {
CREATION_SEALED_WAITING_MESSAGE,
INSTANCE_OPENING_SNAPSHOT_LABEL,
OPENING_OUTPUT_TAG,
SLOT_CREATION_SEALED_BY_OPENING,
} from "../skills/opening-seal.js";
import {
PLAY_WORKING_SNAPSHOT_ID,
PLAY_WORKING_SNAPSHOT_LABEL,
SLOT_PLAY_LAYER_ACTIVE,
isPlayLayerActive,
} from "../skills/play-turn.js";
import {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
} from "../skills/dialogue-history.js";
import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js";
import { createSession } from "../runtime/phase-machine.js";
import {
@@ -18,6 +34,7 @@ import {
reloadDefaultMainAgentLlm,
type LlmTrackingRef,
} from "../runtime/llm-factory.js";
import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js";
import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
@@ -179,6 +196,8 @@ export type SessionView = {
/** @deprecated 用 skillCatalog */
pipeline: StageStep[];
lifecycleStage: LifecycleStage;
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
playLayerActive: boolean;
playReady: boolean;
skillCatalog: SkillCatalogEntry[];
toolTrace: ToolTraceEntry[];
@@ -271,7 +290,13 @@ export type ProposedNextStepView = {
required: boolean;
hint?: string;
}>;
/** 节点特性:prior-artifact = 先验产物(不在确认开干时钉「写什么」) */
kind?: string;
/** 用户在「下一步想写什么」里写的意向(与步骤 mode 不同) */
intent?: string;
/** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise";
revises?: string;
};
export type ReviewArtifactView = {
@@ -279,6 +304,7 @@ export type ReviewArtifactView = {
workerId: string;
summary?: string;
body: string;
outputTags?: string[];
/** 产出该产物的 worker_output 消息(验收态下 feed 会隐藏该消息) */
sourceMessageId?: string;
/** 与 sourceMessage 同源的全量 LLM 上下文,供待验收卡片右键查看 */
@@ -301,6 +327,12 @@ type ManagedSession = {
recipeOptions?: RecipeCatalogEntry[];
/** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */
runAbort?: AbortController;
/** 递增世代:后一次请求作废前一次 */
runGen?: number;
/** 本轮开始时的消息条数,取消时裁掉执行中产生的调度句 */
runMessageCutoff?: number;
};
export class SessionManager {
@@ -327,7 +359,11 @@ export class SessionManager {
if (stage === "play" && !canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
s.runtime.setLifecycleStage(stage);
if (stage === "play") {
this.enterPlayLayer(s, { resume: true });
} else {
this.enterDesignLayer(s);
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
@@ -582,10 +618,10 @@ export class SessionManager {
return toRunSnapshotMeta(snapshot);
}
/** 列出某 Book 的全部存档 */
/** 列出某 Book 的全部存档(不含内部「当前游玩」工作副本) */
listGameSnapshots(bookId: string): RunSnapshotMeta[] {
if (!getBook(bookId)) throw new Error("Book 不存在");
return listRunSnapshots(bookId);
return listRunSnapshots(bookId).filter((s) => s.id !== PLAY_WORKING_SNAPSHOT_ID);
}
/** 仅游玩 run 存档(UI 抽屉用) */
@@ -604,7 +640,7 @@ export class SessionManager {
if (!canEnterPlay(session)) {
throw new Error("须先验收 Worker 集,才能保存游玩存档");
}
if (inferLifecycleStage(session) !== "play") {
if (inferLifecycleStage(session) !== "play" || !isPlayLayerActive(session.slots)) {
throw new Error("请先切换到「游玩」再保存游玩存档");
}
return this.saveGameSnapshot(sessionId, label, "run", note);
@@ -618,16 +654,97 @@ export class SessionManager {
if (!canEnterPlay(session)) {
throw new Error("须先验收 Worker 集,才能开始游玩");
}
this.archivePlayWorking(s.bookId);
this.enterPlayLayer(s, { resume: false });
this.persist(s);
return this.toView(sessionId);
}
let runtimeSession = structuredClone(session);
/** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */
private enterPlayLayer(
s: ManagedSession,
opts: { resume: boolean },
): void {
const session = s.runtime.getSession();
if (!canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
if (
opts.resume &&
isPlayLayerActive(session.slots) &&
inferLifecycleStage(session) === "play"
) {
this.sanitizePlayHitl(s);
return;
}
if (s.bookId) this.ensureInstanceSnapshot(s);
if (opts.resume && s.bookId) {
const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID);
if (working?.kind === "run") {
this.restoreSnapshotInPlace(s, working, "play");
return;
}
}
this.beginNewPlayFromInstance(s);
if (s.bookId) this.savePlayWorking(s);
}
/** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */
private enterDesignLayer(s: ManagedSession): void {
const session = s.runtime.getSession();
if (
inferLifecycleStage(session) === "design" &&
!isPlayLayerActive(session.slots)
) {
s.runtime.setLifecycleStage("design");
return;
}
if (s.bookId && isPlayLayerActive(session.slots)) {
this.savePlayWorking(s);
}
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
if (instance) {
this.restoreSnapshotInPlace(s, instance, "design");
return;
}
let runtimeSession = structuredClone(s.runtime.getSession());
let blackboardItems = s.runtime.getBlackboard().exportItems();
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
runtimeSession.id = session.id;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
runtimeSession.phase = "waiting_user";
runtimeSession.waitingReason = { kind: "input" };
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
}
private beginNewPlayFromInstance(s: ManagedSession): void {
const keepId = s.runtime.getSession().id;
let runtimeSession: RuntimeSession;
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
if (instance) {
runtimeSession = structuredClone(instance.runtimeSession);
blackboardItems = structuredClone(instance.blackboardItems);
} else {
runtimeSession = structuredClone(s.runtime.getSession());
blackboardItems = s.runtime.getBlackboard().exportItems();
}
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "play",
[SLOT_PLAY_LAYER_ACTIVE]: true,
startupCompleted: true,
};
runtimeSession.phase = "waiting_user";
@@ -636,10 +753,166 @@ export class SessionManager {
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
runtimeSession.resumeContext = undefined;
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
this.persist(s);
return this.toView(sessionId);
const opening =
s.runtime.getBlackboard().getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
if (opening) {
s.runtime.getBlackboard().write({
tag: DIALOGUE_HISTORY_TAG,
content: appendDialogueHistoryTurn("", { role: "助手", text: opening }),
source: "runtime",
});
s.messages = [this.playOpeningMessage(opening)];
} else {
s.messages = [];
}
s.branchState = createMessageBranchState();
}
private restoreSnapshotInPlace(
s: ManagedSession,
snapshot: RunSnapshot,
layer: LifecycleStage,
): void {
const keepId = s.runtime.getSession().id;
let runtimeSession = structuredClone(snapshot.runtimeSession);
let blackboardItems = structuredClone(snapshot.blackboardItems);
if (snapshot.kind === "instance" || layer === "design") {
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: layer,
[SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined,
startupCompleted: true,
};
if (layer === "play") {
runtimeSession.phase = runtimeSession.phase === "done"
? "waiting_user"
: runtimeSession.phase;
if (!runtimeSession.waitingReason) {
runtimeSession.waitingReason = { kind: "input" };
}
} else {
const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]);
runtimeSession.phase = "waiting_user";
runtimeSession.waitingReason = {
kind: "input",
...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}),
};
runtimeSession.pendingArtifactId = undefined;
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
}
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
s.messages = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
s.branchState = createMessageBranchState();
if (layer === "play") this.sanitizePlayHitl(s);
}
/** 游玩层不保留创作 HITL(验收 / 确认下一步 / 修订) */
private sanitizePlayHitl(s: ManagedSession): void {
const session = s.runtime.getSession();
const play =
isPlayLayerActive(session.slots) || inferLifecycleStage(session) === "play";
if (!play) return;
const kind = session.waitingReason?.kind;
if (
kind !== "review_artifact" &&
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent"
) {
return;
}
session.phase = "waiting_user";
session.waitingReason = { kind: "input" };
session.pendingArtifactId = undefined;
session.pendingDecision = undefined;
session.currentWorkerId = undefined;
session.resumeContext = undefined;
session.updatedAt = new Date().toISOString();
}
private savePlayWorking(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
const runtimeSession = structuredClone(s.runtime.getSession());
const snapshot: RunSnapshot = {
version: 1,
id: PLAY_WORKING_SNAPSHOT_ID,
bookId: s.bookId,
label: PLAY_WORKING_SNAPSHOT_LABEL,
kind: "run",
orchestratorId:
sessionSkillPackId(runtimeSession) ??
(book ? bookSkillPackId(book) : "") ??
"",
runtimeSession,
blackboardItems: s.runtime.getBlackboard().exportItems(),
messages: s.messages.map((m) => ({ ...m })),
createdAt: new Date().toISOString(),
note: "切换创作/游玩时自动保存的当前聊天",
};
saveRunSnapshotFile(snapshot);
}
private archivePlayWorking(bookId: string): void {
const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID);
if (!working) return;
if (!working.messages.some((m) => m.role === "user")) return;
const stamped: RunSnapshot = {
...working,
id: randomUUID(),
label: `游玩 ${new Date().toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
createdAt: new Date().toISOString(),
note: "新建游玩前自动保存",
};
saveRunSnapshotFile(stamped);
}
private ensureInstanceSnapshot(s: ManagedSession): void {
if (!s.bookId) return;
if (this.latestInstanceSnapshot(s.bookId)) return;
if (!canEnterPlay(s.runtime.getSession())) return;
try {
this.saveGameSnapshot(
s.runtime.getSession().id,
INSTANCE_OPENING_SNAPSHOT_LABEL,
"instance",
"进入游玩前自动保存定稿",
);
} catch (err) {
console.warn("[会话] 进入游玩前保存定稿失败", err);
}
}
private latestInstanceSnapshot(bookId: string): RunSnapshot | null {
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
if (!meta) return null;
return loadRunSnapshotFile(bookId, meta.id);
}
private playOpeningMessage(text: string): ChatMessage {
return {
id: randomUUID(),
role: "system",
text,
createdAt: new Date().toISOString(),
kind: "system_info",
actor: "narrator",
title: "开场白",
body: text,
};
}
/** 从存档读档:替换当前作品进度,可继续创作 */
@@ -665,10 +938,16 @@ export class SessionManager {
runtimeSession,
blackboardItems,
}));
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
} else {
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "play",
[SLOT_PLAY_LAYER_ACTIVE]: true,
};
}
@@ -725,6 +1004,16 @@ export class SessionManager {
opts?: { answers?: QuestionAnswer[] },
): Promise<SessionView> {
const s = this.require(id);
this.sanitizePlayHitl(s);
const waitingBefore = s.runtime.getSession().waitingReason;
if (
inferLifecycleStage(s.runtime.getSession()) === "play" &&
!isPlayLayerActive(s.runtime.getSession().slots) &&
waitingBefore?.kind === "input"
) {
this.enterPlayLayer(s, { resume: true });
if (s.bookId) this.persist(s);
}
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
@@ -764,35 +1053,36 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
try {
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") {
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
if (sidecarAnswers.length && reason.questions?.length) {
const qaText = formatQuestionAnswersForAi(
normalizeQuestions(reason.questions),
sidecarAnswers,
trimmed || undefined,
);
await s.runtime.resolveSidecarQuestions(qaText);
const owned = await this.runExclusive(id, s, async () => {
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") {
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
if (sidecarAnswers.length && reason.questions?.length) {
const qaText = formatQuestionAnswersForAi(
normalizeQuestions(reason.questions),
sidecarAnswers,
trimmed || undefined,
);
await s.runtime.resolveSidecarQuestions(qaText);
}
if (trimmed || sidecarAnswers.length) {
await s.runtime.rejectArtifact(
trimmed || "按追问作答更新产物",
);
}
} else {
await s.runtime.submitInput(trimmed || text);
}
if (trimmed || sidecarAnswers.length) {
await s.runtime.rejectArtifact(
trimmed || "按追问作答更新产物",
);
}
} else {
await s.runtime.submitInput(trimmed || text);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 发送消息失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally {
this.clearAgentThinking(id);
this.syncCreationDialogue(s);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
@@ -838,22 +1128,23 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else {
await s.runtime.submitInput(aiText);
}
const owned = await this.runExclusive(id, s, async () => {
if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else {
await s.runtime.submitInput(aiText);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 回答追问失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally {
this.clearAgentThinking(id);
this.syncCreationDialogue(s);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
@@ -872,7 +1163,10 @@ export class SessionManager {
this.clearAgentThinking(id);
s.messages.push(this.msg("user", "跳过可选追问"));
try {
await s.runtime.resolveSidecarQuestions();
const owned = await this.runExclusive(id, s, () =>
s.runtime.resolveSidecarQuestions(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -918,7 +1212,10 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
await s.runtime.submitInput(trimmed);
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitInput(trimmed),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -974,7 +1271,10 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
await s.runtime.rerunWorker(workerId);
const owned = await this.runExclusive(id, s, () =>
s.runtime.rerunWorker(workerId),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -997,6 +1297,32 @@ export class SessionManager {
return this.toView(id);
}
/** 中止当前生成并重跑卡住的 worker / 总管 */
async abortAndRetry(id: string): Promise<SessionView> {
const s = this.require(id);
const session = s.runtime.getSession();
if (session.phase !== "running" || session.waitingReason) {
throw new Error("当前没有正在执行的任务");
}
const cutoff = s.runMessageCutoff ?? s.messages.length;
s.messages = s.messages.slice(0, cutoff);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, async () => {
await s.runtime.retryStuckRun();
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止并重试失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 删除消息及其后的对话 */
async deleteMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id);
@@ -1053,7 +1379,10 @@ export class SessionManager {
): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.approve(stepParams);
const owned = await this.runExclusive(id, s, () =>
s.runtime.approve(stepParams),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认开干失败", detail);
@@ -1065,7 +1394,10 @@ export class SessionManager {
async confirmIntake(id: string): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.confirmIntake();
const owned = await this.runExclusive(id, s, () =>
s.runtime.confirmIntake(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认需求失败", detail);
@@ -1075,7 +1407,10 @@ export class SessionManager {
return this.toView(id);
}
async accept(id: string): Promise<SessionView> {
async accept(
id: string,
opts?: { openingIndex?: number },
): Promise<SessionView> {
const s = this.require(id);
const pendingId = s.runtime.getSession().pendingArtifactId;
const waiting = s.runtime.getSession().waitingReason;
@@ -1085,7 +1420,21 @@ export class SessionManager {
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
: undefined;
const stageBefore = inferLifecycleStage(s.runtime.getSession());
await s.runtime.acceptArtifact();
const sealedBefore = Boolean(
s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING],
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.acceptArtifact(undefined, opts),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 接受产物失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
this.persist(s);
return this.toView(id);
}
// 接受产物 = 不再用追问完善;询问模块随 waitingReason 清除而收起
if (hadSidecarQuestions) {
s.messages.push(
@@ -1101,6 +1450,8 @@ export class SessionManager {
}
}
this.persist(s);
this.maybeSaveOpeningInstance(s, sealedBefore);
this.persist(s);
return this.toView(id);
}
@@ -1109,13 +1460,16 @@ export class SessionManager {
const s = this.require(id);
const waiting = s.runtime.getSession().waitingReason;
try {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 打回失败", detail);
@@ -1127,26 +1481,42 @@ export class SessionManager {
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
async finish(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
@@ -1308,6 +1678,42 @@ export class SessionManager {
}
}
/** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */
private maybeSaveOpeningInstance(
s: ManagedSession,
sealedBefore: boolean,
): void {
if (sealedBefore) return;
if (!s.bookId) return;
const session = s.runtime.getSession();
if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
if (!canEnterPlay(session)) {
s.messages.push(
this.msg(
"system",
"[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。",
),
);
return;
}
try {
const meta = this.saveGameSnapshot(
session.id,
INSTANCE_OPENING_SNAPSHOT_LABEL,
"instance",
"选定开场后自动保存",
);
s.messages.push(
this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`),
);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(
this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`),
);
}
}
private persist(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
@@ -1423,7 +1829,7 @@ export class SessionManager {
private buildLiveStreamView(sessionId: string): LiveStreamView | undefined {
const worker = this.workerLive.get(sessionId);
if (worker && (worker.thinking || worker.output)) {
if (worker) {
return {
actor: "worker",
actorId: worker.workerId,
@@ -1449,6 +1855,42 @@ export class SessionManager {
return s;
}
private beginRun(s: ManagedSession, sessionId: string): number {
s.runAbort?.abort();
s.runAbort = new AbortController();
s.runGen = (s.runGen ?? 0) + 1;
s.runMessageCutoff = s.messages.length;
this.clearAgentThinking(sessionId);
return s.runGen;
}
private isCurrentRun(s: ManagedSession, gen: number): boolean {
return s.runGen === gen;
}
/** true = 本轮仍有效;false = 已被停止并重试取代 */
private async runExclusive(
id: string,
s: ManagedSession,
fn: () => Promise<unknown>,
): Promise<boolean> {
const gen = this.beginRun(s, id);
try {
await runWithAbortSignal(s.runAbort!.signal, fn);
return this.isCurrentRun(s, gen);
} catch (err) {
if (isAbortError(err) && !this.isCurrentRun(s, gen)) {
return false;
}
throw err;
} finally {
if (this.isCurrentRun(s, gen)) {
this.clearAgentThinking(id);
s.runAbort = undefined;
}
}
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
@@ -1595,6 +2037,7 @@ export class SessionManager {
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
const s = this.require(id);
this.sanitizePlayHitl(s);
const session = s.runtime.getSession();
const reason = session.waitingReason;
const book = s.bookId ? getBook(s.bookId) : null;
@@ -1667,7 +2110,15 @@ export class SessionManager {
: "描述你想写什么…",
});
} else if (reason?.kind === "input") {
if (reason.questions?.length) {
const playNow =
inferLifecycleStage(session) === "play" || isPlayLayerActive(session.slots);
if (playNow) {
actions.push({
type: "send_message",
label: "发送",
placeholder: "说你要做什么…",
});
} else if (reason.questions?.length) {
const qs = normalizeQuestions(reason.questions);
const hasAssessment = Boolean(reason.message?.trim());
hints.push(
@@ -1732,11 +2183,17 @@ export class SessionManager {
);
if (proposed) {
hints.push(
`确认开始「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: ""
}`,
proposed.mode === "revise"
? `回头修改「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: "(继承既有产物继续改,不是从零再生成)"
}`
: `确认开始「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: ""
}`,
);
actions.push({
type: "approve",
@@ -1749,10 +2206,10 @@ export class SessionManager {
actions.push({
type: "send_message",
label: "说明意见",
placeholder: "不同意可说明要改成什么…",
placeholder: "改排、回头改某步,或说明不要这一步…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。");
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
actions.push({
type: "send_message",
label: "继续",
@@ -1859,6 +2316,7 @@ export class SessionManager {
actions,
pipeline: buildPipeline(session),
lifecycleStage,
playLayerActive: isPlayLayerActive(session.slots),
playReady: canEnterPlay(session),
skillCatalog,
toolTrace: buildToolTrace(messages),
@@ -2246,7 +2704,10 @@ function readProposedNextStep(
}))
.filter((p) => p.key)
: [],
kind: typeof doc.kind === "string" ? doc.kind : undefined,
intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined,
};
} catch {
return undefined;
@@ -2416,6 +2877,7 @@ function buildReviewArtifactView(
workerId: art.workerId,
summary,
body,
outputTags: art.outputTags,
sourceMessageId: outputMsg?.id,
contextTrace: outputMsg?.contextTrace,
workerSet,
+51
View File
@@ -22,12 +22,17 @@ import {
patchPresetEntries,
type PresetEntryPatch,
} from "../preset/entries.js";
import { runPresetProbe } from "../preset/probe.js";
import {
deletePreset,
getPreset,
importAndSavePreset,
listPresets,
} from "../preset/store.js";
import {
createLlmForPreset,
hasRealLlmConfig,
} from "../runtime/llm-factory.js";
import { sessionManager } from "./session-manager.js";
async function readBody(req: IncomingMessage): Promise<string> {
@@ -226,6 +231,52 @@ export async function handleSettingsApi(
return true;
}
const presetProbeMatch = pathname.match(/^\/api\/presets\/([^/]+)\/probe$/);
if (presetProbeMatch && req.method === "POST") {
const id = decodeURIComponent(presetProbeMatch[1]);
const preset = getPreset(id);
if (!preset) {
json(res, 404, { error: "预设不存在" });
return true;
}
const body = JSON.parse(await readBody(req)) as {
message?: string;
loreBefore?: string;
history?: string;
loreAfter?: string;
postTurn?: string;
complete?: boolean;
};
try {
const wantComplete = body.complete !== false;
const canComplete = wantComplete && hasRealLlmConfig();
const llm = canComplete ? createLlmForPreset(preset) : null;
const result = await runPresetProbe({
preset,
input: {
message: body.message ?? "",
loreBefore: body.loreBefore,
history: body.history,
loreAfter: body.loreAfter,
postTurn: body.postTurn,
},
complete: llm
? (messages) =>
llm.complete(messages, { caller: "preset-probe" })
: undefined,
});
if (!llm && wantComplete) {
result.skipReason = "未配置 API Key,只返回拼装后的上下文";
}
json(res, 200, result);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : String(err),
});
}
return true;
}
const presetEntriesMatch = pathname.match(
/^\/api\/presets\/([^/]+)\/entries$/,
);
+1 -1
View File
@@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog(
id: "declare-ready",
stage: "design",
label: "实例就绪",
purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。",
purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。",
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
});
+18 -2
View File
@@ -444,9 +444,11 @@ const server = createServer(async (req, res) => {
const body = JSON.parse(await readBody(req)) as {
action?: string;
stepParams?: Record<string, unknown>;
openingIndex?: number;
};
let view;
switch (body.action) {
try {
switch (body.action) {
case "approve":
view = await sessionManager.approve(
sessionId,
@@ -459,7 +461,12 @@ const server = createServer(async (req, res) => {
view = await sessionManager.confirmIntake(sessionId);
break;
case "accept":
view = await sessionManager.accept(sessionId);
view = await sessionManager.accept(
sessionId,
typeof body.openingIndex === "number"
? { openingIndex: body.openingIndex }
: undefined,
);
break;
case "skip_questions":
view = await sessionManager.skipQuestions(sessionId);
@@ -473,10 +480,19 @@ const server = createServer(async (req, res) => {
case "finish":
view = await sessionManager.finish(sessionId);
break;
case "retry_run":
view = await sessionManager.abortAndRetry(sessionId);
break;
default:
json(res, 400, { error: "未知 action" });
return;
}
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "操作失败",
});
return;
}
json(res, 200, view);
return;
}
+24 -5
View File
@@ -163,12 +163,22 @@ export function projectFragmentContent(
const frag = parseContextFragment(raw);
const mode = projection.trim().toLowerCase();
if (!frag) {
if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}…`;
if ((mode === "summary" || mode === "brief" || mode === "index") && raw.length > 800) {
return `${raw.slice(0, 800)}…`;
}
return raw;
}
if (mode === "summary" || mode === "brief") {
return frag.brief || raw;
}
if (mode === "index") {
const lines: string[] = [];
if (frag.技能) lines.push(`技能:${frag.技能}`);
if (frag.brief) lines.push(frag.brief);
if (frag.mount.length) lines.push(`挂载:${frag.mount.join("、")}`);
if (frag.稳变) lines.push(`稳变:${frag.稳变}`);
return lines.join("\n") || frag.brief || raw;
}
if (mode === "fields") {
if (frag.正文 && typeof frag.正文 === "object" && !Array.isArray(frag.正文)) {
const keys = Object.keys(frag.正文 as object);
@@ -237,9 +247,11 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView {
const probe = rawDoc?.追问;
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const row = probe as Record<string, unknown>;
const lines: string[] = [];
if (typeof row.导语 === "string" && row.导语.trim()) lines.push(row.导语.trim());
const qs = Array.isArray(row.题目) ? row.题目 : [];
const lines: string[] = [];
if (qs.length && typeof row.导语 === "string" && row.导语.trim()) {
lines.push(row.导语.trim());
}
qs.forEach((q, i) => {
if (!q || typeof q !== "object") return;
const r = q as Record<string, unknown>;
@@ -282,7 +294,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
const looksFragment =
row.schema === CONTEXT_FRAGMENT_SCHEMA ||
(typeof row.brief === "string" && ("正文" in row || "body" in row));
typeof row.技能 === "string" ||
(typeof row.brief === "string" &&
("正文" in row || "body" in row || "追问" in row));
if (!looksFragment) return { questions: [], assessment: "" };
const assessmentParts: string[] = [];
@@ -358,7 +372,12 @@ export function extractFragmentAskSidecar(raw: unknown): {
}
const questions = normalizeQuestions(rawQs);
const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n");
const assessment = [
questions.length ? lead : "",
...assessmentParts,
]
.filter(Boolean)
.join("\n");
return { questions, assessment };
}
+60 -6
View File
@@ -6,9 +6,14 @@
import type { ContextSegmentDef } from "./context-segments.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
import {
DEFAULT_ON_DEMAND_REFS,
DEFAULT_PLAY_SLOT_REFS,
defaultPlaySlots,
parsePlaySlots,
playSlotIdForRef,
PLAY_SLOT_META,
PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY,
resolvePlayAgentId,
type PlaySlotsConfig,
} from "./play-slots.js";
import {
@@ -87,20 +92,53 @@ function parseInsert(raw: unknown): ContextOrderInsert | undefined {
function parseSlot(raw: unknown): ContextOrderSlot | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const ref = asString(row.ref) ?? asString(row.id);
if (!ref) return undefined;
const rawRef = asString(row.ref) ?? asString(row.id);
if (!rawRef) return undefined;
const slotId = resolvePlayAgentId(rawRef);
const ref = slotId
? slotId === "chance"
? DEFAULT_ON_DEMAND_REFS.chance
: DEFAULT_PLAY_SLOT_REFS[slotId]
: rawRef;
const insertsRaw = Array.isArray(row.inserts) ? row.inserts : [];
const inserts = insertsRaw
.map(parseInsert)
.filter((x): x is ContextOrderInsert => Boolean(x))
.sort((a, b) => a.order - b.order);
const label =
asString(row.label) ??
asString(row.name) ??
(slotId && slotId !== "chance" ? PLAY_SLOT_META[slotId].label : undefined);
return {
ref,
label: asString(row.label) ?? asString(row.name),
label,
inserts,
};
}
function agentEnabledFlag(row: Record<string, unknown>): boolean {
if (typeof row.enabled === "boolean") return row.enabled;
if (row.enabled === "false" || row.enabled === 0) return false;
return true;
}
function playSlotsFromAgents(
rows: Record<string, unknown>[],
): PlaySlotsConfig {
const base = defaultPlaySlots();
for (const row of rows) {
const rawId = asString(row.id) ?? asString(row.ref) ?? asString(row.label);
if (!rawId) continue;
const slotId = resolvePlayAgentId(rawId);
if (!slotId) continue;
const on = agentEnabledFlag(row);
if (slotId === "chance") base.chance = on;
else if (slotId === "auditor") base.auditor = on;
else base[slotId] = on;
}
return base;
}
export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
@@ -118,16 +156,32 @@ export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
}
if (!row) return undefined;
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const slots = slotsRaw
const agentsRaw = Array.isArray(row.agents) ? row.agents : [];
const agentRows = agentsRaw.filter(
(a): a is Record<string, unknown> =>
Boolean(a) && typeof a === "object" && !Array.isArray(a),
);
const fromAgents = agentRows
.filter(agentEnabledFlag)
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const fromSlots = slotsRaw
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slots = fromAgents.length ? fromAgents : fromSlots;
if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined;
const play_slots = fromAgents.length
? playSlotsFromAgents(agentRows)
: parsePlaySlots(row.play_slots ?? row.playSlots);
return {
schema: CONTEXT_ORDER_SCHEMA,
brief: asString(row.brief),
play_slots: parsePlaySlots(row.play_slots ?? row.playSlots),
play_slots,
slots,
};
}
+159 -39
View File
@@ -89,17 +89,40 @@ function readTagContent(
return items[items.length - 1]?.content?.trim() ?? "";
}
/** 单段投影正文(不含 label)。供世界书 marker 填洞复用。 */
export function renderContextSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
return formatSegmentBody(
segment,
inputs,
blackboard,
inputMerge,
skipExact,
skipTags,
);
}
function formatSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
if (segment.inline?.trim()) {
return applyProjection(segment.inline.trim(), segment.projection);
}
const parts: string[] = [];
const seen = new Set<string>();
for (const tag of segment.tags) {
if (skipTags?.has(tag)) continue;
let content: string;
if (isDialogueHistoryRef(tag)) {
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
@@ -115,8 +138,22 @@ function formatSegmentBody(
content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection);
}
if (!content.trim()) continue;
parts.push(content.trim());
const trimmed = content.trim();
if (!trimmed) continue;
if (skipExact?.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
if (
parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
)
) {
continue;
}
seen.add(trimmed);
parts.push(trimmed);
}
return parts.join("\n\n");
}
@@ -141,6 +178,61 @@ function applyProjection(content: string, projection?: string): string {
return projectFragmentContent(content, projection);
}
const USER_REQUIREMENT_TAGS = new Set([
"用户.需求",
"book.brief",
"用户.最新输入",
"用户.worker答复",
"用户.修订说明",
]);
const CREATION_REVIEW_WORKERS = new Set([
"design-step",
"design-flow",
"opening-generator",
]);
function alreadyCovered(parts: string[], next: string): boolean {
const trimmed = next.trim();
if (!trimmed) return true;
return parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
);
}
function pushUnique(parts: string[], next: string): void {
const trimmed = next.trim();
if (!trimmed || alreadyCovered(parts, trimmed)) return;
parts.push(trimmed);
}
/** 创作步:用户要求按时间排;冲突只看较新,旧条未点名推翻则仍有效。 */
function collectUserRequirementBlock(params: {
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge: BlackboardInputMerge;
}): string {
const { inputs, blackboard, inputMerge } = params;
const ordered = [
readTagContent("用户.需求", inputs, blackboard, inputMerge),
readTagContent("book.brief", inputs, blackboard, inputMerge),
readTagContent("用户.worker答复", inputs, blackboard, inputMerge),
readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
readTagContent("用户.修订说明", inputs, blackboard, inputMerge),
];
const parts: string[] = [];
for (const item of ordered) pushUnique(parts, item);
if (!parts.length) return "";
return [
"## 【用户要求】按先后排列。冲突时以较新的为准;未被较新要求改写的旧条仍有效。",
"",
...parts,
].join("\n\n");
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
@@ -178,10 +270,49 @@ export function assembleWorkerContext(params: {
);
}
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const inheritExisting = segments.some((s) => s.id === "inherit-existing");
const splitUserReq = CREATION_REVIEW_WORKERS.has(params.workerId);
const draftBlocks: string[] = [];
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
// 只有本步真有底稿(或回头修改)才算修订;上一步残留的修订说明仍作为用户要求保留
const inheritMode = inheritExisting || (Boolean(revisionNote) && draftBlocks.length > 0);
const skipTags = new Set<string>();
if (splitUserReq) {
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
}
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge);
if (inheritMode && seg.id === "module-opening") return;
const body = formatSegmentBody(
seg,
params.inputs,
params.blackboard,
inputMerge,
undefined,
skipTags,
);
if (!body) return;
if (seg.label) {
blocks.push(`${seg.label}\n\n${body}`);
@@ -193,6 +324,15 @@ export function assembleWorkerContext(params: {
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
for (const seg of segments) render(seg);
if (splitUserReq) {
const reqBlock = collectUserRequirementBlock({
inputs: params.inputs,
blackboard: params.blackboard,
inputMerge,
});
if (reqBlock) blocks.push(reqBlock);
}
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
@@ -200,39 +340,17 @@ export function assembleWorkerContext(params: {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
}
// 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const draftBlocks: string[] = [];
if (revisionNote) {
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
if (draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。",
"节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。",
"",
...draftBlocks,
].join("\n"),
);
}
if (inheritMode && draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**(只保留这一份最新稿)。在其上落实【用户要求】里较新的条目;与旧要求冲突时以较新的为准,未点名改写的旧条仍须保留在产物里。",
"节点方法(task / principles / probe / output)仍须遵守。禁止无视较新要求原样交回。",
"",
...draftBlocks,
].join("\n"),
);
}
const taskLines = [
@@ -243,10 +361,12 @@ export function assembleWorkerContext(params: {
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
];
if (revisionNote) {
if (inheritMode) {
taskLines.push(
"**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。",
"不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。",
inheritExisting
? "**回头修改**:接着上方【既有产物 · 继承修改】(以及【待改底稿】若有)继续改,写回同一产物。"
: "**修订模式**:以【待改底稿】为底,落实【用户要求】;冲突时以较新的为准。",
"不要从零另起一份。标为「只读 / 已定稿」的分区仍不可改。",
);
} else {
taskLines.push(
+316 -63
View File
@@ -53,9 +53,41 @@ const DEFAULT_SKILLS_ROOT = path.resolve(
"../../skills",
);
/** 步骤调用参数(编排期钉死;执行期只读) */
/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕:编排器规划的产物内容(可空),执行时注入为【规划产物】。 */
export type CreationFlowStepParams = Record<string, unknown>;
/**
* 节点特性。缺省 = 普通执行步。后续会加更多 kind。
* prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。
*/
export const MODULE_NODE_KINDS = ["prior-artifact"] as const;
export type ModuleNodeKind = (typeof MODULE_NODE_KINDS)[number];
export const MODULE_NODE_KIND_FLAGS: Record<ModuleNodeKind, string> = {
"prior-artifact": "〔先验产物〕",
};
export function parseModuleNodeKind(raw: unknown): ModuleNodeKind | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim();
return (MODULE_NODE_KINDS as readonly string[]).includes(key)
? (key as ModuleNodeKind)
: undefined;
}
export function isPriorArtifactModule(
module: { kind?: ModuleNodeKind } | null | undefined,
): boolean {
return module?.kind === "prior-artifact";
}
/**
* 本步怎么跑:
* - fresh(缺省)= 从零产出。〔可反复〕再编入新 id 必须用这个——彻底新建,不改旧条。
* - revise = 回头修改:继承 `revises` 所指那步的既有产物继续改。
*/
export type CreationFlowStepMode = "fresh" | "revise";
export type CreationFlowStep = {
/**
* 本局步骤唯一 id(验收与 depends_on 用这个)。
@@ -67,10 +99,15 @@ export type CreationFlowStep = {
/** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */
depends_on: string[];
/**
* 本步调用参数。有「编排参数」声明的能力必须在进 design-step 前钉齐必填项。
* 例:生成规则 → target;具体实例 → rule_id。
* 本步调用参数。
* 普通节点:有必填声明时须在进执行前钉齐。
* 〔先验产物〕:编排器规划的产物内容(可空);执行注入【规划产物】,步内可修订。
*/
params?: CreationFlowStepParams;
/** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */
mode?: CreationFlowStepMode;
/** mode=revise 时:被改的既有步骤 id(须更前、同 name) */
revises?: string;
};
export type CreationFlowStatus = "open" | "closed";
@@ -111,14 +148,29 @@ export type ModuleCatalogEntry = {
* 程序不硬拦;给编排与校验提示。
*/
repeatable?: boolean;
/**
* 可选:创作终节点。选定后程序收口并保存,其后不要再追加步骤。
*/
closer?: boolean;
/**
* 可选:程序步。不抛默认问题、不经「同意并开始」,提案后直接执行;
* 产物仍走验收。适合投影排序这类只排序、几乎不问用户的收成步。
*/
auto?: boolean;
/**
* 可选:节点特性。缺省为普通执行步。
* prior-artifact = 先验产物,须先定写什么;params 是规划产物而非拦执行的必填项。
*/
kind?: ModuleNodeKind;
/**
* 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块;
* catalog 写了则作覆盖。程序发出,不经 LLM。
*/
opening?: string;
/**
* 可选:编排期步骤参数声明。
* 有 required 项时,steps[].params 必须在进执行前钉齐;勿把选型推迟到 design-step。
* 可选:步骤参数声明。
* 普通节点:required 须在进执行前钉齐。
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/
params?: ModuleParamSpec[];
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
@@ -191,15 +243,21 @@ export type CreationFlowUserView = {
id: string;
name: string;
depends_on: string[];
/** 同能力第几次(>1 时 UI 可标「再来」) */
/** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */
occurrence?: number;
/** fresh=从零新建;revise=回头修改 */
mode?: CreationFlowStepMode;
/** 回头修改时:被改的既有步骤 id */
revises?: string;
/** 目录里的短声明(有则展示) */
declaration?: string;
repeatable?: boolean;
/** 本步调用参数(编排期钉死) */
/** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */
params?: CreationFlowStepParams;
/** 参数缺必填项时的提示(给人看) */
/** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */
paramsMissing?: string[];
/** 节点特性(如先验产物) */
kind?: ModuleNodeKind;
/** 已执行 / 将要执行 / 未执行 */
runState: FlowStepRunState;
}>;
@@ -241,11 +299,12 @@ export function normalizeStepParams(
return Object.keys(out).length > 0 ? out : undefined;
}
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序) */
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序)。〔先验产物〕不拦执行,恒为 []。 */
export function missingRequiredStepParams(
step: Pick<CreationFlowStep, "params">,
module: ModuleCatalogEntry | null | undefined,
): string[] {
if (isPriorArtifactModule(module)) return [];
const specs = module?.params ?? [];
if (specs.length === 0) return [];
const params = step.params ?? {};
@@ -278,14 +337,120 @@ export function formatStepParamsForPrompt(
.join("\n");
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#2、name#3… */
const PRIOR_ARTIFACT_CONTEXT_TITLE =
"## 【规划产物】编排器预先为本步规划的产物内容(先验;缺则步内钉;步内可修订)";
/** 〔先验产物〕注入块:规划可空,身份写在片段自身。 */
export function formatPriorArtifactContext(
params: CreationFlowStepParams | null | undefined,
): string {
const body = formatStepParamsForPrompt(params);
const note =
!params || Object.keys(params).length === 0
? "编排器尚未规划本步具体写什么。先在本步与用户钉「写什么」,再填产物。"
: "以上为先验规划,不是锁死合同。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。";
return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}`;
}
const REVISE_MODE_ALIASES = new Set([
"revise",
"amend",
"edit",
"修改",
"回头修改",
"继承修改",
]);
const FRESH_MODE_ALIASES = new Set([
"fresh",
"new",
"create",
"新建",
"再来",
"再来一次",
]);
/** 解析 DAG 步骤的 mode;无法识别则视为缺省(fresh) */
export function parseCreationFlowStepMode(raw: unknown): CreationFlowStepMode | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (REVISE_MODE_ALIASES.has(key) || REVISE_MODE_ALIASES.has(raw.trim())) {
return "revise";
}
if (FRESH_MODE_ALIASES.has(key) || FRESH_MODE_ALIASES.has(raw.trim())) {
return "fresh";
}
return undefined;
}
export function isReviseStep(
step: Pick<CreationFlowStep, "mode"> | null | undefined,
): boolean {
return step?.mode === "revise";
}
function parseStepRevisesRef(raw: Record<string, unknown>): string | undefined {
for (const key of ["revises", "revise_of", "revises_id"] as const) {
const v = raw[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
type DraftCreationFlowStep = {
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
mode?: CreationFlowStepMode;
revises?: string;
};
function withStepModeFields(
step: DraftCreationFlowStep,
): Pick<CreationFlowStep, "mode" | "revises"> {
return {
...(step.mode === "revise" ? { mode: "revise" as const } : {}),
...(step.revises ? { revises: step.revises } : {}),
};
}
/**
* 回头修改步:补 revises(最近一条同名更前步),并继承其 params(本步已写的优先)。
*/
export function normalizeReviseSteps(
steps: CreationFlowStep[],
): CreationFlowStep[] {
return steps.map((step, i) => {
if (step.mode !== "revise") return step;
let revises = step.revises?.trim() || "";
if (!revises) {
for (let j = i - 1; j >= 0; j--) {
if (steps[j]?.name === step.name) {
revises = steps[j]!.id;
break;
}
}
}
const origin = revises
? steps.find((s) => s.id === revises || s.name === revises)
: undefined;
const inherited = origin?.params;
const params =
inherited && Object.keys(inherited).length
? { ...inherited, ...(step.params ?? {}) }
: step.params;
return {
...step,
...(revises ? { revises } : {}),
...(params && Object.keys(params).length ? { params } : {}),
};
});
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#n;回头修改 → name·改 */
export function ensureCreationFlowStepIds(
steps: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}>,
steps: Array<DraftCreationFlowStep>,
): CreationFlowStep[] {
const used = new Set<string>();
const nameCount = new Map<string, number>();
@@ -295,10 +460,20 @@ export function ensureCreationFlowStepIds(
const name = raw.name.trim();
const n = (nameCount.get(name) ?? 0) + 1;
nameCount.set(name, n);
const revise = raw.mode === "revise";
let id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
id = n === 1 ? name : `${name}#${n}`;
if (revise) {
id = n === 1 ? `${name}·改` : `${name}·改`;
if (used.has(id)) {
let i = 2;
while (used.has(`${name}·改#${i}`)) i++;
id = `${name}·改#${i}`;
}
} else {
id = n === 1 ? name : `${name}#${n}`;
}
}
if (used.has(id)) {
let i = 2;
@@ -311,9 +486,10 @@ export function ensureCreationFlowStepIds(
name,
depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean),
...(raw.params ? { params: raw.params } : {}),
...withStepModeFields(raw),
});
}
return out;
return normalizeReviseSteps(out);
}
/** 按 id 或(唯一)name 解析步骤引用 */
@@ -378,12 +554,7 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
const stepsRaw = row.steps;
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null;
const drafted: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}> = [];
const drafted: DraftCreationFlowStep[] = [];
for (const item of stepsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const s = item as Record<string, unknown>;
@@ -395,7 +566,17 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: [];
const params = normalizeStepParams(s.params);
drafted.push({ id, name, depends_on, ...(params ? { params } : {}) });
const mode =
parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent);
const revises = parseStepRevisesRef(s);
drafted.push({
id,
name,
depends_on,
...(params ? { params } : {}),
...(mode ? { mode } : {}),
...(revises ? { revises } : {}),
});
}
const steps = ensureCreationFlowStepIds(drafted);
@@ -440,6 +621,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
? m.opening.trim()
: undefined;
const repeatable = m.repeatable === true;
const closer = m.closer === true;
const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params);
modules.push({
id,
@@ -447,6 +631,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
declaration,
artifact,
...(repeatable ? { repeatable: true } : {}),
...(closer ? { closer: true } : {}),
...(auto ? { auto: true } : {}),
...(kind ? { kind } : {}),
...(opening ? { opening } : {}),
...(params ? { params } : {}),
});
@@ -702,7 +889,9 @@ export function mergeCreationFlowPreservingAccepted(params: {
const changed =
current.name !== frozen.name ||
JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) ||
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null);
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) ||
(current.mode ?? "fresh") !== (frozen.mode ?? "fresh") ||
(current.revises ?? "") !== (frozen.revises ?? "");
if (changed) {
steps[at] = frozen;
restored.push(frozen.id);
@@ -795,29 +984,49 @@ export async function loadModuleCatalog(
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const flags = m.repeatable ? "〔可反复〕" : "";
const flags = [
m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "",
m.auto ? "〔程序步〕" : "",
m.kind ? MODULE_NODE_KIND_FLAGS[m.kind] : "",
]
.filter(Boolean)
.join("");
const parts: string[] = [`- ${m.name}${flags}:${m.declaration}`];
if (m.when) parts.push(` 何时用:${indentMultiline(m.when, " ")}`);
if (m.when_not) parts.push(` 何时不用:${indentMultiline(m.when_not, " ")}`);
if (m.boundary) parts.push(` 边界:${indentMultiline(m.boundary, " ")}`);
if (m.params && m.params.length > 0) {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
if (isPriorArtifactModule(m)) {
parts.push(
` 规划产物字段(可提前写入 params;空则步内钉):${m.params
.map((p) => {
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label}${hint})`;
})
.join(";")}`,
);
} else {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
}
}
return parts.join("\n");
});
return [
"【能力 · 可选工序】",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
"生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:选定后结束创作并保存,排在细化终稿之后,其后不要再追加步骤;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。",
"标〔先验产物〕的步骤(如生成规则、具体实例):须先定「写什么」。编排器可以提前规划多条并写入 params——这是正确的;执行时程序注入【规划产物】。规划可空,「生成什么」在步内钉。禁止为钉对象而 askUser 拦在确认开干前。规划或修订对象时同步改 rule_id(英文 kebab-case)与步骤 id(如 生成规则·{对象}),禁止只改中文沿用旧后台 id。",
"其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。",
"两种「再来一次」必须分开:〔可反复〕再编入新 id(mode 缺省/fresh)= 彻底新建一条;回头修改已完成节点 = mode=revise + revises=原步 id,继承旧产物继续改。禁止把回头修改写成又一条 fresh。",
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
lines.join("\n"),
].join("\n");
@@ -1153,8 +1362,8 @@ export function formatFlowProgressForAgent(params: {
repeatable: boolean,
) => {
const flag = repeatable
? "〔可反复:可再追加不同 id〕"
: "〔不可反复:禁止再排一次执行〕";
? "〔可反复:mode=fresh 再追加 = 彻底新建;要改旧条用 mode=revise〕"
: "〔不可反复:禁止再排新建;要改已完成节点用 mode=revise 回头修改〕";
const idBit = id ? `(id: ${id})` : "";
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
doneNames.add(name);
@@ -1196,9 +1405,12 @@ export function formatFlowProgressForAgent(params: {
const draftLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (doneIds.has(step.id) || doneNames.has(step.name)) continue;
if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue;
const reviseBit = isReviseStep(step)
? ` · 回头修改${step.revises ? `(revises: ${step.revises})` : ""}`
: "";
draftLines.push(
`- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`,
`- ${step.name}(id: ${step.id})${reviseBit} · 已在草案,保留原 id;不要当作新规划再写一遍`,
);
}
}
@@ -1210,9 +1422,9 @@ export function formatFlowProgressForAgent(params: {
const lines = [
"【流程进度】(程序钉死,必须遵守)",
"你的任务是追加缺口,不是从头规划。",
"已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。",
"草案里已有的步骤:原样保留,不要重排一遍开局。",
"只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。",
"已完成的非反复技能:禁止再排一次「新建」(mode=fresh)。用户要改已完成节点 → 追加新 id,mode=revise,revises=原步 id;程序会继承既有产物。",
"〔可反复〕再编入新 id 且 mode 缺省/fresh = 彻底新建一条(新对象/新批次),不是改旧的。要改已有那一条 → 同样用 mode=revise。",
"草案里已有的步骤:原样保留,不要重排一遍开局。回头修改步插在最近已验收步之后、尚未执行的步之前。",
];
if (doneLines.length) {
lines.push("", "已完成:", ...doneLines);
@@ -1258,10 +1470,7 @@ export function validateCreationFlow(
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
: null;
const nameCount = new Map<string, number>();
for (const step of flow.steps) {
nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1);
}
const reportedFreshDup = new Set<string>();
for (let i = 0; i < flow.steps.length; i++) {
const step = flow.steps[i]!;
@@ -1275,14 +1484,44 @@ export function validateCreationFlow(
}
const mod = catalog?.modules.find((m) => m.name === step.name);
if (
if (isReviseStep(step)) {
const ref = step.revises?.trim() ?? "";
if (!ref) {
errors.push(
`「${step.id}」是回头修改,但未标明 revises(应指向更前的同名步骤 id)`,
);
} else {
const origin = findStepByRef(flow, ref);
if (!origin) {
errors.push(
`「${step.id}」回头修改「${ref}」,但流程中没有该步骤`,
);
} else if (origin.id === step.id) {
errors.push(`「${step.id}」不能回头修改自己`);
} else if (origin.name !== step.name) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但能力名不同(${step.name} ≠ ${origin.name})`,
);
} else {
const originIndex = flow.steps.findIndex((s) => s.id === origin.id);
if (originIndex >= i) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但「${origin.id}」未排在其前面`,
);
}
}
}
} else if (
catalog &&
(nameCount.get(step.name) ?? 0) > 1 &&
mod &&
mod.repeatable !== true
mod.repeatable !== true &&
!reportedFreshDup.has(step.name) &&
flow.steps.filter((s) => s.name === step.name && !isReviseStep(s)).length >
1
) {
reportedFreshDup.add(step.name);
errors.push(
`「${step.name}」出现多次,但目录未标 repeatable(仅可反复能力可同名多次)`,
`「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能要改旧稿请用 mode=revise)`,
);
}
@@ -1306,7 +1545,7 @@ export function validateCreationFlow(
return { ok: errors.length === 0, errors };
}
/** 进 design-step 前:本步必填编排参数是否已齐 */
/** 进 design-step 前:本步必填编排参数是否已齐。〔先验产物〕不拦。 */
export function validateStepReadyToRun(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
@@ -1362,14 +1601,19 @@ export function formatCreationFlowForUser(
const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
);
const seenName = new Map<string, number>();
const seenFreshName = new Map<string, number>();
const next = nextPendingStep(flow, acceptedStepIds);
return {
brief: flow.brief,
status: flow.status,
steps: flow.steps.map((s, i) => {
const n = (seenName.get(s.name) ?? 0) + 1;
seenName.set(s.name, n);
const revise = isReviseStep(s);
let occurrence: number | undefined;
if (!revise) {
const n = (seenFreshName.get(s.name) ?? 0) + 1;
seenFreshName.set(s.name, n);
occurrence = n;
}
const mod = decl.get(s.name);
const paramsMissing = missingRequiredStepParams(s, mod);
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
@@ -1382,9 +1626,12 @@ export function formatCreationFlowForUser(
id: s.id,
name: s.name,
depends_on: s.depends_on,
occurrence: n,
occurrence,
...(revise ? { mode: "revise" as const } : {}),
...(s.revises ? { revises: s.revises } : {}),
declaration: mod?.declaration,
repeatable: mod?.repeatable,
...(mod?.kind ? { kind: mod.kind } : {}),
runState,
...(s.params ? { params: s.params } : {}),
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
@@ -1443,6 +1690,7 @@ export function nextPendingStep(
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
if (!flow?.steps.length) return null;
const ready: CreationFlowStep[] = [];
for (const step of flow.steps) {
if (isStepAccepted(step, acceptedStepIds)) continue;
const depsOk = step.depends_on.every((dep) => {
@@ -1450,9 +1698,9 @@ export function nextPendingStep(
if (!depStep) return false;
return isStepAccepted(depStep, acceptedStepIds);
});
if (depsOk) return step;
if (depsOk) ready.push(step);
}
return null;
return ready.find((s) => isReviseStep(s)) ?? ready[0] ?? null;
}
/** 当前已列出的步骤是否都已验收(不管 status) */
@@ -1532,8 +1780,10 @@ export type DesignStepBinding = {
module: ModuleCatalogEntry;
depTags: string[];
modulePrompt: string;
/** 程序开场白;无则本步直接调 LLM */
/** 程序开场白;无则本步直接调 LLM。回头修改不抛 opening。 */
opening: string | null;
/** 回头修改时注入的既有产物 tag */
inheritTag: string | null;
};
/**
@@ -1570,7 +1820,9 @@ export async function resolveDesignStepBinding(params: {
`# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`;
const opening =
module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
isReviseStep(step) || module.auto
? null
: module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
return {
step,
@@ -1578,5 +1830,6 @@ export async function resolveDesignStepBinding(params: {
depTags: dependencyArtifactTags(step, catalog, flow),
modulePrompt: formatModulePromptForLlm(modulePromptRaw),
opening,
inheritTag: isReviseStep(step) ? module.artifact : null,
};
}
+13 -4
View File
@@ -31,6 +31,7 @@ import {
slotOrderForRef,
} from "./context-order.js";
import type { ContextSegmentDef } from "./context-segments.js";
import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js";
type WorkerTemplateDoc = {
id?: string;
@@ -44,8 +45,9 @@ type WorkerTemplateDoc = {
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:design 生命周期默认确认;play 缺省按 review(稳妥)
* - 未声明 acceptance:创作默认确认
*/
export function resolveAcceptanceModeForWorker(params: {
session: RuntimeSession;
@@ -56,6 +58,9 @@ export function resolveAcceptanceModeForWorker(params: {
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
if (inferLifecycleStage(params.session) === "play") {
return "no_confirmation";
}
const decl = buildInstanceWorkerDeclaration(
params.session,
@@ -66,7 +71,6 @@ export function resolveAcceptanceModeForWorker(params: {
if (entry?.acceptance === "continue") return "no_confirmation";
if (entry?.acceptance === "review") return "user_confirmed";
// 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑)
return "user_confirmed";
}
@@ -210,6 +214,11 @@ export function buildDeclaredWorkerSkill(params: {
params.template?.duty?.trim() ||
`执行 ${id}`;
const excerpt = params.template?.prompt_excerpt?.trim() || "";
const visibleBodyRule =
id === "narrator" || id === "round-present"
? PLAY_VISIBLE_BODY_INSTRUCTION
: "";
const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n");
const presentation = params.entry.presentation
? JSON.stringify(params.entry.presentation, null, 2)
: "";
@@ -217,7 +226,7 @@ export function buildDeclaredWorkerSkill(params: {
const premises = (params.workerSet?.core_premises ?? []).filter(Boolean);
const residentSection = formatResidentPromptSection(resident, id);
const personaText = [duty, excerpt].filter(Boolean).join("\n\n");
const personaText = [duty, excerptWithRule].filter(Boolean).join("\n\n");
const orderDoc = parseContextOrder(params.workerSet?.context_order);
const orderSlot = slotOrderForRef(orderDoc, id);
let contextSegments: ContextSegmentDef[] | undefined;
@@ -277,7 +286,7 @@ export function buildDeclaredWorkerSkill(params: {
"",
duty,
"",
excerpt ? `## 写法要点\n\n${excerpt}` : "",
excerptWithRule ? `## 写法要点\n\n${excerptWithRule}` : "",
presentation ? `## presentation(实例)\n\n\`\`\`json\n${presentation}\n\`\`\`` : "",
narrative ? `## 叙事指南\n\n${narrative}` : "",
premises.length
+40 -6
View File
@@ -516,7 +516,9 @@ export async function loadWorkerSkillWithContext(
resolveDesignStepBinding,
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
formatPriorArtifactContext,
formatStepParamsForPrompt,
isPriorArtifactModule,
} = await import("./creation-flow.js");
const binding = await resolveDesignStepBinding({
skillPackRoot: skill.skillPackRoot,
@@ -526,11 +528,27 @@ export async function loadWorkerSkillWithContext(
skillsRoot,
});
if (binding) {
const inheritTag = binding.inheritTag?.trim() || "";
const inheritNote = inheritTag
? `\n\n【回头修改】本步继承既有产物继续改,不是从零再生成。程序已注入【既有产物 · 继承修改】。保留未点名要改的部分,写回同一产物。禁止推倒重写,禁止再走一遍开场询问。`
: "";
const openingNote = binding.opening
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: "";
const paramsBlock = `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
modulePromptBlock = `${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}`;
const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = "";
const depProjection =
binding.module.id === "context-order" ? "index" : undefined;
if (binding.module.id === "context-order") {
const { formatPlayAgentRosterForPrompt } = await import("./play-slots.js");
rosterBlock = `${formatPlayAgentRosterForPrompt()}\n\n`;
}
modulePromptBlock = `${rosterBlock}${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}${inheritNote}`;
const depTags = inheritTag
? binding.depTags.filter((tag) => tag !== inheritTag)
: binding.depTags;
const baseInputs = [
"用户.需求",
"book.brief",
@@ -540,16 +558,29 @@ export async function loadWorkerSkillWithContext(
"设计.创作流程",
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
...binding.depTags,
...depTags,
...(inheritTag ? [inheritTag] : []),
];
const inputTags = [...new Set(baseInputs)];
const outputTags = [binding.module.artifact];
const depSegments = binding.depTags.map((tag, i) => ({
const depSegments = depTags.map((tag, i) => ({
id: `dep-${i}`,
tier: "static" as const,
tags: [tag],
label: `## 【依赖产物 · ${tag}】只读`,
label: `## 【依赖产物 · ${tag}】只读${depProjection === "index" ? "(仅 brief / 挂载 / 稳变)" : ""}`,
...(depProjection ? { projection: depProjection } : {}),
}));
const inheritSegment = inheritTag
? [
{
id: "inherit-existing",
tier: "static" as const,
tags: [inheritTag],
label:
"## 【既有产物 · 继承修改】在下列正文上继续改,写回同一产物;禁止从零另起一份",
},
]
: [];
const openingSegment = binding.opening
? [
{
@@ -562,13 +593,16 @@ export async function loadWorkerSkillWithContext(
: [];
patchedWorker = {
...worker,
name: `创作 · ${binding.module.name}`,
name: inheritTag
? `创作 · 回头修改 · ${binding.module.name}`
: `创作 · ${binding.module.name}`,
description: binding.module.declaration,
inputTags,
outputTags,
contextSegments: [
...(worker.contextSegments ?? []),
...openingSegment,
...inheritSegment,
...depSegments,
],
};
+200
View File
@@ -0,0 +1,200 @@
/**
* 开场白终节点:选定后落库、关 DAG。
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
*/
import {
parseTableDoc,
stringifyTableDoc,
type TableDoc,
} from "../blackboard/table-cells.js";
import {
parseCreationFlow,
stringifyCreationFlow,
} from "./creation-flow.js";
import { parseContextFragment } from "./context-fragment.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
export const CREATION_SEALED_WAITING_MESSAGE =
"开场已选定,创作已收口并保存。可切换到「游玩」。";
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)";
export function isOpeningLockTag(tag: string): boolean {
const t = tag.trim();
return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG;
}
export function isOpeningSealArtifact(artifact: {
workerId?: string;
outputTags?: readonly string[];
}): boolean {
if ((artifact.workerId ?? "").trim() === "opening-generator") return true;
return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG);
}
export type OpeningVariable = {
name: string;
value: unknown;
note?: string;
};
export type OpeningSealPayload = {
candidates: string[];
selectedIndex: number;
selectedText: string;
variables: OpeningVariable[];
};
function asTrimmed(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function collectCandidateTexts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
if (typeof item === "string" && item.trim()) {
out.push(item.trim());
continue;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text =
asTrimmed(row.全文) ||
asTrimmed(row.text) ||
asTrimmed(row.开场白) ||
asTrimmed(row.内容);
if (text) out.push(text);
}
}
return out;
}
function collectVariables(raw: unknown): OpeningVariable[] {
if (!Array.isArray(raw)) return [];
const out: OpeningVariable[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name =
asTrimmed(row.名) || asTrimmed(row.name) || asTrimmed(row.key) || "";
if (!name) continue;
const note = asTrimmed(row.依据) || asTrimmed(row.note);
out.push({
name,
value: row.值 !== undefined ? row.值 : row.value,
...(note ? { note } : {}),
});
}
return out;
}
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null;
}
return fragmentBody as Record<string, unknown>;
}
/** 从开场白 fragment(或裸 JSON)抽出候选与变量。 */
export function parseOpeningSealPayload(
raw: string | null | undefined,
selectedIndex = 0,
): OpeningSealPayload | null {
if (!raw?.trim()) return null;
const fragment = parseContextFragment(raw);
const body = bodyRecord(fragment?.正文) ?? (() => {
try {
const parsed = JSON.parse(raw) as unknown;
return bodyRecord(parsed) ?? bodyRecord((parsed as { 正文?: unknown })?.正文);
} catch {
return null;
}
})();
const primary =
asTrimmed(body?.开场白全文) ||
asTrimmed(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined);
const fromList = collectCandidateTexts(body?.开场白候选);
const candidates: string[] = [];
const seen = new Set<string>();
const push = (text: string | undefined) => {
if (!text || seen.has(text)) return;
seen.add(text);
candidates.push(text);
};
push(primary);
for (const t of fromList) push(t);
if (candidates.length === 0) return null;
const max = candidates.length - 1;
const idx = Number.isFinite(selectedIndex)
? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
: 0;
return {
candidates,
selectedIndex: idx,
selectedText: candidates[idx] ?? candidates[0]!,
variables: collectVariables(body?.开场变量),
};
}
export function openingVariablesToTableDoc(
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): TableDoc {
const now = new Date().toISOString();
return {
rows: variables.map((v) => ({
key: v.name,
value: v.value,
rev: 1,
updatedAt: now,
source,
visibility: "visible" as const,
...(v.note ? { note: v.note } : {}),
})),
};
}
export function mergeOpeningTablePatch(
currentRaw: string | null | undefined,
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): string | null {
if (!variables.length) return null;
const patch = openingVariablesToTableDoc(variables, source);
const current = parseTableDoc(currentRaw);
if (!current) return stringifyTableDoc(patch);
const byKey = new Map(current.rows.map((r) => [r.key, r]));
const now = new Date().toISOString();
for (const row of patch.rows) {
const prev = byKey.get(row.key);
if (prev && String(prev.source).startsWith("user")) continue;
byKey.set(row.key, {
...row,
rev: (prev?.rev ?? 0) + 1,
updatedAt: now,
});
}
return stringifyTableDoc({ rows: [...byKey.values()] });
}
export function closeCreationFlowRaw(raw: string | null | undefined): string | null {
const flow = parseCreationFlow(raw);
if (!flow) return raw?.trim() ? raw : null;
if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" });
}
+51
View File
@@ -319,3 +319,54 @@ export function inferPlaySlotsFromWorkers(
chance: hasChance,
};
}
const AGENT_ID_ALIASES: Record<string, PlaySlotId | OnDemandSlotId> = {
auditor: "auditor",
旁观维护: "auditor",
表格管理: "auditor",
gm: "gm",
"world-simulator": "gm",
主世界层: "gm",
narrator: "narrator",
叙事转述: "narrator",
转述: "narrator",
perspective: "perspective",
"role-decide": "perspective",
角色视角: "perspective",
chance: "chance",
机遇裁定: "chance",
};
/** 把模型写的 id / 中文名 / worker ref 收成槽键 */
export function resolvePlayAgentId(
raw: string,
): PlaySlotId | OnDemandSlotId | undefined {
const t = raw.trim();
return AGENT_ID_ALIASES[t] ?? AGENT_ID_ALIASES[t.toLowerCase()];
}
/** 注入投影排序等晚段步骤:名册由程序钉死,模型只抄不发明。 */
export function formatPlayAgentRosterForPrompt(): string {
const lines = [
"## 【本局固定执行单元 · 程序钉死】",
"",
"禁止增删改名,禁止发明未入白名单的槽(如画图)。先抄名册,再给每个已启用者分配上下文 inserts。",
"",
"每轮管线(顺序固定):",
];
for (const id of PLAY_SLOT_ORDER) {
const meta = PLAY_SLOT_META[id];
const def = id === "perspective" ? "默认关" : "默认开";
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`,
);
}
lines.push("", "按需(不进每轮;enabled 时可空 inserts):");
for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) {
const meta = ON_DEMAND_SLOT_META[id];
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_ON_DEMAND_REFS[id]}\` **${meta.label}**(默认关):${meta.purpose}`,
);
}
return lines.join("\n");
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 游玩回合:按运行规格 playWorkerIds 连跑(旁观 → 视角? → 主世界层 → 转述)。
* 与创作总管无关;收口后的输入走这里。
*/
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive";
export const PLAY_WORKING_SNAPSHOT_ID = "play-working";
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
export function isPlayLayerActive(slots: Record<string, unknown> | undefined): boolean {
return Boolean(slots?.[SLOT_PLAY_LAYER_ACTIVE]);
}
export function readPlayTurnQueue(
slots: Record<string, unknown> | undefined,
): string[] {
const raw = slots?.[SLOT_PLAY_TURN_QUEUE];
if (!Array.isArray(raw)) return [];
return raw
.filter((id): id is string => typeof id === "string" && Boolean(id.trim()))
.map((id) => id.trim());
}
export function withPlayTurnQueue(
slots: Record<string, unknown>,
queue: string[] | undefined,
): Record<string, unknown> {
return {
...slots,
[SLOT_PLAY_TURN_QUEUE]: queue?.length ? queue : undefined,
};
}
+25 -2
View File
@@ -132,6 +132,9 @@ function normalizeBlocks(raw: unknown): PresentBlocks {
if (out.monitor === undefined && row.监控 !== undefined) {
out.monitor = row.监控 as PresentBlocks["monitor"];
}
if (typeof out.body === "string") {
out.body = stripPresentSourceFences(out.body);
}
return out;
}
@@ -170,15 +173,35 @@ export function presentFromPlainText(
return {
schema: PRESENT_SCHEMA,
shell,
blocks: { body: text },
blocks: { body: stripPresentSourceFences(text) },
};
}
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
export function stripPresentSourceFences(raw: string): string {
let s = raw.trim();
for (let i = 0; i < 3; i += 1) {
const next = s
.replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "")
.replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "")
.trim();
if (next === s) break;
s = next;
}
return s;
}
export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"用户可见正文(present.v1 的 blocks.body,或纯 Markdown 主读)默认 **500~2000 字**(按汉字计)。",
"不要写成几句气泡短信就结束,除非用户明确要求极短。",
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
): PresentPacketView {
const text = raw?.trim() ?? "";
const text = stripPresentSourceFences(raw?.trim() ?? "");
if (!text) {
return {
ok: true,
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import { isPlayLayerActive } from "./play-turn.js";
import {
deriveDesignStageScope,
deriveOnDemandWorkerScope,
@@ -45,8 +46,9 @@ export function canEnterPlay(session: RuntimeSession): boolean {
}
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
const override = session.slots.uiLifecycleStage;
if (override === "play" && canEnterPlay(session)) return "play";
if (!canEnterPlay(session)) return "design";
if (session.slots.uiLifecycleStage === "play") return "play";
if (isPlayLayerActive(session.slots)) return "play";
return "design";
}
+7 -19
View File
@@ -3,16 +3,13 @@ import {
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import { resolveActivePreset } from "../preset/store.js";
import { loadAppSettings } from "../config/settings.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
type LlmTrackingRef = { current: LlmTrackingContext };
export type { LlmTrackingContext } from "../llm/token-tracker.js";
/** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */
export function createLlmForProfileId(
@@ -25,17 +22,10 @@ export function createLlmForProfileId(
return fallback;
}
let inner: LlmProvider = new OpenAiCompatibleProvider(
profileToLlmConfig(profile),
return wrapLlmForSession(
new OpenAiCompatibleProvider(profileToLlmConfig(profile)),
trackingRef,
);
const preset = resolveActivePreset(loadAppSettings().activePresetId);
if (preset) {
inner = new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
if (!trackingRef) return inner;
return new TokenTrackingProvider(inner, () => trackingRef.current);
}
/**
@@ -142,5 +132,3 @@ export function filterInputsForRolePerspective(
}
return filtered;
}
export type { LlmTrackingContext };
+44 -5
View File
@@ -1,3 +1,5 @@
import type { GenerationParameters } from "./preset.js";
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
export type LlmContextMessage = {
role: string;
@@ -12,17 +14,51 @@ export type LlmContextTrace = {
/** 便于列表展示 */
charCount: number;
model?: string;
/** 与请求一并送出的生成参数(无则未带) */
generation?: GenerationParameters;
};
/** 把即将写入请求 body.messages 的条目冻成痕迹(含 tool_calls 等附加字段) */
export function snapshotRequestMessages(
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>,
): LlmContextMessage[] {
return messages.map((m) => {
const hasExtras = Boolean(m.tool_calls) || Boolean(m.tool_call_id);
if (!hasExtras) {
return {
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
};
}
return {
role: m.role,
content: JSON.stringify({
role: m.role,
content: m.content ?? "",
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
}),
};
});
}
export function buildContextTrace(params: {
caller: string;
messages: Array<{ role: string; content: string }>;
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>;
model?: string;
generation?: GenerationParameters;
}): LlmContextTrace {
const messages = params.messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
const messages = snapshotRequestMessages(params.messages);
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
return {
caller: params.caller,
@@ -30,6 +66,9 @@ export function buildContextTrace(params: {
messages,
charCount,
model: params.model,
...(params.generation && Object.keys(params.generation).length
? { generation: params.generation }
: {}),
};
}
+11 -2
View File
@@ -53,7 +53,10 @@ export type WaitingReason =
} // worker 无产物时的阻塞提问
| { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑
| {
/** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */
/**
* 旧路径:上一步验收后先问「下一步想写什么」。
* 现已改为验收后直接提案;保留此 kind 以兼容未完成的旧会话。
*/
kind: "next_intent";
afterWorkerId?: string;
afterUnitId?: string;
@@ -329,7 +332,13 @@ export type PhaseEffect =
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
| { type: "propose_next_creation_step" };
| { type: "propose_next_creation_step" }
/** 选定开场白:落库、关 DAG、标记创作收口 */
| { type: "seal_creation_opening" }
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
| { type: "run_play_turn" }
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
| { type: "continue_play_turn" };
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
export type ApplyEventResult = {
+129 -80
View File
@@ -11,9 +11,16 @@ import type { ParsedWorkerSkill } from "../skills/types.js";
import {
extractJsonObjectText,
isUsableWorkerSet,
looksLikeProseNotSpec,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import { isPlayLayerActive } from "../skills/play-turn.js";
import { loadAppSettings } from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
import { PRESENT_TAG } from "../skills/present-packet.js";
import {
mergeQuestionsPreferFragment,
normalizeQuestions,
@@ -21,11 +28,15 @@ import {
} from "../skills/question-protocol.js";
import {
extractFragmentAskSidecar,
expectsContextFragmentTag,
isUsableContextFragment,
looksLikeFragmentDoc,
} from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js";
import {
CONTEXT_ORDER_TAG,
parseContextOrder,
serializeContextOrder,
} from "../skills/context-order.js";
export type WorkerRunParams = {
skillName: string;
@@ -53,6 +64,25 @@ export type WorkerRunResult = {
};
const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]);
const PLAY_FEED_OUTPUT_TAGS = new Set([PRESENT_TAG, "输出.开场白"]);
function formatWorkerPreview(
outputs: Record<string, string>,
summary: string,
): string {
const visible = Object.entries(outputs).filter(([tag]) =>
PLAY_FEED_OUTPUT_TAGS.has(tag),
);
if (visible.length) {
return visible.map(([, v]) => v).join("\n\n");
}
return (
Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary
);
}
const WORKER_OUTPUT_INSTRUCTION = `
@@ -74,7 +104,9 @@ askUser 每项可为:
- outputs 的 key 必须是要求的 outputTags
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
@@ -193,31 +225,46 @@ function recoverFragmentOutputs(
}
}
function dropUnusableFragmentOutputs(outputs: Record<string, string>): void {
/** 进度指针不得当产物;半残 JSON / 缺字段仍保留,交给验收卡。 */
function dropProgressPointerOutputs(outputs: Record<string, string>): void {
for (const tag of Object.keys(outputs)) {
if (isProgressPointerTag(tag)) {
delete outputs[tag];
continue;
}
const content = outputs[tag]!;
const requireFragment = expectsContextFragmentTag(tag);
const looksFragment =
isUsableContextFragment(content) ||
looksLikeFragmentDoc(tryParseObject(content)) ||
/"schema"\s*:\s*"context-fragment\.v1"/.test(content) ||
/"技能"\s*:/.test(content);
if ((requireFragment || looksFragment) && !isUsableContextFragment(content)) {
if (isProgressPointerTag(tag) || !outputs[tag]?.trim()) {
delete outputs[tag];
}
}
}
function tryParseObject(text: string): unknown {
const extracted = extractJsonObjectText(text) ?? text.trim();
try {
return JSON.parse(extracted);
} catch {
return undefined;
/** 像结构化残稿(含截断 JSON),应进验收而不是改成提问。 */
function looksLikeStructuredDraft(text: string): boolean {
const t = text.trim();
if (!t) return false;
if (t.startsWith("{") || t.startsWith("[")) return true;
if (/```(?:json)?/i.test(t) && t.includes("{")) return true;
if (/"schema"\s*:/.test(t)) return true;
if (/"技能"\s*:/.test(t) || /"正文"\s*:/.test(t)) return true;
if (/"inserts"\s*:/.test(t) || /"agents"\s*:/.test(t) || /"play_slots"\s*:/.test(t)) {
return true;
}
if (/"workers"\s*:/.test(t) && t.includes("{")) return true;
return Boolean(extractJsonObjectText(t));
}
/** 几乎只有提问、没有稿。 */
function isQuestionOnlyText(text: string): boolean {
const t = text.trim();
if (!t) return true;
if (looksLikeStructuredDraft(t)) return false;
const qs = extractQuestionsFromText(t);
if (!qs.length) return false;
return looksLikeProseNotSpec(t) || /你的选择|请(?:描述|选择|确认|补充)/.test(t);
}
function canonicalizeKnownOutputs(outputs: Record<string, string>): void {
const raw = outputs[CONTEXT_ORDER_TAG];
if (!raw?.trim()) return;
const doc = parseContextOrder(raw);
if (doc?.slots.length) {
outputs[CONTEXT_ORDER_TAG] = serializeContextOrder(doc);
}
}
@@ -239,20 +286,14 @@ function parseWorkerResponse(
try {
parsed = JSON.parse(raw);
} catch {
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return {
outputs: {},
summary: "未产出合法协议 JSON",
preview: raw.slice(0, 600),
askUser: questions.length
? questions.slice(0, 2)
: normalizeQuestions([
"请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。",
]),
};
}
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
{ summary: "格式不完整,已交出残稿" },
outputTags,
);
}
if (target && isUsableContextFragment(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
@@ -260,6 +301,13 @@ function parseWorkerResponse(
outputTags,
);
}
if (target && raw.trim() && !isQuestionOnlyText(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
{ summary: "未按协议包一层,已交出原文" },
outputTags,
);
}
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return {
outputs: {},
@@ -323,7 +371,7 @@ function finalizeParsedOutputs(
}
}
// 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop
// 先抽追问;半残稿仍留在 outputs,追问挂验收卡(不因缺字段丢掉正文)
let askAssessment: string | undefined;
const fragQuestions: QuestionItem[] = [];
for (const content of Object.values(outputs)) {
@@ -339,7 +387,8 @@ function finalizeParsedOutputs(
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
}
dropUnusableFragmentOutputs(outputs);
dropProgressPointerOutputs(outputs);
canonicalizeKnownOutputs(outputs);
if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
@@ -351,11 +400,7 @@ function finalizeParsedOutputs(
: Object.values(outputs)[0]?.slice(0, 80) ??
(askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成");
const preview =
Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
const preview = formatWorkerPreview(outputs, summary);
return sanitizeWorkerSetOutputs({
outputs,
@@ -366,12 +411,11 @@ function finalizeParsedOutputs(
});
}
/** 规格 tag 必须是可用 JSON;非法散文改为 askUser,避免污染黑板 */
/** 规格 tag:能解析则收成 JSON;半残 JSON 保留进验收;纯提问才改成 askUser。 */
export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult {
const outputs = { ...result.outputs };
const askUser = [...(result.askUser ?? [])];
const askAssessment = result.askAssessment;
let droppedProse = false;
for (const tag of [...Object.keys(outputs)]) {
if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue;
@@ -388,27 +432,16 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu
: (extractJsonObjectText(extracted) ?? extracted);
continue;
}
droppedProse = true;
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
if (looksLikeStructuredDraft(content)) {
outputs[tag] = content.trim();
continue;
}
}
if (droppedProse && askUser.length === 0) {
askUser.push(
...normalizeQuestions([
"请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON)。",
]),
);
}
// 提问时不要夹带半残规格
if (askUser.length > 0) {
for (const tag of WORKER_SET_OUTPUT_TAGS) {
// 保留仍合法的草稿;已在上面删掉非法的
void tag;
if (isQuestionOnlyText(content)) {
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
}
}
}
@@ -420,10 +453,7 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu
const preview =
askUser.length && Object.keys(outputs).length === 0
? askUser.map((q) => `- ${q.prompt}`).join("\n")
: Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
: formatWorkerPreview(outputs, summary);
return {
outputs,
@@ -527,22 +557,41 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
}
const userPayload = assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
workerId: worker.id,
workerName: worker.name,
outputTags: worker.outputTags,
});
const systemContent = promptBody + WORKER_OUTPUT_INSTRUCTION;
const playPreset = isPlayLayerActive(params.slots)
? resolveActivePreset(loadAppSettings().activePresetId)
: null;
const messages = playPreset
? assemblePlayWorkerMessages({
systemPrompt: systemContent,
preset: playPreset,
pack: worldInfoPackFromSegments({
segments: worker.contextSegments ?? [],
inputs,
blackboard: params.blackboard,
inputMerge,
}),
})
: [
{ role: "system" as const, content: systemContent },
{
role: "user" as const,
content: assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
workerId: worker.id,
workerName: worker.name,
outputTags: worker.outputTags,
}),
},
];
const result = await completeWorkerPreferStream(
params.llm,
[
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
{ role: "user", content: userPayload },
],
messages,
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,