Play rounds end in present.v1 body instead of creation HITL.

Narrator uses capability-probed structured delivery and falls back to shell body on failure; play UI hides worker questions and keeps thinking on process rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 01:07:43 +08:00
co-authored by Cursor
parent f37388761b
commit 92b2d88738
11 changed files with 828 additions and 327 deletions
+53 -13
View File
@@ -130,7 +130,14 @@ import {
import {
readPlayTurnQueue,
withPlayTurnQueue,
isPlayLayerActive,
} from "../skills/play-turn.js";
import {
PRESENT_TAG,
isPlayPresentWorker,
parseShellAdaptationFromReplyFormat,
presentOutputFromFallback,
} from "../skills/present-packet.js";
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
import {
executeChance,
@@ -1001,6 +1008,37 @@ export class PhaseRuntime {
return true;
}
/**
* 游玩层不走创作 HITL:转述必须交出用户可见正文,其它执行单元空产物也继续。
*/
private settlePlayWorkerResult(
workerId: string,
result: import("../worker/executor.js").WorkerRunResult,
): import("../worker/executor.js").WorkerRunResult {
if (!isPlayLayerActive(this.session.slots)) return result;
const outputs = { ...result.outputs };
if (Object.keys(outputs).length === 0 && isPlayPresentWorker(workerId)) {
const shell =
parseShellAdaptationFromReplyFormat(
this.blackboard.getContentByTag("设计.正文组成") ??
this.blackboard.getContentByTag("设计.回复格式"),
)?.shell_id ?? "prose";
const preview = result.preview?.trim() ?? "";
const looksAsk =
/可验收|追问|请再补|askUser/.test(preview) || /^\s*-\s/.test(preview);
outputs[PRESENT_TAG] = presentOutputFromFallback(
looksAsk ? "" : preview,
shell,
);
}
return {
...result,
outputs,
askUser: undefined,
askAssessment: undefined,
};
}
/** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */
async workerAsk(
questions: import("../types/questions.js").QuestionItem[] | string[],
@@ -1802,20 +1840,22 @@ export class PhaseRuntime {
},
});
if (result.askUser?.length && Object.keys(result.outputs).length === 0) {
const settled = this.settlePlayWorkerResult(workerId, result);
if (settled.askUser?.length && Object.keys(settled.outputs).length === 0) {
// 无产物:阻塞追问(信息不足,必须补)
if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) {
if (await this.restoreRevisionTargetForReview(workerId, settled.askUser)) {
return;
}
await this.workerAsk(result.askUser);
await this.workerAsk(settled.askUser);
return;
}
if (
workerId === "design-step" &&
Object.keys(result.outputs).length === 0
Object.keys(settled.outputs).length === 0
) {
const retryQuestions = result.askUser?.length
? result.askUser
const retryQuestions = settled.askUser?.length
? settled.askUser
: [
{
id: "design-step-retry",
@@ -1834,7 +1874,7 @@ export class PhaseRuntime {
slots = { ...this.session.slots };
const writtenTags: string[] = [];
for (const [tag, content] of Object.entries(result.outputs)) {
for (const [tag, content] of Object.entries(settled.outputs)) {
if (isProgressPointerTag(tag)) continue;
const written = this.writeWorkerTagContent(tag, content, workerId);
slots[tag] = written;
@@ -1848,16 +1888,16 @@ export class PhaseRuntime {
outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter(
(t) => !isProgressPointerTag(t),
),
summary: result.summary,
summary: settled.summary,
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
const hasPlayVisible = Object.keys(result.outputs).some(
const hasPlayVisible = Object.keys(settled.outputs).some(
(t) => t === "输出.用户展示" || t === "输出.开场白",
);
this.onMessage(
`[Worker] ${workerId} 已完成\n\n${result.preview}${
!hasPlayVisible && result.preview.length >= 4000 ? "\n\n…" : ""
`[Worker] ${workerId} 已完成\n\n${settled.preview}${
!hasPlayVisible && settled.preview.length >= 4000 ? "\n\n…" : ""
}`,
);
@@ -1866,8 +1906,8 @@ export class PhaseRuntime {
payload: {
artifactId: artifact.id,
// 有产物时 askUser 挂到验收态,不阻断 Accept
questions: this.freshSidecarQuestions(result.askUser),
assessment: result.askAssessment?.trim() || undefined,
questions: this.freshSidecarQuestions(settled.askUser),
assessment: settled.askAssessment?.trim() || undefined,
},
});
this.maybeCompressAcceptedArtifact(artifact.id);
+14 -2
View File
@@ -851,7 +851,8 @@ export class SessionManager {
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent" &&
kind !== "pick_creation_step"
kind !== "pick_creation_step" &&
kind !== "worker_questions"
) {
return;
}
@@ -1875,12 +1876,23 @@ export class SessionManager {
this.captureCheckpoint(managed),
);
const usage = managed.trackingRef.current.pendingUsage;
const thinking = managed.trackingRef.current.pendingReasoning;
const sessionId = managed.trackingRef.current.sessionId;
const liveThinking = sessionId
? this.workerLive.get(sessionId)?.thinking?.trim()
: "";
managed.trackingRef.current.pendingUsage = undefined;
const pendingReasoning = managed.trackingRef.current.pendingReasoning;
managed.trackingRef.current.pendingReasoning = undefined;
const classified =
text.trim().length > 0 ? classifyAgentMessage(text) : null;
const thinking =
pendingReasoning ||
(classified?.kind === "worker_output" ||
classified?.kind === "worker_questions"
? liveThinking
: "") ||
undefined;
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
let contextTrace: LlmContextTrace | undefined;
if (
+69
View File
@@ -197,6 +197,75 @@ export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
/** 游玩期把裁决写成用户可见终稿的执行单元 */
export function isPlayPresentWorker(workerId: string): boolean {
const id = workerId.trim();
return id === "narrator" || id === "round-present";
}
/** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */
export const PRESENT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["schema", "shell", "blocks", "meta"],
properties: {
schema: { type: "string" },
shell: { type: "string" },
blocks: {
type: "object",
additionalProperties: false,
required: ["body", "monitor", "header", "footer", "aside"],
properties: {
body: { type: "string" },
monitor: { type: "string" },
header: { type: "string" },
footer: { type: "string" },
aside: { type: "string" },
},
},
meta: {
type: "object",
additionalProperties: false,
required: ["suggested_actions"],
properties: {
suggested_actions: {
type: "array",
items: { type: "string" },
},
},
},
},
};
/** 游玩转述:按探测到的 structured 投递写出 present.v1,不要创作追问 */
export function playPresentOutputInstruction(shell: PresentShellId): string {
return `
---
## 运行时输出协议(游玩正文)
本步产物是给用户看的终稿。程序按已探测的模型能力用 schema / tool / json_object 投递,你输出 **一个 present.v1 对象**:
{"schema":"present.v1","shell":"${shell}","blocks":{"body":"用户可读正文","monitor":"","header":"","footer":"","aside":""},"meta":{"suggested_actions":[]}}
\`shell\` 必须是 \`${shell}\`。只填该壳已有区域,没有的键留空字符串。
prose 壳也可直接输出 Markdown,程序会落入 body。
材料不够也先写一版场面。不要写追问、askUser、自评或「可验收产物」。`;
}
export function stringifyPresentPacket(packet: PresentPacket): string {
return JSON.stringify(packet);
}
export function presentOutputFromFallback(
text: string,
shell: PresentShellId = "prose",
): string {
const body = stripPresentSourceFences(text).trim() || "(本轮场面未写完)";
return stringifyPresentPacket(presentFromPlainText(body, shell));
}
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
+229 -51
View File
@@ -25,7 +25,18 @@ import { loadAppSettings } from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
import { PRESENT_TAG } from "../skills/present-packet.js";
import {
PRESENT_TAG,
PRESENT_JSON_SCHEMA,
isPlayPresentWorker,
isPresentLikeObject,
parsePresentPacket,
parseShellAdaptationFromReplyFormat,
playPresentOutputInstruction,
presentOutputFromFallback,
stringifyPresentPacket,
type PresentShellId,
} from "../skills/present-packet.js";
import {
coerceToFlatArtifact,
flatArtifactToFragmentDoc,
@@ -44,6 +55,7 @@ import {
extractFragmentAskSidecar,
isUsableContextFragment,
looksLikeFragmentDoc,
parseContextFragment,
} from "../skills/context-fragment.js";
import {
isProgressPointerTag,
@@ -200,13 +212,66 @@ 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);
return (
looksLikeCreationFlowDoc(parsed) ||
looksLikeFragmentDoc(parsed) ||
isPresentLikeObject(parsed)
);
}
function usablePlayFallbackText(raw?: string): string {
if (!raw?.trim()) return "";
if (isQuestionOnlyText(raw)) return "";
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
}
return raw.trim();
}
function playVisibleTarget(outputTags: string[]): string | undefined {
return productOutputTags(outputTags).find((t) => PLAY_FEED_OUTPUT_TAGS.has(t));
}
function canonicalizePresentOutput(
value: unknown,
fallbackShell: PresentShellId,
): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") {
const view = parsePresentPacket(value, fallbackShell);
if (!view.packet.blocks.body && view.fallbackPlain && !value.trim()) {
return undefined;
}
return stringifyPresentPacket(view.packet);
}
if (typeof value === "object") {
if (isPresentLikeObject(value)) {
const view = parsePresentPacket(JSON.stringify(value), fallbackShell);
return stringifyPresentPacket(view.packet);
}
const view = parsePresentPacket(JSON.stringify(value), fallbackShell);
if (!view.fallbackPlain) return stringifyPresentPacket(view.packet);
}
return undefined;
}
function fragmentSceneText(doc: unknown): string | undefined {
const frag =
typeof doc === "string"
? parseContextFragment(doc)
: parseContextFragment(JSON.stringify(doc));
if (!frag) return undefined;
if (typeof frag.正文 === "string" && frag.正文.trim()) return frag.正文.trim();
if (typeof frag.brief === "string" && frag.brief.trim()) return frag.brief.trim();
return undefined;
}
function recoverFragmentOutputs(
obj: Record<string, unknown>,
outputs: Record<string, string>,
outputTags: string[],
fallbackShell: PresentShellId = "prose",
): void {
const target = productOutputTags(outputTags)[0];
if (!target) return;
@@ -225,7 +290,8 @@ function recoverFragmentOutputs(
if (
isUsableContextFragment(text) ||
looksLikeFragmentDoc(val) ||
looksLikeCreationFlowDoc(val)
looksLikeCreationFlowDoc(val) ||
isPresentLikeObject(val)
) {
outputs[target] = text;
return;
@@ -238,6 +304,14 @@ function recoverFragmentOutputs(
for (const [k, v] of Object.entries(obj)) {
if (!protocolKeys.has(k)) rest[k] = v;
}
if (isPresentLikeObject(obj) || isPresentLikeObject(rest)) {
const source = isPresentLikeObject(obj) ? obj : rest;
const present = canonicalizePresentOutput(source, fallbackShell);
if (present) {
outputs[target] = present;
return;
}
}
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
const source = looksLikeFragmentDoc(obj) ? obj : rest;
outputs[target] = JSON.stringify(source);
@@ -267,6 +341,7 @@ function looksLikeStructuredDraft(text: string): boolean {
if (t.startsWith("{") || t.startsWith("[")) return true;
if (/```(?:json)?/i.test(t) && t.includes("{")) return true;
if (/"schema"\s*:/.test(t)) return true;
if (/"blocks"\s*:/.test(t) && /"body"\s*:/.test(t)) return true;
if (/"技能"\s*:/.test(t) || /"正文"\s*:/.test(t)) return true;
if (/"inserts"\s*:/.test(t) || /"agents"\s*:/.test(t) || /"play_slots"\s*:/.test(t)) {
return true;
@@ -321,10 +396,26 @@ function lookupOutputValue(
function parseWorkerResponse(
raw: string,
outputTags: string[],
opts?: { fallbackShell?: PresentShellId },
): WorkerRunResult {
const fallbackShell = opts?.fallbackShell ?? "prose";
const playTarget = playVisibleTarget(outputTags);
const parsed = tryParseJsonDoc(raw);
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
const target = playTarget ?? productOutputTags(outputTags)[0] ?? outputTags[0];
if (playTarget) {
const body =
raw.trim() && !isQuestionOnlyText(raw)
? raw
: "";
return finalizeParsedOutputs(
{ [playTarget]: presentOutputFromFallback(body, fallbackShell) },
{ summary: body.slice(0, 80) || "游玩正文" },
outputTags,
{ playVisible: true, fallbackShell },
);
}
if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
@@ -357,6 +448,18 @@ function parseWorkerResponse(
const obj = parsed as Record<string, unknown>;
if (playTarget && isPresentLikeObject(obj)) {
const present = canonicalizePresentOutput(obj, fallbackShell);
if (present) {
return finalizeParsedOutputs(
{ [playTarget]: present },
obj,
outputTags,
{ playVisible: true, fallbackShell },
);
}
}
// 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉)
if (looksLikeCreationFlowDoc(obj)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
@@ -369,8 +472,21 @@ function parseWorkerResponse(
}
}
// 游玩正文:片段里的场面字收成 present,不要扁形成创作稿
if (playTarget && !isPresentLikeObject(obj)) {
const scene = fragmentSceneText(obj);
if (scene) {
return finalizeParsedOutputs(
{ [playTarget]: presentOutputFromFallback(scene, fallbackShell) },
obj,
outputTags,
{ playVisible: true, fallbackShell },
);
}
}
// 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。
if (!looksLikeFragmentDoc(obj)) {
if (!looksLikeFragmentDoc(obj) && !isPresentLikeObject(obj) && !playTarget) {
const asFlat = coerceToFlatArtifact(obj);
if (asFlat) {
const frag = flatArtifactToFragmentDoc(asFlat);
@@ -391,69 +507,116 @@ function parseWorkerResponse(
if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) {
const bag = outputsRaw as Record<string, unknown>;
for (const tag of productOutputTags(outputTags)) {
assignOutput(outputs, tag, lookupOutputValue(bag, tag));
const val = lookupOutputValue(bag, tag);
if (playTarget && tag === playTarget) {
const present = canonicalizePresentOutput(val, fallbackShell);
if (present) {
outputs[tag] = present;
continue;
}
const scene = fragmentSceneText(val);
if (scene) {
outputs[tag] = presentOutputFromFallback(scene, fallbackShell);
continue;
}
}
assignOutput(outputs, tag, val);
}
}
recoverFragmentOutputs(obj, outputs, outputTags);
recoverFragmentOutputs(obj, outputs, outputTags, fallbackShell);
return finalizeParsedOutputs(outputs, obj, outputTags);
if (playTarget && outputs[playTarget]) {
const present = canonicalizePresentOutput(outputs[playTarget], fallbackShell);
if (present) outputs[playTarget] = present;
}
return finalizeParsedOutputs(outputs, obj, outputTags, {
playVisible: Boolean(playTarget),
fallbackShell,
rawFallback: raw,
});
}
function finalizeParsedOutputs(
outputs: Record<string, string>,
obj: Record<string, unknown>,
_outputTags: string[],
outputTags: string[],
opts?: {
playVisible?: boolean;
fallbackShell?: PresentShellId;
rawFallback?: string;
},
): WorkerRunResult {
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
const playTarget = playVisibleTarget(outputTags);
const playVisible = Boolean(opts?.playVisible || playTarget);
const fallbackShell = opts?.fallbackShell ?? "prose";
let askUser: QuestionItem[] | undefined;
if (Array.isArray(obj.askUser)) {
askUser = normalizeQuestions(
obj.askUser.filter((q) => {
if (typeof q === "string") return q.trim() && !isMetaAskToken(q);
return true;
}),
);
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
const q = obj.askUser.trim();
askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]);
}
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
const summaryText =
typeof obj.summary === "string" ? obj.summary.trim() : "";
if (
summaryText &&
!isMetaAskToken(summaryText) &&
summaryText.length > 8 &&
/[??]/.test(summaryText)
) {
askUser = normalizeQuestions([summaryText]);
} else if (isMetaAskToken(summaryText)) {
askUser = normalizeQuestions([
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。",
]);
if (!playVisible) {
if (Array.isArray(obj.askUser)) {
askUser = normalizeQuestions(
obj.askUser.filter((q) => {
if (typeof q === "string") return q.trim() && !isMetaAskToken(q);
return true;
}),
);
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
const q = obj.askUser.trim();
askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]);
}
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
const summaryText =
typeof obj.summary === "string" ? obj.summary.trim() : "";
if (
summaryText &&
!isMetaAskToken(summaryText) &&
summaryText.length > 8 &&
/[??]/.test(summaryText)
) {
askUser = normalizeQuestions([summaryText]);
} else if (isMetaAskToken(summaryText)) {
askUser = normalizeQuestions([
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。",
]);
}
}
}
// 先抽追问;半残稿仍留在 outputs,追问挂验收卡(不因缺字段丢掉正文)
let askAssessment: string | undefined;
const fragQuestions: QuestionItem[] = [];
for (const content of Object.values(outputs)) {
const side = extractFragmentAskSidecar(content);
if (side.assessment && !askAssessment) askAssessment = side.assessment;
for (const q of side.questions) {
if (!fragQuestions.some((x) => x.prompt === q.prompt)) {
fragQuestions.push(q);
if (!playVisible) {
for (const content of Object.values(outputs)) {
const side = extractFragmentAskSidecar(content);
if (side.assessment && !askAssessment) askAssessment = side.assessment;
for (const q of side.questions) {
if (!fragQuestions.some((x) => x.prompt === q.prompt)) {
fragQuestions.push(q);
}
}
}
}
if (fragQuestions.length) {
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
if (fragQuestions.length) {
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
}
}
dropProgressPointerOutputs(outputs);
canonicalizeKnownOutputs(outputs);
if (playVisible && playTarget && !outputs[playTarget]?.trim()) {
const scene = fragmentSceneText(obj);
const summaryText =
typeof obj.summary === "string" && !/[??]/.test(obj.summary)
? obj.summary.trim()
: "";
const fallback =
scene ||
summaryText ||
usablePlayFallbackText(opts?.rawFallback);
outputs[playTarget] = presentOutputFromFallback(fallback, fallbackShell);
}
if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
}
@@ -470,8 +633,8 @@ function finalizeParsedOutputs(
outputs,
summary,
preview,
askUser: askUser?.length ? askUser : undefined,
askAssessment,
askUser: playVisible ? undefined : askUser?.length ? askUser : undefined,
askAssessment: playVisible ? undefined : askAssessment,
});
}
@@ -618,9 +781,20 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
)?.play_slots,
);
const playPresent =
isPlayLayerActive(params.slots) && isPlayPresentWorker(worker.id);
const playShell: PresentShellId | undefined = playPresent
? parseShellAdaptationFromReplyFormat(
params.blackboard.getContentByTag("设计.正文组成") ??
params.blackboard.getContentByTag("设计.回复格式"),
)?.shell_id ?? "prose"
: undefined;
const systemContent =
promptBody +
WORKER_OUTPUT_INSTRUCTION +
(playPresent && playShell
? playPresentOutputInstruction(playShell)
: WORKER_OUTPUT_INSTRUCTION) +
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
const playPreset = isPlayLayerActive(params.slots)
? resolveActivePreset(loadAppSettings().activePresetId)
@@ -663,7 +837,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
stream: params.stream,
caller: `worker:${worker.id}`,
});
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags, {
fallbackShell: playShell,
});
}
const streamCallbacks: StreamCallbacks | undefined = params.stream
@@ -674,12 +850,12 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
: undefined;
const result = await completeStructured(params.llm, messages, {
schema: {
schema: playPresent ? PRESENT_JSON_SCHEMA : {
type: "object",
additionalProperties: true,
},
name: "submit_worker_result",
schemaIsLoose: true,
name: playPresent ? "submit_present_packet" : "submit_worker_result",
schemaIsLoose: !playPresent,
capabilities: resolveActiveProfile()?.capabilities,
caller: `worker:${worker.id}`,
stream: streamCallbacks,
@@ -692,6 +868,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
return parseWorkerResponse(
selectJsonPayload(raw, result.reasoning) || raw,
worker.outputTags,
{ fallbackShell: playShell },
);
}
@@ -699,8 +876,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
export function parseWorkerResponseForTest(
raw: string,
outputTags: string[],
opts?: { fallbackShell?: PresentShellId },
): WorkerRunResult {
return parseWorkerResponse(raw, outputTags);
return parseWorkerResponse(raw, outputTags, opts);
}
/** @internal 供单测验证 inputTags → inputs 拼接 */