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
+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 供单测 */