Add capability-probed structured outputs and a prototype/instance creation graph.

Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 01:57:22 +08:00
co-authored by Cursor
parent 872f742d3e
commit bd5d88abfa
65 changed files with 7625 additions and 942 deletions
+79 -28
View File
@@ -1,6 +1,12 @@
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
normalizeCapabilities,
probeProfileCapabilities,
type ProfileCapabilities,
} from "../llm/capabilities.js";
import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
import type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
@@ -11,8 +17,15 @@ export type ApiProfile = {
baseUrl: string;
apiKey: string;
model: string;
/**
* 该配置默认思考强度(写入请求的 reasoning_effort)。
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。
*/
reasoningEffort?: string;
createdAt: string;
updatedAt: string;
/** 能力探测结果;改 baseUrl/model 后应重测 */
capabilities?: ProfileCapabilities;
};
type ApiProfilesFile = {
@@ -45,6 +58,16 @@ function writeFile(data: ApiProfilesFile): void {
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
}
/** 存盘值:合法档位原样保留;空 / auto / 非法 → undefined(不发送) */
export function normalizeProfileReasoningEffort(
value: unknown,
): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "auto") return undefined;
return sanitizeReasoningEffort(trimmed);
}
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional();
@@ -79,15 +102,18 @@ export function createApiProfile(input: {
baseUrl: string;
apiKey: string;
model: string;
reasoningEffort?: string;
}): ApiProfile {
const data = readFile();
const now = new Date().toISOString();
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
const profile: ApiProfile = {
id: randomUUID(),
name: input.name.trim() || "未命名",
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
apiKey: input.apiKey.trim(),
model: input.model.trim() || "deepseek-v4-pro",
...(reasoningEffort ? { reasoningEffort } : {}),
createdAt: now,
updatedAt: now,
};
@@ -98,20 +124,52 @@ export function createApiProfile(input: {
export function updateApiProfile(
id: string,
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
input: Partial<
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
>,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index];
const nextBase = input.baseUrl?.trim() || current.baseUrl;
const nextModel = input.model?.trim() || current.model;
const endpointChanged =
nextBase !== current.baseUrl || nextModel !== current.model;
const reasoningEffort =
input.reasoningEffort !== undefined
? normalizeProfileReasoningEffort(input.reasoningEffort)
: current.reasoningEffort;
const updated: ApiProfile = {
...current,
name: input.name?.trim() || current.name,
baseUrl: input.baseUrl?.trim() || current.baseUrl,
baseUrl: nextBase,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey,
model: input.model?.trim() || current.model,
model: nextModel,
updatedAt: new Date().toISOString(),
// 端点或模型变了,旧探测作废
...(endpointChanged ? { capabilities: undefined } : {}),
};
if (reasoningEffort) updated.reasoningEffort = reasoningEffort;
else delete updated.reasoningEffort;
data.profiles[index] = updated;
writeFile(data);
return updated;
}
export function saveProfileCapabilities(
id: string,
capabilities: ProfileCapabilities,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]!;
const updated: ApiProfile = {
...current,
capabilities: normalizeCapabilities(capabilities) ?? capabilities,
updatedAt: new Date().toISOString(),
};
data.profiles[index] = updated;
@@ -119,6 +177,20 @@ export function updateApiProfile(
return updated;
}
/** 探测并持久化;返回更新后的 profile */
export async function probeAndSaveProfileCapabilities(
id: string,
): Promise<ApiProfile> {
const profile = getApiProfile(id);
if (!profile) throw new Error("API 配置不存在");
const capabilities = await probeProfileCapabilities({
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model: profile.model,
});
return saveProfileCapabilities(id, capabilities);
}
export function deleteApiProfile(id: string): void {
const data = readFile();
data.profiles = data.profiles.filter((p) => p.id !== id);
@@ -130,30 +202,9 @@ export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model: profile.model,
...(profile.reasoningEffort
? { reasoningEffort: profile.reasoningEffort }
: {}),
capabilities: normalizeCapabilities(profile.capabilities),
};
}
export async function testApiProfile(profile: ApiProfile): Promise<{
ok: boolean;
message: string;
}> {
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${profile.apiKey}` },
});
if (response.ok) {
return { ok: true, message: "连接成功" };
}
const body = await response.text();
return {
ok: false,
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "连接失败",
};
}
}
+9
View File
@@ -1,10 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { ProfileCapabilities } from "../llm/capabilities.js";
export type LlmConfig = {
baseUrl: string;
apiKey: string;
model: string;
/**
* API 配置上的默认思考强度;generation 未指定时写入 reasoning_effort。
* 空 / auto = 不发送,走供应商默认。
*/
reasoningEffort?: string;
/** 来自 API profile 探测结果;决定 structured 投递选路 */
capabilities?: ProfileCapabilities;
};
export function loadLlmConfig(): LlmConfig {
+364
View File
@@ -0,0 +1,364 @@
/**
* API profile 能力探测与 structured 投递选路。
* 优先 response json_schema;不可用则 forced tool;再退 json_object。
*/
import {
applyRejectedGenerationField,
parseRejectedGenerationField,
} from "./generation-compat.js";
export type CapabilityStatus = "ok" | "fail" | "unknown";
export type StructuredDeliveryMode =
| "json_schema"
| "forced_tool"
| "json_object"
| "text";
export type ProfileCapabilities = {
/** 普通聊天 /models 或极简 completion */
chat: CapabilityStatus;
/** response_format: json_object */
jsonObject: CapabilityStatus;
/** response_format: json_schema(真 schema 约束) */
jsonSchema: CapabilityStatus;
/** tools + 返回 tool_calls */
tools: CapabilityStatus;
/** tool_choice=required(或指名函数)能强制提交 */
forcedTool: CapabilityStatus;
/** tools[].function.strict(若探测时带了) */
strictTool: CapabilityStatus;
testedAt?: string;
notes?: string[];
};
export const UNKNOWN_CAPABILITIES: ProfileCapabilities = {
chat: "unknown",
jsonObject: "unknown",
jsonSchema: "unknown",
tools: "unknown",
forcedTool: "unknown",
strictTool: "unknown",
};
/** 探测固定 low,避免 always-on 思考把 max_tokens 吃光导致误判 */
export const CAPABILITY_PROBE_REASONING_EFFORT = "low" as const;
const PROBE_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["ping", "n"],
properties: {
ping: { type: "string" },
n: { type: "number" },
},
} as const;
const PROBE_TOOL = {
type: "function" as const,
function: {
name: "submit_probe",
description: "Submit the probe result. Always call this tool.",
parameters: {
type: "object",
additionalProperties: false,
required: ["ping", "n"],
properties: {
ping: { type: "string" },
n: { type: "number" },
},
},
},
};
const PROBE_TOOL_STRICT = {
type: "function" as const,
function: {
...PROBE_TOOL.function,
strict: true,
},
};
export function normalizeCapabilities(
raw: unknown,
): ProfileCapabilities | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const read = (k: string): CapabilityStatus => {
const v = row[k];
if (v === "ok" || v === "fail" || v === "unknown") return v;
return "unknown";
};
return {
chat: read("chat"),
jsonObject: read("jsonObject"),
jsonSchema: read("jsonSchema"),
tools: read("tools"),
forcedTool: read("forcedTool"),
strictTool: read("strictTool"),
testedAt: typeof row.testedAt === "string" ? row.testedAt : undefined,
notes: Array.isArray(row.notes)
? row.notes.filter((x): x is string => typeof x === "string")
: undefined,
};
}
/** 选路:schema → forced tool → json_object → text */
export function resolveStructuredDeliveryMode(
caps: ProfileCapabilities | null | undefined,
): StructuredDeliveryMode {
if (!caps) return "json_object";
if (caps.jsonSchema === "ok") return "json_schema";
if (caps.forcedTool === "ok") return "forced_tool";
if (caps.jsonObject === "ok") return "json_object";
return "text";
}
export function deliveryModeLabel(mode: StructuredDeliveryMode): string {
switch (mode) {
case "json_schema":
return "JSON Schema";
case "forced_tool":
return "强制 Tool";
case "json_object":
return "JSON Object";
default:
return "纯文本";
}
}
type ProbeConfig = {
baseUrl: string;
apiKey: string;
model: string;
};
async function postOnce(
config: ProbeConfig,
body: Record<string, unknown>,
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ model: config.model, ...body }),
});
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text) as unknown;
} catch {
/* ignore */
}
return { ok: res.ok, status: res.status, json, text };
} catch (err) {
return {
ok: false,
status: 0,
json: null,
text: err instanceof Error ? err.message : String(err),
};
}
}
/**
* 探测请求始终带 reasoning_effort=low。
* 若供应商拒收该字段,剥掉后再试一次。
*/
async function postChat(
config: ProbeConfig,
body: Record<string, unknown>,
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
const withEffort: Record<string, unknown> = {
...body,
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
};
const first = await postOnce(config, withEffort);
if (first.ok || first.status !== 400) return first;
const rejected = parseRejectedGenerationField(first.text);
if (rejected !== "reasoning_effort") return first;
const retryBody = { ...withEffort };
const tried = new Set<string>();
if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) {
return first;
}
return postOnce(config, retryBody);
}
function assistantMessage(json: unknown): Record<string, unknown> | null {
if (!json || typeof json !== "object") return null;
const choices = (json as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") {
return null;
}
const msg = (choices[0] as { message?: unknown }).message;
if (!msg || typeof msg !== "object") return null;
return msg as Record<string, unknown>;
}
function hasToolCallNamed(json: unknown, name: string): boolean {
const msg = assistantMessage(json);
if (!msg) return false;
const calls = msg.tool_calls;
if (!Array.isArray(calls)) return false;
return calls.some((c) => {
if (!c || typeof c !== "object") return false;
const fn = (c as { function?: unknown }).function;
if (!fn || typeof fn !== "object") return false;
return (fn as { name?: unknown }).name === name;
});
}
function contentLooksLikeJsonObject(json: unknown): boolean {
const msg = assistantMessage(json);
const content = msg?.content;
if (typeof content !== "string" || !content.trim()) return false;
try {
const parsed = JSON.parse(content) as unknown;
return parsed != null && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/**
* 对单个 profile 跑一轮能力探测(会真实打 API,费用很小)。
*/
export async function probeProfileCapabilities(
config: ProbeConfig,
): Promise<ProfileCapabilities> {
const notes: string[] = [];
const caps: ProfileCapabilities = {
...UNKNOWN_CAPABILITIES,
testedAt: new Date().toISOString(),
};
const chat = await postChat(config, {
messages: [{ role: "user", content: "Reply with exactly: ok" }],
max_tokens: 32,
});
caps.chat = chat.ok ? "ok" : "fail";
if (!chat.ok) {
notes.push(`chat: HTTP ${chat.status} ${chat.text.slice(0, 160)}`);
caps.notes = notes;
return caps;
}
const jsonObject = await postChat(config, {
messages: [
{
role: "user",
content:
'Return a JSON object with keys ping (string "ok") and n (number 1).',
},
],
response_format: { type: "json_object" },
max_tokens: 128,
});
caps.jsonObject =
jsonObject.ok && contentLooksLikeJsonObject(jsonObject.json) ? "ok" : "fail";
if (!jsonObject.ok) {
notes.push(`json_object: HTTP ${jsonObject.status} ${jsonObject.text.slice(0, 120)}`);
} else if (caps.jsonObject === "fail") {
notes.push("json_object: 返回了但不是可解析对象");
}
const jsonSchema = await postChat(config, {
messages: [
{
role: "user",
content: "Fill ping=ok and n=1.",
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "probe_result",
strict: true,
schema: PROBE_SCHEMA,
},
},
max_tokens: 128,
});
caps.jsonSchema =
jsonSchema.ok && contentLooksLikeJsonObject(jsonSchema.json) ? "ok" : "fail";
if (!jsonSchema.ok) {
notes.push(`json_schema: HTTP ${jsonSchema.status} ${jsonSchema.text.slice(0, 120)}`);
}
const tools = await postChat(config, {
messages: [
{
role: "user",
content: "Call submit_probe with ping=ok and n=1.",
},
],
tools: [PROBE_TOOL],
tool_choice: "auto",
max_tokens: 256,
});
caps.tools = tools.ok && hasToolCallNamed(tools.json, "submit_probe") ? "ok" : "fail";
if (!tools.ok) {
notes.push(`tools: HTTP ${tools.status} ${tools.text.slice(0, 120)}`);
} else if (caps.tools === "fail") {
notes.push("tools: 请求成功但未返回 submit_probe");
}
const forced = await postChat(config, {
messages: [
{
role: "user",
content: "Submit the probe.",
},
],
tools: [PROBE_TOOL],
tool_choice: "required",
max_tokens: 256,
});
let forcedOk =
forced.ok && hasToolCallNamed(forced.json, "submit_probe");
if (!forcedOk && forced.ok) {
const named = await postChat(config, {
messages: [{ role: "user", content: "Submit the probe." }],
tools: [PROBE_TOOL],
tool_choice: {
type: "function",
function: { name: "submit_probe" },
},
max_tokens: 256,
});
forcedOk = named.ok && hasToolCallNamed(named.json, "submit_probe");
if (!named.ok) {
notes.push(`forced_tool(named): HTTP ${named.status} ${named.text.slice(0, 120)}`);
}
} else if (!forced.ok) {
notes.push(`forced_tool: HTTP ${forced.status} ${forced.text.slice(0, 120)}`);
}
caps.forcedTool = forcedOk ? "ok" : "fail";
if (caps.tools === "ok" || caps.forcedTool === "ok") {
const strict = await postChat(config, {
messages: [{ role: "user", content: "Submit the probe." }],
tools: [PROBE_TOOL_STRICT],
tool_choice: "required",
max_tokens: 256,
});
caps.strictTool =
strict.ok && hasToolCallNamed(strict.json, "submit_probe") ? "ok" : "fail";
if (!strict.ok) {
notes.push(`strict_tool: HTTP ${strict.status} ${strict.text.slice(0, 120)}`);
}
} else {
caps.strictTool = "fail";
}
caps.notes = notes.length ? notes : undefined;
return caps;
}
+56 -37
View File
@@ -1,11 +1,18 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
import {
applyOpenAiGeneration,
fetchWithGenerationCompat,
sanitizeReasoningEffort,
} from "./generation-compat.js";
import { consumeOpenAiToolStream } from "./stream-complete.js";
import {
currentAbortSignal,
throwIfAborted,
} from "./run-abort.js";
export { sanitizeReasoningEffort };
export type ToolCallPayload = {
id: string;
type: "function";
@@ -30,6 +37,8 @@ export type ToolDefinition = {
name: string;
description: string;
parameters: Record<string, unknown>;
/** 部分供应商支持的严格参数约束 */
strict?: boolean;
};
};
@@ -57,8 +66,16 @@ export type CompleteResult = {
model?: string;
};
export type JsonSchemaFormat = {
name: string;
strict?: boolean;
schema: Record<string, unknown>;
};
export type CompleteOptions = {
responseFormat?: "json_object" | "text";
responseFormat?: "json_object" | "json_schema" | "text";
/** responseFormat=json_schema 时使用 */
jsonSchema?: JsonSchemaFormat;
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
@@ -66,8 +83,15 @@ export type CompleteOptions = {
signal?: AbortSignal;
};
export type ToolChoice =
| "auto"
| "required"
| "none"
| { type: "function"; function: { name: string } };
export type CompleteWithToolsOptions = CompleteOptions & {
tools: ToolDefinition[];
toolChoice?: ToolChoice;
};
export type CompleteWithToolsResult = {
@@ -104,12 +128,11 @@ export type LlmProvider = {
): Promise<CompleteWithToolsResult>;
};
function buildRequestBody(
export function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> {
const gen = options?.generation ?? {};
const body: Record<string, unknown> = {
model: config.model,
messages,
@@ -117,34 +140,28 @@ function buildRequestBody(
if (options?.tools?.length) {
body.tools = options.tools;
body.tool_choice = "auto";
body.tool_choice =
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
}
if (gen.temperature !== undefined) body.temperature = gen.temperature;
else body.temperature = 0.2;
if (gen.topP !== undefined) body.top_p = gen.topP;
if (gen.topK !== undefined) body.top_k = gen.topK;
if (gen.minP !== undefined) body.min_p = gen.minP;
if (gen.frequencyPenalty !== undefined) {
body.frequency_penalty = gen.frequencyPenalty;
}
if (gen.presencePenalty !== undefined) {
body.presence_penalty = gen.presencePenalty;
}
if (gen.repetitionPenalty !== undefined) {
body.repetition_penalty = gen.repetitionPenalty;
}
if (gen.maxOutputTokens !== undefined) {
body.max_tokens = gen.maxOutputTokens;
}
if (gen.seed !== undefined) body.seed = gen.seed;
if (gen.reasoningEffort !== undefined) {
body.reasoning_effort = gen.reasoningEffort;
}
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort,
});
if (options?.responseFormat === "json_object") {
body.response_format = { type: "json_object" };
} else if (
options?.responseFormat === "json_schema" &&
options.jsonSchema
) {
body.response_format = {
type: "json_schema",
json_schema: {
name: options.jsonSchema.name,
strict: options.jsonSchema.strict ?? true,
schema: options.jsonSchema.schema,
},
};
}
if (options?.stream) {
@@ -246,19 +263,21 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
async function llmFetch(
url: string,
apiKey: string,
body: unknown,
body: Record<string, 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(),
});
return fetchWithGenerationCompat(async (payload) => {
throwIfAborted(options?.signal);
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
signal: options?.signal ?? currentAbortSignal(),
});
}, body);
}
export class OpenAiCompatibleProvider implements LlmProvider {
+277
View File
@@ -0,0 +1,277 @@
import type { GenerationParameters } from "../types/preset.js";
/**
* OpenAI-compatible Chat Completions 的生成参数兼容层,对齐 SillyTavern Custom 源:
* - `auto` / 关闭值不发送
* - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发
* - 推理模型去掉 temperature / top_p / penalty
* - o 系列与 gpt-5 用 max_completion_tokens
* - 仍 400 时按错误信息剥掉不支持字段再试
*/
const REASONING_EFFORT_VALUES = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
type ReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
const REASONING_EFFORT_ALIASES: Record<string, ReasoningEffort> = {
min: "minimal",
};
const VERBOSITY_VALUES = ["low", "medium", "high"] as const;
type Verbosity = (typeof VERBOSITY_VALUES)[number];
export const STRIPPABLE_GENERATION_FIELDS = new Set([
"temperature",
"top_p",
"top_k",
"min_p",
"frequency_penalty",
"presence_penalty",
"repetition_penalty",
"seed",
"n",
"reasoning_effort",
"verbosity",
"max_tokens",
"max_completion_tokens",
"logit_bias",
"stop",
"response_format",
"stream_options",
]);
export type ChatModelCompat = {
omitSampling: boolean;
useMaxCompletionTokens: boolean;
supportsVerbosity: boolean;
};
function modelLeaf(model: string): string {
const trimmed = model.trim().toLowerCase();
const slash = trimmed.lastIndexOf("/");
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
}
export function classifyChatModel(model: string): ChatModelCompat {
const id = modelLeaf(model);
const isOSeries = /^o[134]($|[-.\d])/.test(id);
const isGpt5 = id.includes("gpt-5");
const isGpt5Chat = isGpt5 && id.includes("chat");
const isKimi25 = id.includes("kimi-k2.5");
const isDeepseekReasoner = id.includes("deepseek-reasoner");
const isGrok3Mini = id.includes("grok-3-mini");
return {
omitSampling:
isOSeries ||
(isGpt5 && !isGpt5Chat) ||
isKimi25 ||
isDeepseekReasoner ||
isGrok3Mini,
useMaxCompletionTokens: isOSeries || isGpt5,
supportsVerbosity: isGpt5,
};
}
export function sanitizeReasoningEffort(
value: unknown,
): ReasoningEffort | undefined {
if (typeof value !== "string") return undefined;
const key = value.trim().toLowerCase();
if (!key || key === "auto") return undefined;
const mapped = REASONING_EFFORT_ALIASES[key] ?? key;
return (REASONING_EFFORT_VALUES as readonly string[]).includes(mapped)
? (mapped as ReasoningEffort)
: undefined;
}
export function sanitizeVerbosity(value: unknown): Verbosity | undefined {
if (typeof value !== "string") return undefined;
const key = value.trim().toLowerCase();
if (!key || key === "auto") return undefined;
return (VERBOSITY_VALUES as readonly string[]).includes(key)
? (key as Verbosity)
: undefined;
}
function finiteNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return value;
}
export type GenerationDefaults = {
/** API profile 等来源的默认思考强度;仅当 gen 未指定时生效 */
reasoningEffort?: string;
};
export function applyOpenAiGeneration(
body: Record<string, unknown>,
gen: GenerationParameters,
model: string,
defaults?: GenerationDefaults,
): void {
const { omitSampling, useMaxCompletionTokens, supportsVerbosity } =
classifyChatModel(model);
if (!omitSampling) {
body.temperature = gen.temperature !== undefined ? gen.temperature : 0.2;
const topP = finiteNumber(gen.topP);
if (topP !== undefined && topP > 0) body.top_p = topP;
const frequencyPenalty = finiteNumber(gen.frequencyPenalty);
if (frequencyPenalty !== undefined) {
body.frequency_penalty = frequencyPenalty;
}
const presencePenalty = finiteNumber(gen.presencePenalty);
if (presencePenalty !== undefined) {
body.presence_penalty = presencePenalty;
}
}
const maxOutput = finiteNumber(gen.maxOutputTokens);
if (maxOutput !== undefined && maxOutput > 0) {
if (useMaxCompletionTokens) body.max_completion_tokens = maxOutput;
else body.max_tokens = maxOutput;
}
const seed = finiteNumber(gen.seed);
if (seed !== undefined && seed >= 0) body.seed = seed;
const reasoningEffort =
sanitizeReasoningEffort(gen.reasoningEffort) ??
sanitizeReasoningEffort(defaults?.reasoningEffort);
if (reasoningEffort !== undefined) {
body.reasoning_effort = reasoningEffort;
}
const verbosity = sanitizeVerbosity(gen.verbosity);
if (verbosity !== undefined && supportsVerbosity) {
body.verbosity = verbosity;
}
}
function readErrorMessage(raw: unknown): string {
if (!raw || typeof raw !== "object") return "";
const err = (raw as { error?: unknown }).error;
if (typeof err === "string") return err;
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") return message;
}
const message = (raw as { message?: unknown }).message;
return typeof message === "string" ? message : "";
}
function readErrorParam(raw: unknown): string | undefined {
if (!raw || typeof raw !== "object") return undefined;
const err = (raw as { error?: unknown }).error;
if (err && typeof err === "object") {
const param = (err as { param?: unknown }).param;
if (typeof param === "string" && STRIPPABLE_GENERATION_FIELDS.has(param)) {
return param;
}
}
return undefined;
}
export function parseRejectedGenerationField(
errorText: string,
): string | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(errorText);
} catch {
parsed = undefined;
}
const fromParam = readErrorParam(parsed);
if (fromParam) return fromParam;
const message = `${readErrorMessage(parsed)}\n${errorText}`;
if (/temperature and top_p cannot both/i.test(message)) return "top_p";
const patterns = [
/unsupported parameter:?\s*['"`]?([a-z_]+)/i,
/unknown field [`'"]([a-z_]+)[`'"]/i,
/([a-z_]+):\s*unknown variant/i,
/invalid value[^\n]*for[^\n]*['"`]([a-z_]+)['"`]/i,
];
for (const pattern of patterns) {
const match = message.match(pattern);
const field = match?.[1]?.toLowerCase();
if (field && STRIPPABLE_GENERATION_FIELDS.has(field)) return field;
}
return undefined;
}
export function applyRejectedGenerationField(
body: Record<string, unknown>,
field: string,
tried: Set<string>,
): boolean {
if (!STRIPPABLE_GENERATION_FIELDS.has(field)) return false;
if (
field === "max_completion_tokens" &&
body.max_completion_tokens != null &&
!tried.has("max_tokens")
) {
body.max_tokens = body.max_completion_tokens;
delete body.max_completion_tokens;
tried.add("max_completion_tokens");
return true;
}
if (
field === "max_tokens" &&
body.max_tokens != null &&
!tried.has("max_completion_tokens")
) {
body.max_completion_tokens = body.max_tokens;
delete body.max_tokens;
tried.add("max_tokens");
return true;
}
if (!(field in body)) return false;
delete body[field];
tried.add(field);
return true;
}
function replayResponse(source: Response, text: string): Response {
return new Response(text, {
status: source.status,
statusText: source.statusText,
headers: source.headers,
});
}
export async function fetchWithGenerationCompat(
send: (body: Record<string, unknown>) => Promise<Response>,
initialBody: Record<string, unknown>,
): Promise<Response> {
let body = initialBody;
const tried = new Set<string>();
let last: Response | undefined;
for (let attempt = 0; attempt < 8; attempt++) {
last = await send(body);
if (last.ok || last.status !== 400) return last;
const text = await last.text();
const field = parseRejectedGenerationField(text);
if (!field) return replayResponse(last, text);
const next = { ...body };
if (!applyRejectedGenerationField(next, field, tried)) {
return replayResponse(last, text);
}
body = next;
}
return last ?? new Response("LLM request failed", { status: 400 });
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 按 profile 能力选路的 structured 完成:json_schema → forced_tool → json_object。
*/
import {
resolveStructuredDeliveryMode,
type ProfileCapabilities,
type StructuredDeliveryMode,
} from "./capabilities.js";
import type {
ChatMessage,
CompleteOptions,
CompleteResult,
LlmProvider,
ToolDefinition,
} from "./client.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
export type StructuredCompleteOptions = CompleteOptions & {
/** JSON Schema 对象(json_schema / tool parameters 共用) */
schema: Record<string, unknown>;
/** schema / tool 名称 */
name: string;
/** 覆盖自动选路 */
mode?: StructuredDeliveryMode;
capabilities?: ProfileCapabilities | null;
/**
* schema 含 additionalProperties:true 等、无法走 strict json_schema 时置 true。
* 会自动降级:forced_tool → json_object。
*/
schemaIsLoose?: boolean;
/** forced_tool 时是否尝试 strict(默认:capabilities.strictTool===ok 且非 loose) */
preferStrictTool?: boolean;
stream?: import("./client.js").StreamCallbacks;
};
export type StructuredCompleteResult = CompleteResult & {
parsed: unknown;
mode: StructuredDeliveryMode;
};
function parseObjectPayload(raw: string): unknown {
const parsed = tryParseJsonDoc(raw);
if (parsed != null) return parsed;
try {
return JSON.parse(raw) as unknown;
} catch {
throw new Error("structured 输出无法解析为 JSON");
}
}
function buildSubmitTool(
name: string,
schema: Record<string, unknown>,
strict: boolean,
): ToolDefinition {
return {
type: "function",
function: {
name,
description: `Submit the structured result as arguments to ${name}. Always call this tool.`,
parameters: schema,
...(strict ? { strict: true } : {}),
},
};
}
/**
* 一次 structured 完成:返回解析后的对象。
* forced_tool 路径走 completeWithTools;其余走 complete / completeStream。
*/
export async function completeStructured(
llm: LlmProvider,
messages: ChatMessage[],
options: StructuredCompleteOptions,
): Promise<StructuredCompleteResult> {
const requested =
options.mode ?? resolveStructuredDeliveryMode(options.capabilities);
let mode = requested;
if (options.schemaIsLoose && mode === "json_schema") {
mode =
options.capabilities?.forcedTool === "ok"
? "forced_tool"
: options.capabilities?.jsonObject === "ok"
? "json_object"
: "text";
}
const schemaName = options.name || "submit_result";
if (mode === "forced_tool") {
const strict =
!options.schemaIsLoose &&
(options.preferStrictTool ??
options.capabilities?.strictTool === "ok");
const tools = [buildSubmitTool(schemaName, options.schema, strict)];
const result = await llm.completeWithTools(messages, {
...options,
tools,
toolChoice: "required",
responseFormat: undefined,
jsonSchema: undefined,
});
const call =
result.toolCalls.find((t) => t.name === schemaName) ??
result.toolCalls[0];
if (!call) {
throw new Error("forced_tool 模式未返回 tool_calls");
}
const parsed = parseObjectPayload(call.arguments);
return {
content: call.arguments,
reasoning: result.reasoning,
usage: result.usage,
model: result.model,
parsed,
mode,
};
}
const completeOpts: CompleteOptions = {
...options,
responseFormat:
mode === "json_schema"
? "json_schema"
: mode === "json_object"
? "json_object"
: "text",
jsonSchema:
mode === "json_schema"
? { name: schemaName, strict: true, schema: options.schema }
: undefined,
};
let result: CompleteResult;
if (options.stream && llm.completeStream) {
result = await llm.completeStream(messages, completeOpts, options.stream);
} else {
result = await llm.complete(messages, completeOpts);
}
const parsed = parseObjectPayload(result.content);
return { ...result, parsed, mode };
}
+7
View File
@@ -46,6 +46,7 @@ const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
worker_questions: "执行单元提问",
revision: "修订",
next_intent: "下一步意向",
pick_creation_step: "点选节点",
};
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
@@ -56,9 +57,14 @@ const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
main_agent_decision_created: "总管决策",
user_approved_next_step: "确认下一步",
user_rejected_next_step: "拒绝下一步",
user_requested_flow_replan: "再编排",
user_picked_creation_step: "点选节点",
creation_step_pick_awaited: "等待点选节点",
user_left_creation_step: "返回节点选择",
worker_started: "执行单元开始",
worker_completed: "执行单元完成",
worker_needs_input: "执行单元提问",
worker_revision_produced_nothing: "修订无新产物",
user_resolved_sidecar_questions: "回答挂载追问",
user_accepted_artifact: "验收通过",
user_rejected_artifact: "打回产物",
@@ -78,6 +84,7 @@ const EFFECT_LABEL: Record<string, string> = {
emit_message: "发消息",
propose_next_creation_step: "提案下一步",
seal_creation_opening: "收口开场白",
unseal_creation_opening: "解开开场收口",
run_play_turn: "开游玩回合",
continue_play_turn: "续游玩管线",
};
+16 -15
View File
@@ -24,25 +24,26 @@ function buildMainAgentSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
- DAG 不存在或需要追加节点:调用 design-flow。
- DAG 中已有下一个可执行节点:调用 design-step。
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
${workerLines}
输出必须是 JSON 对象,字段:
{
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
"reason": "string",
"assessment": "string | null",
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null,
"workerId": "string | null",
"requiresApproval": boolean,
"workerContext": { "roleId": "string" } | null
}`;
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
}
/** 总管把推理写成正文、未给出可用工具/JSON 时,运行时改走点选或 design-flow。 */
export function isUnusableMainAgentOutput(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return (
/invalid JSON/i.test(msg) ||
/no tool calls and no content/i.test(msg) ||
/Invalid action/i.test(msg) ||
/must be an object/i.test(msg) ||
/requires reason/i.test(msg) ||
/multiple terminal tools/i.test(msg) ||
/Not a terminal tool/i.test(msg)
);
}
export class MainAgent {
+2 -7
View File
@@ -42,17 +42,12 @@ function buildToolLoopSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
当前 skill 可用 worker:
${workerLines}
选定节点后立即调用 run_worker。`;
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
}
function executeLoopTool(
+226
View File
@@ -0,0 +1,226 @@
/** 从模型正文里抽出 JSON:先按原文 parse,再宽松修,避免弯引号/围栏/夹叙把合法稿撕开。 */
export function stripCodeFences(text: string): string {
let t = String(text || "").trim();
t = t.replace(/^```(?:json|yaml|yml)?\s*\r?\n?/i, "");
t = t.replace(/\r?\n?```\s*$/i, "");
const fenced = t.match(/```(?:json|yaml|yml)?\s*\r?\n([\s\S]*?)\r?\n```/i);
if (fenced) t = fenced[1]!.trim();
return t.trim();
}
export function escapeRawControlsInJsonStrings(s: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (inString) {
if (escaped) {
out += c;
escaped = false;
continue;
}
if (c === "\\") {
out += c;
escaped = true;
continue;
}
if (c === '"') {
out += c;
inString = false;
continue;
}
if (c === "\n") {
out += "\\n";
continue;
}
if (c === "\r") {
out += "\\r";
continue;
}
if (c === "\t") {
out += "\\t";
continue;
}
out += c;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
out += c;
}
return out;
}
export function convertStructuralSmartQuotes(s: string): string {
const text = String(s || "");
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
const isOpen = c === "\u201C" || c === "\u201E" || c === "\u201F";
const isClose = c === "\u201D" || c === "\u2033" || c === "\u2036";
if (!isOpen && !isClose) {
out += c;
continue;
}
let prev = i - 1;
while (prev >= 0 && /\s/.test(text[prev]!)) prev -= 1;
let next = i + 1;
while (next < text.length && /\s/.test(text[next]!)) next += 1;
const prevCh = prev >= 0 ? text[prev]! : "";
const nextCh = next < text.length ? text[next]! : "";
const afterOpen = !prevCh || "{[:,[".includes(prevCh);
const beforeClose = !nextCh || ":,}]".includes(nextCh);
if (isOpen && afterOpen) out += '"';
else if (isClose && beforeClose) out += '"';
else out += c;
}
return out;
}
export function softenJsonText(s: string): string {
let t = String(s || "")
.replace(/^\uFEFF/, "")
.replace(/\u2026/g, "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
t = t.replace(/^\s*\/\/.*$/gm, "");
t = t.replace(/\/\*[\s\S]*?\*\//g, "");
t = escapeRawControlsInJsonStrings(t);
t = t.replace(/,\s*([\]}])/g, "$1");
return t;
}
export function repairTruncatedJsonObject(slice: string): string {
let s = String(slice || "").trim();
if (!s.startsWith("{")) return s;
s = s.replace(/,\s*"[^"]*$/u, "");
s = s.replace(/,\s*$/u, "");
const opens = (s.match(/\{/g) || []).length;
const closes = (s.match(/\}/g) || []).length;
const openBrackets = (s.match(/\[/g) || []).length;
const closeBrackets = (s.match(/\]/g) || []).length;
const quoteCount = (s.match(/(?<!\\)"/g) || []).length;
if (quoteCount % 2 === 1) s += '"';
if (openBrackets > closeBrackets) s += "]".repeat(openBrackets - closeBrackets);
if (opens > closes) s += "}".repeat(opens - closes);
return s;
}
function tryParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}
const PRODUCT_HINT_KEYS = [
"outputs",
"schema",
"正文",
"body",
"技能",
"workers",
"steps",
"brief",
"追问",
] as const;
export function looksLikeProductJson(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
return PRODUCT_HINT_KEYS.some((k) => k in row);
}
function parseSlice(slice: string): unknown {
return (
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(softenJsonText(convertStructuralSmartQuotes(slice))) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(slice)))
);
}
/** 思维链末尾常整段 JSON;从后往前找像产物的对象,避免吃进前面的举例 `{ }` */
export function extractLastProductJson(text: string): unknown {
const s = String(text || "");
const starts: number[] = [];
for (let i = s.length - 1; i >= 0 && starts.length < 8; i--) {
if (s[i] === "{") starts.push(i);
}
let fallback: unknown = null;
for (const start of starts) {
const slice = s.slice(start);
const end = slice.lastIndexOf("}");
if (end < 1) continue;
const parsed = parseSlice(slice.slice(0, end + 1));
if (parsed == null) continue;
if (looksLikeProductJson(parsed)) return parsed;
if (fallback == null) fallback = parsed;
}
return fallback;
}
/** 模型常把弯引号写进字符串;必须先按原文 parse,再做宽松修复 */
export function tryParseJsonDoc(text: string): unknown {
const stripped = stripCodeFences(text);
const attempts = [
stripped,
softenJsonText(stripped),
softenJsonText(convertStructuralSmartQuotes(stripped)),
];
for (const candidate of attempts) {
const parsed = tryParse(candidate);
if (parsed != null) return parsed;
}
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
) {
const inner = tryParse(cleaned);
if (typeof inner === "string") {
const parsed = tryParse(softenJsonText(inner)) ?? tryParse(inner);
if (parsed != null) return parsed;
}
}
const fromTail = extractLastProductJson(stripped) ?? extractLastProductJson(cleaned);
if (fromTail != null) return fromTail;
const arrStart = cleaned.indexOf("[");
const arrEnd = cleaned.lastIndexOf("]");
if (arrStart >= 0 && arrEnd > arrStart) {
const parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1));
if (parsed != null) return parsed;
}
return null;
}
/**
* 正文可能是闲聊/英文收束,JSON 在思维链里。
* 能 parse 的一边优先;两边都能 parse 时用更像产物的那边。
*/
export function selectJsonPayload(content: string, reasoning?: string): string {
const body = String(content ?? "").trim();
const think = String(reasoning ?? "").trim();
const fromBody = body ? tryParseJsonDoc(body) : null;
const fromThink = think ? tryParseJsonDoc(think) : null;
const bodyOk = fromBody != null && typeof fromBody === "object";
const thinkOk = fromThink != null && typeof fromThink === "object";
if (bodyOk && thinkOk) {
if (looksLikeProductJson(fromThink) && !looksLikeProductJson(fromBody)) {
return think;
}
return body;
}
if (bodyOk) return body;
if (thinkOk) return think;
return body || think;
}
+3
View File
@@ -160,6 +160,9 @@ export class RuntimeOrchestrator {
case "invoke_main_agent":
case "propose_next_creation_step":
case "seal_creation_opening":
case "unseal_creation_opening":
case "run_play_turn":
case "continue_play_turn":
break;
}
}
+167 -10
View File
@@ -35,6 +35,11 @@ import {
SLOT_CREATION_SEALED_BY_OPENING,
isOpeningSealArtifact,
} from "../skills/opening-seal.js";
import {
CREATION_PROPOSED_STEP_TAG,
DESIGN_FLOW_WORKER_ID,
DESIGN_STEP_WORKER_ID,
} from "../skills/creation-flow.js";
import { inferLifecycleStage } from "../skills/worker-declaration.js";
import type { QuestionItem } from "../types/questions.js";
@@ -146,6 +151,54 @@ function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined });
}
/** 回到流程编排:保留已验收步骤,让 design-flow 追加/改排节点 */
function applyFlowReplan(
session: RuntimeSession,
event: RuntimeEvent,
reason?: string,
): ApplyEventResult {
const slots = { ...session.slots };
const note = reason?.trim() ?? "";
if (note) {
slots["用户.下一步意向"] = note;
slots["用户.修订说明"] = note;
}
delete slots[CREATION_PROPOSED_STEP_TAG];
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
let artifacts = session.artifacts;
if (session.pendingArtifactId) {
artifacts = session.artifacts.map((a) =>
a.id === session.pendingArtifactId
? { ...a, status: "rejected" as const, updatedAt: nowIso() }
: a,
);
}
return {
session: touch(
running(
appendHistory(
{
...session,
slots,
artifacts,
pendingDecision: undefined,
pendingArtifactId: undefined,
currentWorkerId: undefined,
resumeContext: undefined,
},
event,
),
),
),
effects: [
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: DESIGN_FLOW_WORKER_ID },
],
};
}
/** 游玩连跑下一执行单元;创作则交总管 */
function afterPlayOrMainAgent(
session: RuntimeSession,
@@ -286,6 +339,7 @@ export function getAllowedEvents(
case "running":
return [
"main_agent_decision_created",
"creation_step_pick_awaited",
"worker_started",
"worker_completed",
"worker_needs_input",
@@ -303,6 +357,9 @@ export function getAllowedEvents(
"user_confirmed_intake",
"user_approved_next_step",
"user_rejected_next_step",
"user_requested_flow_replan",
"user_picked_creation_step",
"user_left_creation_step",
"user_accepted_artifact",
"user_rejected_artifact",
"user_requested_revision",
@@ -341,13 +398,34 @@ export function canApplyEvent(
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
reason?.kind === "revision" ||
reason?.kind === "next_intent"
reason?.kind === "next_intent" ||
reason?.kind === "pick_creation_step"
);
case "user_confirmed_intake":
return reason?.kind === "intake";
case "user_approved_next_step":
case "user_rejected_next_step":
return reason?.kind === "approve_step";
case "user_requested_flow_replan":
return (
reason?.kind === "approve_step" ||
reason?.kind === "review_artifact" ||
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
reason?.kind === "revision" ||
reason?.kind === "pick_creation_step"
);
case "user_picked_creation_step":
return reason?.kind === "pick_creation_step";
case "user_left_creation_step":
return (
reason?.kind === "approve_step" ||
reason?.kind === "worker_questions" ||
reason?.kind === "input" ||
reason?.kind === "revision"
);
case "creation_step_pick_awaited":
return session.phase === "running";
case "user_accepted_artifact":
case "user_rejected_artifact":
return reason?.kind === "review_artifact";
@@ -361,8 +439,12 @@ export function canApplyEvent(
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
);
case "main_agent_decision_created":
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
return session.phase === "running" || reason?.kind === "input";
// running 时可决策;waiting_user(input/pick) 时允许总管在启动阶段插话或点选后补参确认
return (
session.phase === "running" ||
reason?.kind === "input" ||
reason?.kind === "pick_creation_step"
);
default:
return true;
}
@@ -420,13 +502,20 @@ function handleWorkerCompleted(
if (mode === "no_confirmation") {
next = updateArtifact(next, artifact.id, { status: "accepted" });
return afterPlayOrMainAgent(session, {
const cleared = {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
});
};
if (artifact.workerId === DESIGN_FLOW_WORKER_ID) {
return {
session: touch(running(cleared)),
effects: [{ type: "propose_next_creation_step" }],
};
}
return afterPlayOrMainAgent(session, cleared);
}
// programmatic_review
@@ -618,6 +707,10 @@ export function applyEvent(
};
}
if (session.waitingReason?.kind === "pick_creation_step") {
return applyFlowReplan(session, event, text);
}
if (session.waitingReason?.kind === "revision") {
const instruction =
text.trim() || session.waitingReason.instruction?.trim() || "";
@@ -836,16 +929,81 @@ export function applyEvent(
}
case "user_rejected_next_step": {
return applyFlowReplan(session, event, event.payload.reason);
}
case "user_requested_flow_replan": {
return applyFlowReplan(session, event, event.payload.reason);
}
case "creation_step_pick_awaited": {
return {
session: waiting(
touch(
appendHistory(
{
...session,
currentWorkerId: undefined,
pendingDecision: undefined,
},
event,
),
),
{ kind: "pick_creation_step" },
),
effects: [],
};
}
case "user_left_creation_step": {
const slots = { ...session.slots };
delete slots[CREATION_PROPOSED_STEP_TAG];
return {
session: waiting(
touch(
appendHistory(
{
...session,
slots,
pendingDecision: undefined,
pendingArtifactId: undefined,
currentWorkerId: undefined,
currentStepId: undefined,
resumeContext: undefined,
},
event,
),
),
{ kind: "pick_creation_step" },
),
effects: [],
};
}
case "user_picked_creation_step": {
const stepId = event.payload.stepId.trim();
if (!stepId) {
return fail(session, "pick_creation_step requires stepId");
}
return {
session: touch(
running(
appendHistory(
{ ...session, pendingDecision: undefined },
{
...session,
currentWorkerId: DESIGN_STEP_WORKER_ID,
pendingDecision: undefined,
},
event,
),
),
),
effects: [{ type: "invoke_main_agent" }],
effects: [
{
type: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
},
],
};
}
@@ -1033,9 +1191,8 @@ export function applyEvent(
if (inferLifecycleStage(session) === "play") {
return afterPlayOrMainAgent(session, cleared);
}
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
// 创作步验收后停在分层图上等点选(可反复能力可当场新开一条)。
// 仅当没有任何可点节点时,propose 才会把控制权交回总管扩步。
const proposeNext =
artifact.workerId === "design-step" ||
artifact.workerId === "design-flow";
+653 -154
View File
@@ -17,7 +17,11 @@ import {
} from "./phase-machine.js";
import { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
import {
DEFAULT_WORKERS,
isUnusableMainAgentOutput,
MainAgent,
} from "../main-agent/main-agent.js";
import { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js";
import {
buildInstanceWorkerDeclaration,
@@ -58,14 +62,32 @@ import {
CREATION_FLOW_TAG,
CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_INHERIT_EXISTING_TAG,
CREATION_MODULE_OPENING_TAG,
CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog,
isReviseStep,
isStepAccepted,
isInheritExistingFlag,
mergeCreationFlowPreservingAccepted,
nextPendingStep,
listReadySteps,
listSelectablePrototypes,
listRepeatableSpawns,
isStepReady,
hasSelectableCreationWork,
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
removeUnstartedInstance,
isPrototypeStep,
isPrototypeSelectable,
isInstanceStep,
findStepByRef,
findModuleByName,
parseCreationFlow,
DESIGN_FLOW_WORKER_ID,
DESIGN_STEP_WORKER_ID,
parseModuleOpeningState,
patchCreationFlowStepParams,
pickRecordedStepId,
@@ -76,7 +98,10 @@ import {
stepUnitId,
stringifyCreationFlow,
stringifyModuleOpeningState,
finalizeCreationFlow,
type CreationFlow,
type CreationFlowStepParams,
type ModuleCatalog,
type ModuleCatalogEntry,
type ModuleParamSpec,
} from "../skills/creation-flow.js";
@@ -98,6 +123,7 @@ import {
OPENING_OUTPUT_TAG,
OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload,
reopenCreationFlowRaw,
SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js";
@@ -326,6 +352,56 @@ export class PhaseRuntime {
throw new Error("当前没有可重试的执行");
}
/** LLM/解析失败后离开 running,避免界面一直转圈。 */
async failRun(reason: string): Promise<RuntimeSession> {
if (this.session.phase !== "running") return this.session;
const result = this.commitEvent({
type: "runtime_failed",
payload: { reason },
});
await this.processEffects(result.effects);
return this.session;
}
/**
* 进程里留下的「执行中、没 worker」:配方已预置 DAG 时改回点选等待。
* 读档 / 轮询时调用,避免界面一直转圈。
*/
recoverOrphanedRun(): boolean {
if (this.session.phase !== "running" || this.session.waitingReason) {
return false;
}
if (this.session.currentWorkerId) return false;
if (!this.hasCreationFlow()) return false;
debugLog("step", "恢复中断的调度,等待点选节点");
this.clearProposedStep();
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
pendingDecision: undefined,
currentWorkerId: undefined,
updatedAt: new Date().toISOString(),
};
return true;
}
/**
* 配方已预置 DAG:跳过「先描述需求」,直接进入点选图。
* 图上节点来自当前剧本 seed,换配方即换开局。
*/
async enterSeededCreationPick(): Promise<void> {
if (!this.hasCreationFlow()) return;
this.session = {
...this.session,
slots: {
...this.session.slots,
startupCompleted: true,
},
};
await this.awaitCreationStepPick();
}
/** 重 roll 指定 worker(刷新 Skill 回复) */
async rerunWorker(workerId: string): Promise<RuntimeSession> {
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
@@ -595,7 +671,7 @@ export class PhaseRuntime {
return this.session;
}
/** 用户拒绝 pendingDecision,回到总管 */
/** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */
async rejectStep(reason?: string): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待拒绝的决策");
@@ -606,6 +682,180 @@ export class PhaseRuntime {
return this.session;
}
/**
* 离开当前技能步,回到分层图。
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
*/
async leaveCreationStep(): Promise<RuntimeSession> {
const waiting = this.session.waitingReason?.kind;
const allowed =
waiting === "worker_questions" ||
waiting === "input" ||
waiting === "revision" ||
waiting === "approve_step";
if (!allowed) {
throw new Error("当前不能返回节点选择");
}
if (inferLifecycleStage(this.session) === "play") {
throw new Error("游玩中不能返回创作节点");
}
const stepId = this.readPinnedStepId();
const removed = this.unspawnCurrentInstanceIfEmpty();
this.clearProposedStep();
this.clearCurrentStepPointers();
const result = await this.dispatch({
type: "user_left_creation_step",
payload: {},
});
if (result.error) {
throw new Error(result.error);
}
const stepName = removed?.name?.trim();
this.onMessage(
removed
? `已取消刚增殖的「${stepName || stepId}」,回到节点选择。`
: "已回到节点选择。",
);
return this.session;
}
private unspawnCurrentInstanceIfEmpty(): { id: string; name: string } | null {
const stepId = this.readPinnedStepId();
if (!stepId) return null;
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return null;
const step = findStepByRef(flow, stepId);
if (!step || !isInstanceStep(step)) return null;
const accepted = this.readAcceptedStepIds();
if (isStepAccepted(step, accepted)) return null;
if (
this.session.artifacts.some(
(a) => a.stepId === step.id && a.status !== "rejected",
)
) {
return null;
}
const next = removeUnstartedInstance(flow, step.id, accepted);
if (next.steps.length === flow.steps.length) return null;
this.writeCreationFlow(next);
debugLog("step", `取消未完成实例 ${step.id}`);
return { id: step.id, name: step.name };
}
private clearCurrentStepPointers(): void {
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
content: "",
source: "runtime",
});
this.blackboard.write({
tag: CREATION_INHERIT_EXISTING_TAG,
content: "",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_CURRENT_STEP_TAG]: undefined,
[CREATION_INHERIT_EXISTING_TAG]: undefined,
[SLOT_CREATION_CURRENT_UNIT]: undefined,
},
};
}
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
async replanCreationFlow(reason?: string): Promise<RuntimeSession> {
const waiting = this.session.waitingReason?.kind;
if (waiting === "approve_step") {
return this.rejectStep(reason);
}
await this.dispatch({
type: "user_requested_flow_replan",
payload: { reason },
});
return this.session;
}
/** 图上点选:普通节点开干;原型槽位先增殖再进去;已完成节点可 reenter 重进 */
async pickCreationStep(
stepId: string,
stepParams?: CreationFlowStepParams,
opts?: { reenter?: boolean },
): Promise<RuntimeSession> {
const id = stepId.trim();
this.syncSlotsToBlackboard(this.session);
const catalog = await this.loadActiveModuleCatalog();
const flow = this.readNormalizedFlow(catalog);
const step = flow ? findStepByRef(flow, id) : null;
if (flow && step && isPrototypeStep(step)) {
if (opts?.reenter) {
throw new Error("原型槽位不能回头修改,请点已完成的实例");
}
const spawned = spawnInstanceFromPrototype({
flow,
catalog,
prototypeId: step.id,
acceptedStepIds: this.readAcceptedStepIds(),
});
if ("error" in spawned) throw new Error(spawned.error);
this.writeCreationFlow(spawned.flow);
debugLog("step", `点原型 ${step.id} → ${spawned.step.id}`);
await this.startCreationStep(spawned.step.id, { stepParams });
return this.session;
}
await this.startCreationStep(id, {
stepParams,
reenter: opts?.reenter === true,
});
return this.session;
}
/** 可反复能力:新编一条再进去 */
async spawnCreationStep(moduleName: string): Promise<RuntimeSession> {
this.syncSlotsToBlackboard(this.session);
const catalog = await this.loadActiveModuleCatalog();
const flow = this.readNormalizedFlow(catalog);
if (!flow) throw new Error("还没有工作流计划,无法追加节点");
const spawned = spawnRepeatableCreationStep({
flow,
catalog,
moduleName,
acceptedStepIds: this.readAcceptedStepIds(),
});
if ("error" in spawned) throw new Error(spawned.error);
this.writeCreationFlow(spawned.flow);
debugLog("step", `新开 ${spawned.step.id}(${spawned.step.name})`);
await this.startCreationStep(spawned.step.id);
return this.session;
}
private writeCreationFlow(flow: CreationFlow): void {
const content = stringifyCreationFlow(flow);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
}
private readNormalizedFlow(catalog: ModuleCatalog | null): CreationFlow | null {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return null;
const next = finalizeCreationFlow(flow, catalog);
const changed = JSON.stringify(next.steps) !== JSON.stringify(flow.steps);
if (changed) this.writeCreationFlow(next);
return next;
}
/** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */
private persistInstanceWorkerDeclaration(): void {
const active = this.getActiveSkill();
@@ -857,6 +1107,13 @@ export class PhaseRuntime {
}
break;
case "run_worker":
if (
effect.workerId === DESIGN_STEP_WORKER_ID &&
!this.hasPinnedReadyStep()
) {
await this.awaitCreationStepPick();
break;
}
if (this.autoStubWorker) {
await this.runStubWorker(effect);
} else if (this.llm) {
@@ -894,6 +1151,9 @@ export class PhaseRuntime {
case "seal_creation_opening":
this.sealCreationOpening();
break;
case "unseal_creation_opening":
this.unsealCreationOpening();
break;
case "run_play_turn":
await this.startPlayTurn();
break;
@@ -905,9 +1165,243 @@ export class PhaseRuntime {
}
/**
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
* 用户填完「下一步想写什么」后:有可点节点则停在分层图;程序步自动开干;否则交总管扩步。
*/
private async proposeNextCreationStep(): Promise<void> {
await this.awaitCreationStepPick();
}
private readAcceptedStepIds(): string[] {
return parseAcceptedUnits(
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
}
private async loadActiveModuleCatalog(): Promise<ModuleCatalog | null> {
const active = this.getActiveSkill();
if (!active?.name) return null;
try {
const skill = await loadSkill(active.name);
if (!skill.skillPackRoot) return null;
return await loadModuleCatalog(skill.skillPackRoot);
} catch {
return null;
}
}
private readPinnedStepId(): string {
const fromCurrent = this.blackboard
.getContentByTag(CREATION_CURRENT_STEP_TAG)
?.trim();
if (fromCurrent) return fromCurrent;
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
: null) || this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
if (!raw?.trim()) return "";
try {
const doc = JSON.parse(raw) as { stepId?: unknown };
return typeof doc.stepId === "string" ? doc.stepId.trim() : "";
} catch {
return "";
}
}
private hasPinnedReadyStep(): boolean {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return false;
const step = findStepByRef(flow, this.readPinnedStepId());
if (!step) return false;
return isStepReady(flow, step, this.readAcceptedStepIds());
}
private pinCreationStepSnapshot(params: {
stepId: string;
name: string;
declaration: string;
params: Record<string, unknown>;
paramsMissing: string[];
paramSpecs: ModuleParamSpec[];
kind?: ModuleCatalogEntry["kind"];
closer?: boolean;
revise: boolean;
revises?: string;
}): void {
const intent =
typeof this.session.slots["用户.下一步意向"] === "string"
? String(this.session.slots["用户.下一步意向"]).trim()
: "";
const snapshot = {
stepId: params.stepId,
name: params.name,
declaration: params.declaration || undefined,
params: params.params,
paramsMissing: params.paramsMissing,
paramSpecs: params.paramSpecs.map((p) => ({
key: p.key,
label: p.label,
required: Boolean(p.required),
hint: p.hint,
})),
intent: intent || undefined,
...(params.kind ? { kind: params.kind } : {}),
...(params.closer ? { closer: true } : {}),
...(params.revise ? { mode: "revise" as const } : {}),
...(params.revises ? { revises: params.revises } : {}),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
content: params.stepId,
source: "runtime",
});
this.blackboard.write({
tag: CREATION_INHERIT_EXISTING_TAG,
content: params.revise ? "1" : "",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
[CREATION_CURRENT_STEP_TAG]: params.stepId,
[CREATION_INHERIT_EXISTING_TAG]: params.revise ? "1" : "",
},
};
}
private async startCreationStep(
stepId: string,
opts?: { stepParams?: CreationFlowStepParams; reenter?: boolean },
): Promise<void> {
this.syncSlotsToBlackboard(this.session);
let flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) throw new Error("还没有工作流计划");
let step = findStepByRef(flow, stepId);
if (!step) throw new Error(`找不到节点:${stepId}`);
const accepted = this.readAcceptedStepIds();
const catalog = await this.loadActiveModuleCatalog();
const reenter = opts?.reenter === true;
if (isPrototypeStep(step)) {
if (!isPrototypeSelectable(flow, step, accepted)) {
throw new Error(`「${step.name}」的前置还没齐,不能增殖`);
}
const spawned = spawnInstanceFromPrototype({
flow,
catalog,
prototypeId: step.id,
acceptedStepIds: accepted,
});
if ("error" in spawned) throw new Error(spawned.error);
const content = stringifyCreationFlow(spawned.flow);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
flow = spawned.flow;
step = spawned.step;
} else if (reenter) {
if (!isStepAccepted(step, accepted)) {
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
}
} else if (!isStepReady(flow, step, accepted)) {
throw new Error(`「${step.name}」的前置节点还没完成,不能开始`);
}
const pendingModule = findModuleByName(catalog, step.name);
const paramSpecs = pendingModule?.params ?? [];
const auto = pendingModule?.auto === true;
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
const next = patchCreationFlowStepParams(flow, step.id, opts.stepParams);
const content = stringifyCreationFlow(next);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
}
const latest = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const pinned = latest ? findStepByRef(latest, step.id) ?? step : step;
const params = pinned.params ? { ...pinned.params } : {};
const paramsMissing = missingRequiredStepParams(pinned, pendingModule);
this.pinCreationStepSnapshot({
stepId: pinned.id,
name: pinned.name,
declaration: pendingModule?.declaration?.trim() || "",
params,
paramsMissing,
paramSpecs,
kind: pendingModule?.kind,
closer: pendingModule?.closer === true,
revise: reenter || isReviseStep(pinned),
revises: pinned.revises,
});
const waitingPick =
this.session.waitingReason?.kind === "pick_creation_step";
const needsParamForm = paramsMissing.length > 0 && !auto;
if (needsParamForm) {
const decision = createDecision({
action: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
reason: `「${pinned.name}」还缺参数:${paramsMissing.join("、")}`,
requiresApproval: true,
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
return;
}
if (waitingPick) {
await this.dispatch({
type: "user_picked_creation_step",
payload: { stepId: pinned.id },
});
return;
}
const decision = createDecision({
action: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
reason: reenter || isReviseStep(pinned)
? `回头修改 · ${pinned.name}`
: `开始:${pinned.name}`,
requiresApproval: false,
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
}
/**
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
*/
private async awaitCreationStepPick(): Promise<void> {
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
return;
@@ -916,121 +1410,67 @@ export class PhaseRuntime {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const accepted = parseAcceptedUnits(
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
const pending = nextPendingStep(flow, accepted);
if (!pending || !flow) {
debugLog("step", "暂无下一步,交给总管");
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: undefined,
},
};
if (this.mainAgent) {
await this.runMainAgent();
} else {
this.onMessage(
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
);
}
const accepted = this.readAcceptedStepIds();
if (!flow) {
debugLog("step", "暂无可用流程,等待点选或改编排");
await this.enterCreationStepPickWait();
return;
}
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);
if (skill.skillPackRoot) {
const catalog = await loadModuleCatalog(skill.skillPackRoot);
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 */
const catalog = await this.loadActiveModuleCatalog();
const ready = listReadySteps(flow, accepted);
const autoReady = ready.filter((step) => {
const mod = findModuleByName(catalog, step.name);
return (
mod?.auto === true &&
missingRequiredStepParams(step, mod).length === 0
);
});
if (autoReady.length > 0 && autoReady.length === ready.length) {
const protoReady = listSelectablePrototypes(flow, accepted).length > 0;
const spawnReady = catalog
? listRepeatableSpawns(flow, catalog, accepted).some((s) => s.ready)
: false;
if (!protoReady && !spawnReady) {
debugLog("step", `程序步直接开始 ${autoReady[0]!.id}`);
await this.startCreationStep(autoReady[0]!.id);
return;
}
}
debugLog(
"step",
hasSelectableCreationWork(flow, catalog, accepted)
? "等待点选创作节点"
: "图上暂无已就绪节点,点节点进入或在底栏写意见改编排",
);
await this.enterCreationStepPickWait();
}
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
const params = pending.params ? { ...pending.params } : {};
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
const intent =
typeof this.session.slots["用户.下一步意向"] === "string"
? String(this.session.slots["用户.下一步意向"]).trim()
: "";
const paramHint =
Object.keys(params).length > 0
? `;参数 ${Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join(" · ")}`
: paramsMissing.length
? `;待钉参数:${paramsMissing.join("、")}`
: "";
const revise = isReviseStep(pending);
const reason = [
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`,
declaration ? `—— ${declaration}` : "",
paramHint,
intent ? `(你的意向:${intent})` : "",
]
.filter(Boolean)
.join("");
const snapshot = {
stepId: pending.id,
name: pending.name,
declaration: declaration || undefined,
params,
paramsMissing,
paramSpecs: paramSpecs.map((p) => ({
key: p.key,
label: p.label,
required: Boolean(p.required),
hint: p.hint,
})),
intent: intent || undefined,
...(kind ? { kind } : {}),
...(revise ? { mode: "revise" as const } : {}),
...(pending.revises ? { revises: pending.revises } : {}),
private async enterCreationStepPickWait(): Promise<void> {
this.clearProposedStep();
if (this.session.waitingReason?.kind === "pick_creation_step") return;
if (this.session.phase === "running") {
await this.dispatch({ type: "creation_step_pick_awaited", payload: {} });
return;
}
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
pendingDecision: undefined,
currentWorkerId: undefined,
updatedAt: new Date().toISOString(),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
}
private clearProposedStep(): void {
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
[CREATION_PROPOSED_STEP_TAG]: undefined,
},
};
const decision = createDecision({
action: "run_worker",
workerId: "design-step",
reason,
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
requiresApproval: !(auto && paramsMissing.length === 0),
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
}
/**
@@ -1108,6 +1548,24 @@ export class PhaseRuntime {
};
}
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
private unsealCreationOpening(): void {
const reopened = reopenCreationFlowRaw(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (reopened) {
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content: reopened,
source: "runtime",
});
}
const slots = { ...this.session.slots };
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
if (reopened) slots[CREATION_FLOW_TAG] = reopened;
this.session = { ...this.session, slots };
}
private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
@@ -1807,6 +2265,10 @@ export class PhaseRuntime {
flowRaw,
currentStepName,
acceptedStepNames: accepted,
inheritExisting: isInheritExistingFlag(
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
),
});
if (!binding?.opening?.trim()) return false;
@@ -1952,7 +2414,9 @@ export class PhaseRuntime {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const pending = nextPendingStep(flow, accepted);
const pinnedId = this.readPinnedStepId();
const pinned = flow && pinnedId ? findStepByRef(flow, pinnedId) : null;
const pending = pinned ?? nextPendingStep(flow, accepted);
current = pending ? stepUnitId(pending) : current || "(无待执行步骤)";
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
@@ -2209,65 +2673,100 @@ export class PhaseRuntime {
}
}
private hasCreationFlow(): boolean {
return Boolean(
parseCreationFlow(this.blackboard.getContentByTag(CREATION_FLOW_TAG)),
);
}
private async commitMainAgentDecision(
decision: MainAgentDecision,
): Promise<void> {
debugLog(
"step",
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = this.commitEvent({
type: "main_agent_decision_created",
payload: { decision },
});
if (result.error) this.onMessage(result.error);
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
}
private async runMainAgent(): Promise<void> {
if (!this.mainAgent || this.session.phase !== "running") {
return;
}
if (this.hasCreationFlow()) {
debugLog("step", "已有创作流程,等待点选节点");
await this.awaitCreationStepPick();
return;
}
debugLog("step", "总管开始");
const availableWorkers = await this.resolveAvailableWorkers();
const decision = await this.mainAgent.runToolLoop(
{
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers,
},
{
handlers: {
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
listWorkers: () => availableWorkers,
listArtifacts: () =>
this.session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
onToolCall: (name, detail) => {
const preview =
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
},
onThinkingDelta: (delta) => {
this.onAgentThinkingDelta?.(delta);
},
onThinkingDone: (text) => {
this.onAgentThinkingDone?.(text);
this.onMessage(`[总管 思考]\n\n${text}`);
try {
const decision = await this.mainAgent.runToolLoop(
{
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers,
},
{
handlers: {
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
listWorkers: () => availableWorkers,
listArtifacts: () =>
this.session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
onToolCall: (name, detail) => {
const preview =
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
},
onThinkingDelta: (delta) => {
this.onAgentThinkingDelta?.(delta);
},
onThinkingDone: (text) => {
this.onAgentThinkingDone?.(text);
this.onMessage(`[总管 思考]\n\n${text}`);
},
},
},
},
);
debugLog(
"step",
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = this.commitEvent({
type: "main_agent_decision_created",
payload: { decision },
});
if (result.error) {
this.onMessage(result.error);
);
await this.commitMainAgentDecision(decision);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (this.hasCreationFlow()) {
debugLog("step", "总管无可用决策,改为等待点选");
await this.awaitCreationStepPick();
return;
}
if (isUnusableMainAgentOutput(err)) {
debugLog("step", "总管未给出工具调用,改为启动 design-flow");
await this.commitMainAgentDecision(
createDecision({
action: "run_worker",
workerId: DESIGN_FLOW_WORKER_ID,
reason: "尚无创作流程,开始编排近期起点",
requiresApproval: false,
}),
);
return;
}
debugLog("step", `总管失败 ${reason.slice(0, 120)}`);
await this.failRun(reason);
}
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
}
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
+17
View File
@@ -435,6 +435,15 @@ export function buildFocus(
};
}
if (reason?.kind === "pick_creation_step") {
return {
actorType: "user",
actorLabel: "你",
action: "点选要做的节点",
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
};
}
if (reason?.kind === "next_intent") {
return {
actorType: "user",
@@ -471,6 +480,14 @@ export function buildFocus(
if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
if (art?.workerId === "design-flow") {
return {
actorType: "user",
actorLabel: "你",
action: "点选要做的节点",
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
};
}
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
outputTags: art?.outputTags,
+13 -2
View File
@@ -123,6 +123,17 @@ export function formatAgentDisplayTitle(detail?: string): string {
return "编排器";
}
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
export function isFlowPlanReview(view: {
waitingReason?: { kind?: string } | null;
reviewArtifact?: { workerId?: string | null } | null;
} | null | undefined): boolean {
return (
view?.waitingReason?.kind === "review_artifact" &&
view?.reviewArtifact?.workerId === "design-flow"
);
}
/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
export type ReviewComposerCopy = {
/** 底栏短芯片 */
@@ -179,8 +190,8 @@ export function reviewComposerCopy(
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
: "同意就选定此开场;要改开场白就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
+324 -32
View File
@@ -81,8 +81,10 @@ import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
isDesignDiskWorker,
parseAcceptedContentStore,
parseAcceptedUnits,
SLOT_CREATION_ACCEPTED_UNITS,
} from "../skills/creation-units.js";
@@ -104,18 +106,26 @@ import {
CREATION_FLOW_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG,
DESIGN_FLOW_WORKER_ID,
creationFlowFromRecipeSeed,
findRecipeCatalogEntry,
formatCreationFlowForUser,
findModuleByName,
findStepByRef,
type FlowStepTitleHint,
isProgressPointerTag,
loadRecipeCatalog,
loadRecipeDetail,
loadModuleCatalog,
parseCreationFlow,
parseSelectedRecipeRef,
resolveDesignStepBinding,
stepUnitId,
stringifyCreationFlow,
stripReviseSteps,
type CreationFlow,
type CreationFlowUserView,
type ModuleCatalog,
type RecipeCatalogEntry,
} from "../skills/creation-flow.js";
import { loadSkill } from "../skills/loader.js";
@@ -260,6 +270,8 @@ export type SessionAction =
| { type: "approve"; label: string }
| { type: "accept"; label: string }
| { type: "reject"; label: string }
| { type: "replan"; label: string }
| { type: "leave_step"; label: string }
| { type: "run_outline"; label: string }
| { type: "finish"; label: string };
@@ -297,6 +309,8 @@ export type ProposedNextStepView = {
/** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise";
revises?: string;
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
closer?: boolean;
};
export type ReviewArtifactView = {
@@ -325,6 +339,8 @@ type ManagedSession = {
branchState: MessageBranchState;
/** 当前能力包可选初始配方(用户手动选) */
recipeOptions?: RecipeCatalogEntry[];
/** 模块目录(分层图 / 可反复增殖) */
moduleCatalog?: ModuleCatalog | null;
/** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */
@@ -540,6 +556,7 @@ export class SessionManager {
if (bookId) {
this.activeBookSessions.set(bookId, id);
appendBookSession(bookId, id);
this.persist(managed);
}
return this.toView(id);
}
@@ -557,7 +574,9 @@ export class SessionManager {
}
get(id: string): SessionView | null {
if (!this.sessions.has(id)) return null;
const s = this.sessions.get(id);
if (!s) return null;
if (s.runtime.recoverOrphanedRun()) this.persist(s);
return this.toView(id);
}
@@ -825,7 +844,8 @@ export class SessionManager {
kind !== "review_artifact" &&
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent"
kind !== "next_intent" &&
kind !== "pick_creation_step"
) {
return;
}
@@ -1079,8 +1099,7 @@ export class SessionManager {
} 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);
await this.recoverFailedRun(s, detail);
}
this.syncCreationDialogue(s);
this.persist(s);
@@ -1315,8 +1334,7 @@ export class SessionManager {
} 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);
await this.recoverFailedRun(s, detail);
}
this.syncCreationDialogue(s);
this.persist(s);
@@ -1462,7 +1480,7 @@ export class SessionManager {
try {
const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
await s.runtime.leaveCreationStep();
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
@@ -1476,9 +1494,174 @@ export class SessionManager {
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 leaveStep(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.leaveCreationStep(),
);
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 replan(id: string, reason?: string): Promise<SessionView> {
const s = this.require(id);
const note = reason?.trim() || "回到流程编排,追加或改排节点。";
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(this.msg("user", note));
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.replanCreationFlow(note),
);
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 pickStep(
id: string,
stepId: string,
stepParams?: Record<string, unknown>,
reenter?: boolean,
): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.pickCreationStep(stepId, stepParams, {
reenter: reenter === true,
});
});
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);
}
async getStepArtifact(
id: string,
stepId: string,
): Promise<{
stepId: string;
name: string;
tag: string | null;
content: string;
}> {
const s = this.require(id);
const catalog = s.moduleCatalog;
const flow = parseCreationFlow(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) throw new Error("还没有工作流计划");
const cleaned = stripReviseSteps(flow);
const step = findStepByRef(cleaned, stepId.trim());
if (!step) throw new Error(`找不到节点:${stepId}`);
const mod = findModuleByName(catalog, step.name);
const tag = mod?.artifact?.trim() || null;
const content = tag
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
: "";
return { stepId: step.id, name: step.name, tag, content };
}
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是追加节点的时机");
}
await s.runtime.spawnCreationStep(moduleName);
});
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);
}
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
private canOfferLeaveCreationStep(
s: ManagedSession,
reason: WaitingReason | undefined,
): boolean {
const session = s.runtime.getSession();
if (
isPlayLayerActive(session.slots) ||
inferLifecycleStage(session) === "play"
) {
return false;
}
const kind = reason?.kind;
if (
kind !== "worker_questions" &&
kind !== "input" &&
kind !== "revision"
) {
return false;
}
const stepId =
s.runtime.getBlackboard().getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() ||
session.currentStepId?.trim() ||
"";
return Boolean(stepId);
}
/** 流程编排还停在核对态时,点节点先收下计划再进入 */
private async confirmPendingFlowPlan(s: ManagedSession): Promise<void> {
const session = s.runtime.getSession();
if (session.waitingReason?.kind !== "review_artifact") return;
const art = session.pendingArtifactId
? session.artifacts.find((a) => a.id === session.pendingArtifactId)
: undefined;
if (art?.workerId !== DESIGN_FLOW_WORKER_ID) {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.acceptArtifact();
}
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
try {
@@ -1586,6 +1769,7 @@ export class SessionManager {
this.activeBookSessions.set(params.bookId, params.sessionId);
appendBookSession(params.bookId, params.sessionId);
await this.refreshRecipeOptions(managedRef);
runtime.recoverOrphanedRun();
this.persist(managedRef);
await this.maybeKickAgentAfterDemand(runtime);
@@ -1891,6 +2075,20 @@ export class SessionManager {
}
}
/** 执行失败后离开 running,避免界面一直转圈。 */
private async recoverFailedRun(
s: ManagedSession,
detail: string,
): Promise<void> {
const formatted = formatRuntimeError(detail);
if (s.runtime.getSession().phase === "running") {
await s.runtime.failRun(formatted);
} else {
s.messages.push(this.msg("system", formatted));
}
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
@@ -2202,11 +2400,21 @@ export class SessionManager {
} else {
actions.push({ type: "approve", label: "确认执行" });
}
actions.push({ type: "reject", label: "暂不执行" });
actions.push({ type: "reject", label: "返回节点" });
actions.push({
type: "send_message",
label: "说明意见",
placeholder: "改排、回头改某步,或说明不要这一步…",
placeholder: "改排、补节点(如 NPC)、回头改某步,或说明不要这一步…",
});
} else if (reason?.kind === "pick_creation_step") {
hints.push(
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({ type: "replan", label: "发送" });
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
@@ -2220,15 +2428,30 @@ export class SessionManager {
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
});
actions.push({ type: "accept", label: copy.acceptLabel });
actions.push({ type: "reject", label: "不接受" });
actions.push({
type: "send_message",
label: copy.submitLabel,
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
});
if (art?.workerId === DESIGN_FLOW_WORKER_ID) {
hints.push(
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else {
actions.push({ type: "accept", label: copy.acceptLabel });
actions.push({ type: "reject", label: "不接受" });
actions.push({
type: "send_message",
label: copy.submitLabel,
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
});
}
if (reason.questions?.length) {
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
hints.push(
art?.workerId === DESIGN_FLOW_WORKER_ID
? "下方追问可选;点节点进入即收起"
: `下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`,
);
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
}
@@ -2281,6 +2504,10 @@ export class SessionManager {
});
}
if (this.canOfferLeaveCreationStep(s, reason)) {
actions.push({ type: "leave_step", label: "返回节点" });
}
if (s.bookId) {
this.persist(s);
}
@@ -2330,7 +2557,12 @@ export class SessionManager {
effectiveStartupMode(activeSnap) === "agent-first"
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
: undefined,
reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()),
reviewArtifact: buildReviewArtifactView(
session,
messages,
s.runtime.getBlackboard(),
s.moduleCatalog,
),
workerSetView: buildWorkerSetUserView(
s.runtime.getBlackboard(),
s.runtime.getSession(),
@@ -2338,6 +2570,7 @@ export class SessionManager {
creationFlowView: buildCreationFlowUserView(
s.runtime.getBlackboard(),
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
s.moduleCatalog,
),
proposedNextStep:
reason?.kind === "approve_step"
@@ -2448,6 +2681,7 @@ export class SessionManager {
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
@@ -2455,13 +2689,19 @@ export class SessionManager {
const skill = await loadSkill(skillName);
if (!skill.skillPackRoot) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
const [catalog, modules] = await Promise.all([
loadRecipeCatalog(skill.skillPackRoot),
loadModuleCatalog(skill.skillPackRoot),
]);
s.recipeOptions = catalog?.recipes ?? [];
s.moduleCatalog = modules;
} catch {
s.recipeOptions = [];
s.moduleCatalog = null;
}
await this.ensureRecipeSeededFlow(s);
await this.refreshOpeningGuide(s);
@@ -2489,11 +2729,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (!seeded) return;
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
await this.commitSeededCreationFlow(s, seeded);
} catch (err) {
console.error("[会话] 配方预置失败", err);
}
@@ -2553,6 +2789,7 @@ export class SessionManager {
s: ManagedSession,
reason: WaitingReason | undefined,
): { stepId: string; stepName: string; text: string } | null {
if (reason?.kind === "pick_creation_step") return null;
if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
@@ -2584,6 +2821,44 @@ export class SessionManager {
return s.openingGuide ?? null;
}
/** 写入剧本 seed,并立刻进入点选图(开局节点跟配方走)。 */
private async commitSeededCreationFlow(
s: ManagedSession,
seeded: CreationFlow,
): Promise<void> {
this.writeSeededCreationFlow(s, seeded);
await s.runtime.enterSeededCreationPick();
}
/** 配方预置 DAG:写入黑板并留下可回退的编排检查点(不必先跑 LLM) */
private writeSeededCreationFlow(s: ManagedSession, seeded: CreationFlow): void {
s.runtime.getBlackboard().write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
const alreadyRecorded = s.messages.some(
(m) =>
m.kind === "worker_output" &&
m.actor === "design-flow" &&
(m.text ?? "").includes("配方预置近期起点"),
);
if (alreadyRecorded) return;
const names = seeded.steps.map((step) => step.name).join(" → ");
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(
this.msg(
"system",
`[Worker] design-flow 已完成\n\n配方预置近期起点${names ? `:${names}` : ""}。\n这是可回退的编排检查点;之后可再编排追加节点(如生成规则 / 具体实例)。`,
),
);
this.syncCreationDialogue(s);
}
private async applySelectedRecipe(
s: ManagedSession,
recipeId: string,
@@ -2606,7 +2881,7 @@ export class SessionManager {
source: "user",
});
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow)
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!existingFlow) {
const skillName = s.runtime.getActiveSkill()?.name;
@@ -2618,11 +2893,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (seeded) {
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
await this.commitSeededCreationFlow(s, seeded);
}
}
} catch (err) {
@@ -2652,6 +2923,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
function buildCreationFlowUserView(
blackboard: Blackboard,
acceptedRaw?: unknown,
catalog?: ModuleCatalog | null,
): CreationFlowUserView | undefined {
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!raw) return undefined;
@@ -2662,7 +2934,24 @@ function buildCreationFlowUserView(
const accepted = parseAcceptedUnits(
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
return formatCreationFlowForUser(flow, undefined, accepted);
const titleHints: Record<string, FlowStepTitleHint> = {};
const store = parseAcceptedContentStore(
blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
);
for (const [id, entry] of Object.entries(store.units)) {
if (!entry.summary && entry.content == null) continue;
titleHints[id] = {
...(entry.summary ? { summary: entry.summary } : {}),
...(entry.content != null ? { content: entry.content } : {}),
};
}
return formatCreationFlowForUser(
flow,
catalog,
accepted,
collectFilledTags(blackboard),
titleHints,
);
}
function readProposedNextStep(
@@ -2708,6 +2997,7 @@ function readProposedNextStep(
intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined,
closer: doc.closer === true ? true : undefined,
};
} catch {
return undefined;
@@ -2767,6 +3057,7 @@ function buildReviewArtifactView(
session: RuntimeSession,
messages: ChatMessage[],
blackboard: Blackboard,
catalog?: ModuleCatalog | null,
): ReviewArtifactView | undefined {
if (session.waitingReason?.kind !== "review_artifact") return undefined;
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
@@ -2869,6 +3160,7 @@ function buildReviewArtifactView(
? buildCreationFlowUserView(
blackboard,
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
catalog,
)
: undefined;
@@ -2896,7 +3188,7 @@ function formatRuntimeError(detail: string): string {
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
return (
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
"或点击「测试连接」验证。"
"或点击「探测」验证。"
);
}
if (detail.startsWith("LLM request failed")) {
+38 -11
View File
@@ -4,9 +4,13 @@ import {
deleteApiProfile,
getApiProfile,
listApiProfiles,
testApiProfile,
probeAndSaveProfileCapabilities,
updateApiProfile,
} from "../config/api-profiles.js";
import {
deliveryModeLabel,
resolveStructuredDeliveryMode,
} from "../llm/capabilities.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
@@ -120,12 +124,14 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
reasoningEffort?: string;
};
const profile = createApiProfile({
name: body.name ?? "新配置",
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
apiKey: body.apiKey ?? "",
model: body.model ?? "deepseek-v4-pro",
reasoningEffort: body.reasoningEffort,
});
const activate = (body as { activate?: boolean }).activate === true;
if (activate) {
@@ -136,19 +142,34 @@ export async function handleSettingsApi(
return true;
}
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
const profileMatch = pathname.match(
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
);
if (profileMatch) {
const id = decodeURIComponent(profileMatch[1]);
const isTest = profileMatch[2] === "/test";
const suffix = profileMatch[2];
if (isTest && req.method === "POST") {
const profile = getApiProfile(id);
if (!profile) {
json(res, 404, { error: "配置不存在" });
return true;
if (suffix === "/probe-capabilities" && req.method === "POST") {
try {
const profile = await probeAndSaveProfileCapabilities(id);
const mode = resolveStructuredDeliveryMode(profile.capabilities);
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, {
ok: profile.capabilities?.chat === "ok",
profile,
deliveryMode: mode,
deliveryModeLabel: deliveryModeLabel(mode),
reloadedSessions,
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "能力探测失败",
});
}
const result = await testApiProfile(profile);
json(res, 200, result);
return true;
}
@@ -168,10 +189,16 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
reasoningEffort?: string;
};
try {
const profile = updateApiProfile(id, body);
json(res, 200, { profile });
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, { profile, reloadedSessions });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
+41
View File
@@ -123,6 +123,18 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "GET" && sub === "/step-artifact") {
try {
const stepId = url.searchParams.get("stepId") ?? "";
const data = await sessionManager.getStepArtifact(sessionId, stepId);
json(res, 200, data);
} catch (err) {
const msg = err instanceof Error ? err.message : "读取失败";
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
}
return;
}
if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as {
text?: string;
@@ -445,6 +457,10 @@ const server = createServer(async (req, res) => {
action?: string;
stepParams?: Record<string, unknown>;
openingIndex?: number;
reason?: string;
stepId?: string;
moduleName?: string;
reenter?: boolean;
};
let view;
try {
@@ -474,6 +490,31 @@ const server = createServer(async (req, res) => {
case "reject":
view = await sessionManager.reject(sessionId);
break;
case "replan":
view = await sessionManager.replan(
sessionId,
typeof body.reason === "string" ? body.reason : undefined,
);
break;
case "leave_step":
view = await sessionManager.leaveStep(sessionId);
break;
case "pick_step":
view = await sessionManager.pickStep(
sessionId,
typeof body.stepId === "string" ? body.stepId : "",
body.stepParams && typeof body.stepParams === "object"
? body.stepParams
: undefined,
body.reenter === true,
);
break;
case "spawn_step":
view = await sessionManager.spawnStep(
sessionId,
typeof body.moduleName === "string" ? body.moduleName : "",
);
break;
case "run_outline":
view = await sessionManager.runOutline(sessionId);
break;
+7 -17
View File
@@ -2,7 +2,7 @@
* 上下文片段产物(context-fragment.v1)解析与展示辅助。
* 规范:docs/context-fragment-design.md
*/
import { extractJsonObjectText } from "./worker-set-parse.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import {
normalizeQuestions,
type QuestionItem,
@@ -75,14 +75,9 @@ function normalizeStability(v: unknown): ContextFragmentStability | undefined {
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return undefined;
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
@@ -278,14 +273,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
} {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return { questions: [], assessment: "" };
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -44,7 +44,8 @@ type WorkerTemplateDoc = {
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - design-flow → no_confirmation(图上点节点即确认;底栏输入即改编排)
* - design-step / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:创作默认确认
@@ -55,6 +56,7 @@ export function resolveAcceptanceModeForWorker(params: {
workerId: string;
}): AcceptanceMode {
const workerId = params.workerId.trim();
if (workerId === "design-flow") return "no_confirmation";
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
@@ -95,7 +97,7 @@ export async function resolveRunnableWorker(params: {
const acceptedRaw =
params.session.slots?.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
const { parseAcceptedSteps } = await import("./creation-flow.js");
const { parseAcceptedSteps, isInheritExistingFlag } = await import("./creation-flow.js");
const withCtx = await loadWorkerSkillWithContext(
params.skillPackName,
params.workerId,
@@ -106,6 +108,9 @@ export async function resolveRunnableWorker(params: {
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef,
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
inheritExisting: isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
),
},
);
return {
+258
View File
@@ -0,0 +1,258 @@
/**
* 扁平产物契约(wire format):机器字段薄,正文用 sections[] 字符串块。
* 渲染与旧 context-fragment.v1 深树通过 normalize 互转。
*/
export const FLAT_ARTIFACT_SCHEMA_ID = "artifact.flat.v1" as const;
export type FlatArtifactQuestion = {
id: string;
prompt: string;
options: string[];
};
export type FlatArtifactSection = {
id: string;
title: string;
text: string;
};
/** 所有需程序校验的创作产物尽量收成此形 */
export type FlatArtifact = {
schema: typeof FLAT_ARTIFACT_SCHEMA_ID | string;
skill: string;
brief: string;
mount: string[];
stability: "stable" | "semi" | "volatile" | "";
sections: FlatArtifactSection[];
open_questions: string[];
questions: FlatArtifactQuestion[];
summary: string;
};
/** OpenAI/DeepSeek strict 友好的 JSON Schema(全 required + additionalProperties:false) */
export const FLAT_ARTIFACT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: [
"schema",
"skill",
"brief",
"mount",
"stability",
"sections",
"open_questions",
"questions",
"summary",
],
properties: {
schema: { type: "string" },
skill: { type: "string" },
brief: { type: "string" },
mount: { type: "array", items: { type: "string" } },
stability: {
type: "string",
enum: ["stable", "semi", "volatile", ""],
},
sections: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "title", "text"],
properties: {
id: { type: "string" },
title: { type: "string" },
text: { type: "string" },
},
},
},
open_questions: { type: "array", items: { type: "string" } },
questions: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "prompt", "options"],
properties: {
id: { type: "string" },
prompt: { type: "string" },
options: { type: "array", items: { type: "string" } },
},
},
},
summary: { type: "string" },
},
};
export function isFlatArtifact(raw: unknown): raw is FlatArtifact {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const row = raw as Record<string, unknown>;
return Array.isArray(row.sections);
}
/** 扁形 → 旧 fragment 外壳,便于现有渲染;正文按 section id 堆对象。
* section.text 若是 JSON 对象/数组字符串,解析回结构,避免专用卡吃到整段 stringify。
*/
export function flatArtifactToFragmentDoc(flat: FlatArtifact): Record<string, unknown> {
const 正文: Record<string, unknown> = {};
for (const s of flat.sections) {
const key = s.id || s.title;
if (!key) continue;
正文[key] = coerceSectionText(s.text);
}
return {
schema: "context-fragment.v1",
技能: flat.skill,
brief: flat.brief,
mount: flat.mount,
...(flat.stability ? { 稳变: flat.stability } : {}),
正文,
开放问题: flat.open_questions,
追问: {
导语: "",
题目: flat.questions.map((q) => ({
问: q.prompt,
建议选项: q.options,
})),
},
...(flat.summary ? { summary: flat.summary } : {}),
};
}
/** section 文本:纯散文保留;JSON 对象/数组还原为结构 */
export function coerceSectionText(text: string): unknown {
const raw = String(text ?? "").trim();
if (!raw) return "";
if (
!(
(raw.startsWith("{") && raw.endsWith("}")) ||
(raw.startsWith("[") && raw.endsWith("]"))
)
) {
return String(text ?? "");
}
try {
return JSON.parse(raw) as unknown;
} catch {
return String(text ?? "");
}
}
/** 旧深树 / 任意对象 → 尽量收成扁形(保存与投递归一) */
export function coerceToFlatArtifact(raw: unknown): FlatArtifact | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
// DAG / worker 集 / 上下文排序:不是扁形正文稿,不能靠 brief 误收
if (
Array.isArray(row.steps) ||
Array.isArray(row.workers) ||
Array.isArray(row.agents) ||
Array.isArray(row.slots)
) {
return null;
}
if (isFlatArtifact(raw)) {
return {
schema:
typeof row.schema === "string" ? row.schema : FLAT_ARTIFACT_SCHEMA_ID,
skill: String(row.skill ?? row.技能 ?? ""),
brief: String(row.brief ?? ""),
mount: Array.isArray(row.mount)
? row.mount.filter((x): x is string => typeof x === "string")
: [],
stability:
row.stability === "stable" ||
row.stability === "semi" ||
row.stability === "volatile"
? row.stability
: row.稳变 === "stable" ||
row.稳变 === "semi" ||
row.稳变 === "volatile"
? row.稳变
: "",
sections: (row.sections as FlatArtifactSection[]).map((s) => ({
id: String(s?.id ?? ""),
title: String(s?.title ?? ""),
text: String(s?.text ?? ""),
})),
open_questions: Array.isArray(row.open_questions)
? row.open_questions.filter((x): x is string => typeof x === "string")
: Array.isArray(row.开放问题)
? row.开放问题.filter((x): x is string => typeof x === "string")
: [],
questions: Array.isArray(row.questions)
? (row.questions as FlatArtifactQuestion[]).map((q, i) => ({
id: String(q?.id ?? `q${i + 1}`),
prompt: String(q?.prompt ?? ""),
options: Array.isArray(q?.options)
? q.options.filter((x): x is string => typeof x === "string")
: [],
}))
: [],
summary: String(row.summary ?? ""),
};
}
const 正文 = row.正文 ?? row.body;
const sections: FlatArtifactSection[] = [];
if (正文 && typeof 正文 === "object" && !Array.isArray(正文)) {
for (const [key, val] of Object.entries(正文 as Record<string, unknown>)) {
sections.push({
id: key,
title: key,
text:
typeof val === "string"
? val
: JSON.stringify(val, null, 2),
});
}
} else if (typeof 正文 === "string" && 正文.trim()) {
sections.push({ id: "body", title: "正文", text: 正文 });
}
const probe = row.追问;
const questions: FlatArtifactQuestion[] = [];
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const topics = (probe as { 题目?: unknown }).题目;
if (Array.isArray(topics)) {
topics.forEach((t, i) => {
if (!t || typeof t !== "object") return;
const item = t as Record<string, unknown>;
questions.push({
id: `q${i + 1}`,
prompt: String(item.问 ?? item.prompt ?? ""),
options: Array.isArray(item.建议选项)
? item.建议选项.filter((x): x is string => typeof x === "string")
: Array.isArray(item.options)
? item.options.filter((x): x is string => typeof x === "string")
: [],
});
});
}
}
if (!sections.length) return null;
const stab = row.稳变 ?? row.stability;
return {
schema: FLAT_ARTIFACT_SCHEMA_ID,
skill: String(row.技能 ?? row.skill ?? ""),
brief: String(row.brief ?? ""),
mount: Array.isArray(row.mount)
? row.mount.filter((x): x is string => typeof x === "string")
: [],
stability:
stab === "stable" || stab === "semi" || stab === "volatile" ? stab : "",
sections,
open_questions: Array.isArray(row.开放问题)
? row.开放问题.filter((x): x is string => typeof x === "string")
: Array.isArray(row.open_questions)
? row.open_questions.filter((x): x is string => typeof x === "string")
: [],
questions,
summary: String(row.summary ?? ""),
};
}
+8 -2
View File
@@ -475,6 +475,8 @@ export async function loadWorkerSkillWithContext(
selectedRecipeRef?: string | null;
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
filledArtifactTags?: readonly string[];
/** 用户点已完成节点重进 */
inheritExisting?: boolean;
},
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
@@ -526,6 +528,7 @@ export async function loadWorkerSkillWithContext(
currentStepName: opts?.currentStepName,
acceptedStepNames: opts?.acceptedStepNames,
skillsRoot,
inheritExisting: opts?.inheritExisting,
});
if (binding) {
const inheritTag = binding.inheritTag?.trim() || "";
@@ -536,8 +539,11 @@ export async function loadWorkerSkillWithContext(
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: "";
const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
? formatPriorArtifactContext(
binding.step.params,
binding.prototypeSuggestion,
)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问编排层已定的参数)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = "";
const depProjection =
binding.module.id === "context-order" ? "index" : undefined;
+8
View File
@@ -198,3 +198,11 @@ export function closeCreationFlowRaw(raw: string | null | undefined): string | n
if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" });
}
/** 再编排:收口前补节点时把 DAG 改回 open。 */
export function reopenCreationFlowRaw(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: "open" });
}
+5
View File
@@ -1,4 +1,5 @@
import { parse as parseYaml } from "yaml";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import {
inferPlaySlotsFromWorkers,
mergeWorkersWithPlaySlots,
@@ -400,6 +401,10 @@ export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | n
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
export function extractJsonObjectText(raw: string): string | null {
const parsed = tryParseJsonDoc(raw);
if (parsed != null && typeof parsed === "object") {
return JSON.stringify(parsed);
}
const text = raw.trim();
if (!text) return null;
if (text.startsWith("{")) {
+14 -4
View File
@@ -72,9 +72,16 @@ export function buildContextTrace(params: {
};
}
/** 编排检查点:不随「只留最新 N 条」丢掉,否则开场白阶段无法回看/回退到流程编排 */
export function isPinnedContextTraceCaller(caller: string | undefined): boolean {
const id = (caller ?? "").trim();
return id === "worker:design-flow" || id.endsWith(":design-flow");
}
/**
* 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文;
* 更早的消息删除 contextTrace 字段(消息本身保留)。
* design-flow 痕迹钉住不删(keep=0 仍全部清除)。
*/
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
messages: T[],
@@ -88,12 +95,15 @@ export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>
return rest as T;
});
}
const withTraceIdx: number[] = [];
const droppableIdx: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].contextTrace) withTraceIdx.push(i);
const trace = messages[i].contextTrace;
if (!trace) continue;
if (isPinnedContextTraceCaller(trace.caller)) continue;
droppableIdx.push(i);
}
if (withTraceIdx.length <= keep) return messages;
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep));
if (droppableIdx.length <= keep) return messages;
const drop = new Set(droppableIdx.slice(0, droppableIdx.length - keep));
return messages.map((m, i) => {
if (!drop.has(i) || !m.contextTrace) return m;
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
+30 -1
View File
@@ -60,6 +60,13 @@ export type WaitingReason =
kind: "next_intent";
afterWorkerId?: string;
afterUnitId?: string;
}
| {
/**
* DAG 已在:按依赖层级点选节点。依赖未齐的不能点;
* 可反复能力以 role=prototype 槽位呈现;点原型增殖 instance 再进去。
*/
kind: "pick_creation_step";
};
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
@@ -232,6 +239,26 @@ export type RuntimeEvent =
type: "user_rejected_next_step";
payload: { decisionId: string; reason?: string };
}
| {
/** 回到流程编排:在已验收步骤上追加/改排节点(不整局重开) */
type: "user_requested_flow_replan";
payload: { reason?: string };
}
| {
/** DAG 图上点选一条已就绪的步骤,进入 design-step */
type: "user_picked_creation_step";
payload: { stepId: string };
}
| {
/** 验收后 / 总管误调 design-step:停在分层图上等用户点选 */
type: "creation_step_pick_awaited";
payload?: Record<string, never>;
}
| {
/** 离开当前技能步,回到分层图点选(误点增殖节点时用) */
type: "user_left_creation_step";
payload?: Record<string, never>;
}
| {
type: "worker_started";
payload: {
@@ -331,10 +358,12 @@ export type PhaseEffect =
| { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
/** 根据流程与用户下一步意向,提案下一节点或停在分层图上等点选 */
| { type: "propose_next_creation_step" }
/** 选定开场白:落库、关 DAG、标记创作收口 */
| { type: "seal_creation_opening" }
/** 再编排:解开开场收口、把 DAG 改回 open */
| { type: "unseal_creation_opening" }
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
| { type: "run_play_turn" }
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
+126 -63
View File
@@ -1,6 +1,7 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js";
import { supportsContentStream } from "../llm/stream-complete.js";
import { completeStructured } from "../llm/structured-complete.js";
import { resolveActiveProfile } from "../config/settings.js";
import { loadWorkerSkillWithContext } from "../skills/loader.js";
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
import { collectUserInputTranscript } from "../intake/intake.js";
@@ -14,6 +15,10 @@ import {
looksLikeProseNotSpec,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import {
selectJsonPayload,
tryParseJsonDoc,
} from "../parse/json-doc.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import { isPlayLayerActive } from "../skills/play-turn.js";
import { loadAppSettings } from "../config/settings.js";
@@ -21,6 +26,10 @@ 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 {
coerceToFlatArtifact,
flatArtifactToFragmentDoc,
} from "../skills/flat-artifact.js";
import {
gmChanceToolsEnabled,
GM_CHANCE_HARNESS_INSTRUCTION,
@@ -36,7 +45,10 @@ import {
isUsableContextFragment,
looksLikeFragmentDoc,
} from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js";
import {
isProgressPointerTag,
looksLikeCreationFlowDoc,
} from "../skills/creation-flow.js";
import {
CONTEXT_ORDER_TAG,
parseContextOrder,
@@ -93,28 +105,19 @@ const WORKER_OUTPUT_INSTRUCTION = `
---
## 运行时输出协议(必须遵守)
## 运行时输出协议
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 问题数组
}
输出 **一个 JSON 对象**(由程序按模型能力用 schema / tool / json_object 约束投递)。
askUser 每项可为:
- 字符串:"需要用户补充的问题"
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
按本步 SKILL 规定的产物形状写。有正文的技能稿按该步 \`正文\` 对象写(柱下到条目/诊断三元组),不要把业务字段改成一段 \`sections[].text\`。
「设计.创作流程」必须是带 \`steps\` 数组的 DAG,worker 集必须是带 \`workers\` 的规格。
- outputs 的 key 必须是要求的 outputTags
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
也兼容:
1. 直接输出本步产物对象(创作流程 / context-fragment.v1 / worker 集等)
2. \`{ "outputs": { "<本步 tag>": { … } }, "summary":"…", "askUser": null }\`(values 用对象)
不要写入进度指针。追问用 \`questions\`(或旧 \`追问.题目\`);\`questions: []\` 表示可验收。
「开放问题」留给后续步。残稿也要交。summary 用于界面展示。`;
function slotValueForTag(
tag: string,
@@ -193,6 +196,13 @@ function assignOutput(
}
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
function existingOutputIsProduct(text: string | undefined): boolean {
if (!text?.trim()) return false;
if (isUsableContextFragment(text)) return true;
const parsed = tryParseJsonDoc(text);
return looksLikeCreationFlowDoc(parsed) || looksLikeFragmentDoc(parsed);
}
function recoverFragmentOutputs(
obj: Record<string, unknown>,
outputs: Record<string, string>,
@@ -200,7 +210,7 @@ function recoverFragmentOutputs(
): void {
const target = productOutputTags(outputTags)[0];
if (!target) return;
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
if (existingOutputIsProduct(outputs[target])) return;
const outputsRaw =
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
@@ -212,7 +222,11 @@ function recoverFragmentOutputs(
if (key === target) continue;
const text = stringifyOutputValue(val);
if (!text) continue;
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
if (
isUsableContextFragment(text) ||
looksLikeFragmentDoc(val) ||
looksLikeCreationFlowDoc(val)
) {
outputs[target] = text;
return;
}
@@ -227,6 +241,13 @@ function recoverFragmentOutputs(
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
const source = looksLikeFragmentDoc(obj) ? obj : rest;
outputs[target] = JSON.stringify(source);
return;
}
if (looksLikeCreationFlowDoc(obj) || looksLikeCreationFlowDoc(rest)) {
const source = looksLikeCreationFlowDoc(obj) ? obj : rest;
if (!outputs[target]?.trim()) {
outputs[target] = JSON.stringify(source);
}
}
}
@@ -283,14 +304,26 @@ const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
},
]);
function lookupOutputValue(
raw: Record<string, unknown>,
tag: string,
): unknown {
if (raw[tag] != null) return raw[tag];
const leaf = tag.includes(".") ? tag.slice(tag.lastIndexOf(".") + 1) : tag;
if (!leaf) return undefined;
for (const [key, val] of Object.entries(raw)) {
if (val == null) continue;
if (key === leaf || key.endsWith(`.${leaf}`)) return val;
}
return undefined;
}
function parseWorkerResponse(
raw: string,
outputTags: string[],
): WorkerRunResult {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
const parsed = tryParseJsonDoc(raw);
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs(
@@ -322,17 +355,43 @@ function parseWorkerResponse(
};
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Worker 返回了无效 JSON");
const obj = parsed as Record<string, unknown>;
// 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉)
if (looksLikeCreationFlowDoc(obj)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target) {
return finalizeParsedOutputs(
{ [target]: JSON.stringify(obj) },
obj,
outputTags,
);
}
}
// 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。
if (!looksLikeFragmentDoc(obj)) {
const asFlat = coerceToFlatArtifact(obj);
if (asFlat) {
const frag = flatArtifactToFragmentDoc(asFlat);
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target) {
return finalizeParsedOutputs(
{ [target]: JSON.stringify(frag) },
{ ...frag, summary: asFlat.summary || obj.summary },
outputTags,
);
}
}
}
const obj = parsed as Record<string, unknown>;
const outputsRaw = obj.outputs;
const outputs: Record<string, string> = {};
if (outputsRaw && typeof outputsRaw === "object") {
if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) {
const bag = outputsRaw as Record<string, unknown>;
for (const tag of productOutputTags(outputTags)) {
assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
assignOutput(outputs, tag, lookupOutputValue(bag, tag));
}
}
recoverFragmentOutputs(obj, outputs, outputTags);
@@ -484,25 +543,6 @@ function extractQuestionsFromText(text: string): string[] {
return [];
}
async function completeWorkerPreferStream(
llm: LlmProvider,
messages: Parameters<LlmProvider["complete"]>[0],
options: Parameters<LlmProvider["complete"]>[1],
stream?: WorkerStreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
const callbacks: StreamCallbacks = {
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
};
if (supportsContentStream(llm)) {
return llm.completeStream!(messages, options, callbacks);
}
const result = await llm.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId);
let worker: ParsedWorkerSkill;
@@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
params.slots.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
let acceptedStepNames: string[] = [];
let inheritExisting = false;
try {
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
const { parseAcceptedSteps, isInheritExistingFlag } = await import(
"../skills/creation-flow.js"
);
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
inheritExisting = isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
);
} catch {
acceptedStepNames = [];
}
@@ -538,6 +584,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
acceptedStepNames,
selectedRecipeRef,
filledArtifactTags,
inheritExisting,
},
);
worker = loaded.worker;
@@ -616,20 +663,36 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
stream: params.stream,
caller: `worker:${worker.id}`,
});
return parseWorkerResponse(harness.content, worker.outputTags);
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
}
const result = await completeWorkerPreferStream(
params.llm,
messages,
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
},
params.stream,
);
const streamCallbacks: StreamCallbacks | undefined = params.stream
? {
onReasoningDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => params.stream?.onOutputDelta?.(delta),
}
: undefined;
return parseWorkerResponse(result.content, worker.outputTags);
const result = await completeStructured(params.llm, messages, {
schema: {
type: "object",
additionalProperties: true,
},
name: "submit_worker_result",
schemaIsLoose: true,
capabilities: resolveActiveProfile()?.capabilities,
caller: `worker:${worker.id}`,
stream: streamCallbacks,
});
const raw =
typeof result.parsed === "string"
? result.parsed
: JSON.stringify(result.parsed);
return parseWorkerResponse(
selectJsonPayload(raw, result.reasoning) || raw,
worker.outputTags,
);
}
/** @internal 供单测 */