From 1c54e73356f4eeb35cd774e1ecc3c8eebe0f1e65 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:05:05 +0800 Subject: [PATCH 1/2] feat(subagents): add structured direct results --- README.md | 2 + extensions/shared/structured-output.ts | 154 ++++++++++++++++++ extensions/subagents/index.ts | 43 ++++- extensions/subagents/src/backends/pi.ts | 81 ++++++++- extensions/subagents/src/domain.ts | 17 +- extensions/subagents/src/manager.ts | 5 + extensions/subagents/src/prompt.ts | 20 ++- extensions/subagents/src/result-artifact.ts | 32 ++++ extensions/workflows/prompt.ts | 8 - extensions/workflows/runner.ts | 77 +-------- skills/subagents/REFERENCE.md | 5 +- skills/subagents/SKILL.md | 1 + .../shared/structured-output.test.ts | 55 +++++++ tests/extensions/subagents/index.test.ts | 46 ++++++ .../subagents/pi-backend-lifecycle.test.ts | 100 +++++++++++- tests/extensions/subagents/prompt.test.ts | 18 ++ .../subagents/result-artifact.test.ts | 18 ++ 17 files changed, 586 insertions(+), 96 deletions(-) create mode 100644 extensions/shared/structured-output.ts create mode 100644 tests/extensions/shared/structured-output.test.ts diff --git a/README.md b/README.md index 70ac3a23..dea86cc1 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,8 @@ Capability discovery 默认是 `explicit`:普通父 Session 不常驻任何 Op `subagent_spawn` 立即返回,结束后自动回传并重新唤醒主 Agent。交互会话没有其他工作时,主 Agent 应结束当前轮、让用户继续交互;“下一步依赖结果”本身不是阻塞理由。只有用户明确要求当前回复等完,或非交互自动化必须在同一次调用中返回完整结果时,才应调用 `subagent_wait`。 +需要机器可验证的 review findings、research evidence 或 test matrix 时,可为 `subagent_spawn` 提供可选 `output_schema`。该次 Direct Subagent 只会额外获得 terminating `structured_output`,未提交匹配结果会明确失败;验证后的 JSON 会有界回传并写入私有 content-addressed artifact。省略 schema 的普通文本路径不会加载该 child tool 或 structured instruction。 +
diff --git a/extensions/shared/structured-output.ts b/extensions/shared/structured-output.ts new file mode 100644 index 00000000..7b6da4df --- /dev/null +++ b/extensions/shared/structured-output.ts @@ -0,0 +1,154 @@ +import { + defineTool, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { type TSchema, Type } from "typebox"; + +export const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION = + "When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it."; + +export const STRUCTURED_OUTPUT_TOOL_DESCRIPTION = + "Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it."; + +export const STRUCTURED_RESULT_LIMITS = Object.freeze({ + schemaDepth: 24, + schemaNodes: 10_000, + resultBytes: 2 * 1024 * 1024, + resultDepth: 24, + resultNodes: 100_000, + resultStringBytes: 1024 * 1024, +}); + +export interface EncodedStructuredResult { + readonly value: unknown; + readonly json: string; + readonly byteLength: number; +} + +function safeRecordKey(key: string) { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +} + +/** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */ +export function jsonSchemaToTypebox(schema: unknown): TSchema { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("structured output schema must be a bounded JSON object"); + } + const seen = new WeakSet(); + let nodes = 0; + const validate = (current: unknown, depth: number): boolean => { + if ( + ++nodes > STRUCTURED_RESULT_LIMITS.schemaNodes || + depth > STRUCTURED_RESULT_LIMITS.schemaDepth + ) { + return false; + } + if ( + current === null || + typeof current === "string" || + typeof current === "boolean" + ) { + return true; + } + if (typeof current === "number") return Number.isFinite(current); + if (Array.isArray(current)) { + return current.every((item) => validate(item, depth + 1)); + } + if (typeof current !== "object" || seen.has(current)) return false; + seen.add(current); + return Object.keys(current).every( + (key) => + safeRecordKey(key) && + validate((current as Record)[key], depth + 1), + ); + }; + if (!validate(schema, 0)) { + throw new Error("structured output schema must be a bounded JSON object"); + } + return Type.Unsafe(schema); +} + +/** Encode one complete JSON result or fail before any truncated artifact exists. */ +export function encodeStructuredResult( + value: unknown, +): EncodedStructuredResult { + const seen = new WeakSet(); + let nodes = 0; + const validate = (current: unknown, depth: number): void => { + if (++nodes > STRUCTURED_RESULT_LIMITS.resultNodes) { + throw new Error("structured result exceeds the node limit"); + } + if (depth > STRUCTURED_RESULT_LIMITS.resultDepth) { + throw new Error("structured result exceeds the depth limit"); + } + if (typeof current === "string") { + if ( + Buffer.byteLength(current, "utf8") > + STRUCTURED_RESULT_LIMITS.resultStringBytes + ) { + throw new Error("structured result contains an oversized string"); + } + return; + } + if ( + current === null || + typeof current === "boolean" || + (typeof current === "number" && Number.isFinite(current)) + ) { + return; + } + if (typeof current !== "object" || seen.has(current)) { + throw new Error( + "structured result must contain only acyclic JSON values", + ); + } + seen.add(current); + if (Array.isArray(current)) { + for (const item of current) validate(item, depth + 1); + return; + } + for (const [key, item] of Object.entries(current)) { + if (!safeRecordKey(key)) { + throw new Error("structured result contains an unsafe object key"); + } + validate(item, depth + 1); + } + }; + validate(value, 0); + const json = JSON.stringify(value); + const byteLength = Buffer.byteLength(json, "utf8"); + if (byteLength > STRUCTURED_RESULT_LIMITS.resultBytes) { + throw new Error("structured result exceeds the total byte limit"); + } + return { value, json, byteLength }; +} + +/** One-shot terminating child tool shared by Workflow and Direct Subagent. */ +export function createStructuredOutputTool( + schema: unknown, + capture: (value: unknown) => void, +): ToolDefinition { + return defineTool({ + name: "structured_output", + label: "Structured Output", + description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION, + parameters: jsonSchemaToTypebox(schema), + async execute(_toolCallId, params) { + capture(params); + return { + content: [{ type: "text", text: "Recorded structured result." }], + details: params, + terminate: true, + }; + }, + }); +} + +export function childToolsWithStructuredOutput( + tools: readonly string[] | undefined, + structured: boolean, +) { + return tools + ? [...new Set([...tools, ...(structured ? ["structured_output"] : [])])] + : undefined; +} diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 7bcb0340..f328e1a7 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -169,6 +169,7 @@ interface SpawnResultDetails { readonly harness?: string; readonly model?: string; readonly agentType?: string; + readonly structured?: boolean; } interface SubagentFinishedData { @@ -187,6 +188,8 @@ interface SubagentResultDetails { readonly elapsed?: string; readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; + readonly structured?: unknown; + readonly structuredArtifactPath?: string; readonly count?: number; readonly results?: ReadonlyArray<{ readonly id: string; @@ -197,6 +200,8 @@ interface SubagentResultDetails { readonly elapsed?: string; readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; + readonly structured?: unknown; + readonly structuredArtifactPath?: string; }>; /** Display-only projection for the custom message renderer. */ readonly displayContent?: string; @@ -227,13 +232,17 @@ function describeSubagent(snap: SubagentSnapshot) { return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`; } +function subagentResultContent(snap: SubagentSnapshot) { + return snap.structuredResult?.json ?? (snap.finalText || "(no output)"); +} + export function truncatedOutput( snap: SubagentSnapshot, maxBytes = SUBAGENT_OUTPUT_MAX_BYTES, writeArtifact: (content: string) => string = (content) => persistResultArtifact(getAgentDir(), content), ): string { - const output = snap.finalText || "(no output)"; + const output = subagentResultContent(snap); return projectResult(output, { maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), maxLines: Math.min(600, DEFAULT_MAX_LINES), @@ -245,7 +254,7 @@ function projectSubagentOutput( snap: SubagentSnapshot, maxBytes: number, ): ResultProjection { - const output = snap.finalText || "(no output)"; + const output = subagentResultContent(snap); return projectResult(output, { maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), maxLines: Math.min(600, DEFAULT_MAX_LINES), @@ -313,7 +322,7 @@ export function createSubagentResultDispatcher( ); const allocation = allocateResultBudgets( snaps.map((snap) => - Buffer.byteLength(snap.finalText || "(no output)", "utf8"), + Buffer.byteLength(subagentResultContent(snap), "utf8"), ), getContextUsage(), { @@ -370,6 +379,13 @@ export function createSubagentResultDispatcher( ...(projections[0]!.artifactSaveFailed ? { artifactSaveFailed: true } : {}), + ...(snaps[0]!.structuredResult + ? { + structured: snaps[0]!.structuredResult.value, + structuredArtifactPath: + snaps[0]!.structuredResult.artifactPath, + } + : {}), } : { count: snaps.length, @@ -388,6 +404,12 @@ export function createSubagentResultDispatcher( ...(projections[index]!.artifactSaveFailed ? { artifactSaveFailed: true } : {}), + ...(snap.structuredResult + ? { + structured: snap.structuredResult.value, + structuredArtifactPath: snap.structuredResult.artifactPath, + } + : {}), })), }; pi.appendEntry("subagent-result", { @@ -931,6 +953,9 @@ export default function ( ...(agentType?.body ? { appendSystemPrompt: [agentType.body] } : {}), ...(childTools ? { tools: childTools } : {}), ...(agentType ? { agentTypeName: agentType.name } : {}), + ...(params.output_schema !== undefined + ? { outputSchema: params.output_schema } + : {}), ...(worktree ? { worktree: { ...worktree, repoCwd: cwd } } : {}), parent: { parentCwd: ctx.cwd, @@ -1000,6 +1025,9 @@ export default function ( ...(worktree ? { worktreeBranch: worktree.branch } : {}), ...(agentType ? { agentTypeName: agentType.name } : {}), ...(childTools ? { tools: childTools } : {}), + ...(params.output_schema !== undefined + ? { structured: true } + : {}), }), }, ], @@ -1010,6 +1038,7 @@ export default function ( harness, model: snap.meta.modelLabel, ...(agentType ? { agentType: agentType.name } : {}), + ...(params.output_schema !== undefined ? { structured: true } : {}), }, }; }, @@ -1132,7 +1161,7 @@ export default function ( ); const allocation = allocateResultBudgets( resultEntries.map(({ snap }) => - Buffer.byteLength(snap.finalText || "(no output)", "utf8"), + Buffer.byteLength(subagentResultContent(snap), "utf8"), ), ctx.getContextUsage(), { @@ -1182,6 +1211,12 @@ export default function ( ...(artifactSaveFailures.has(id) ? { artifactSaveFailed: true } : {}), + ...(snap?.structuredResult + ? { + structured: snap.structuredResult.value, + structuredArtifactPath: snap.structuredResult.artifactPath, + } + : {}), }; }), }, diff --git a/extensions/subagents/src/backends/pi.ts b/extensions/subagents/src/backends/pi.ts index b50db916..ab968406 100644 --- a/extensions/subagents/src/backends/pi.ts +++ b/extensions/subagents/src/backends/pi.ts @@ -19,6 +19,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { createAgentSession, + getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent"; import type { Cause, Scope } from "effect"; @@ -49,6 +50,14 @@ import { reclaimWorktree, } from "../../../shared/worktree.ts"; import { AgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts"; +import { + childToolsWithStructuredOutput, + createStructuredOutputTool, + encodeStructuredResult, + type EncodedStructuredResult, + STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION, +} from "../../../shared/structured-output.ts"; +import { persistStructuredResultArtifact } from "../result-artifact.ts"; const DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS = 4_000; const PARTIAL_TEXT_MAX_LENGTH = 128 * 1_024; @@ -198,14 +207,26 @@ const makePiSession = ( const thinkingLevel = (task.reasoningEffort ?? task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined; + let capturedStructured: EncodedStructuredResult | undefined; + const structuredOutputTool = + task.outputSchema === undefined + ? undefined + : createStructuredOutputTool(task.outputSchema, (value) => { + capturedStructured = encodeStructuredResult(value); + }); + const session = yield* Effect.tryPromise({ try: async () => { + const appendSystemPrompt = [ + ...(task.appendSystemPrompt ?? []), + ...(structuredOutputTool + ? [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION] + : []), + ]; const { loader, settingsManager } = await createChildResources({ cwd: task.cwd, projectTrusted: task.parent.projectTrusted, - ...(task.appendSystemPrompt - ? { appendSystemPrompt: [...task.appendSystemPrompt] } - : {}), + ...(appendSystemPrompt.length > 0 ? { appendSystemPrompt } : {}), }); const { session } = await ( options.sessionFactory ?? createAgentSession @@ -216,13 +237,27 @@ const makePiSession = ( resourceLoader: loader, model, thinkingLevel, - ...childToolPolicy(task.tools), + ...(structuredOutputTool + ? { customTools: [structuredOutputTool] } + : {}), + ...childToolPolicy( + childToolsWithStructuredOutput( + task.tools, + structuredOutputTool !== undefined, + ), + ), }); // Start child extension session hooks/resources in headless mode. // A rejection here would otherwise leak the freshly created session: // the scope finalizer that owns cleanup is only registered later. try { - await bindChildSessionExtensions(session, task.tools); + await bindChildSessionExtensions( + session, + childToolsWithStructuredOutput( + task.tools, + structuredOutputTool !== undefined, + ), + ); } catch (error) { await shutdownAndDisposeChildSession(session, { timeoutMs: options.shutdownTimeoutMs, @@ -338,11 +373,46 @@ const makePiSession = ( }); return; } + if (task.outputSchema !== undefined && capturedStructured === undefined) { + emit({ + _tag: "RunSettled", + outcome: { + _tag: "Failed", + errorText: + "Agent finished without calling structured_output; no structured result matching output_schema was produced.", + partialText, + }, + }); + return; + } + let structuredResult; + if (capturedStructured) { + try { + structuredResult = { + ...capturedStructured, + artifactPath: persistStructuredResultArtifact( + getAgentDir(), + capturedStructured.json, + ), + }; + } catch (error) { + emit({ + _tag: "RunSettled", + outcome: { + _tag: "Failed", + errorText: `Structured result artifact could not be persisted: ${boundedError(error)}`, + partialText, + }, + }); + return; + } + } emit({ _tag: "RunSettled", outcome: { _tag: "Completed", finalText: prompt.finalText, + ...(structuredResult ? { structuredResult } : {}), }, }); }; @@ -597,6 +667,7 @@ const makePiSession = ( promise: Promise.resolve(), }; state.activePrompt = activePrompt; + capturedStructured = undefined; state.settled = false; emit({ _tag: "RunStarted" }); let prompt: Promise; diff --git a/extensions/subagents/src/domain.ts b/extensions/subagents/src/domain.ts index 503bd351..ac8f9caf 100644 --- a/extensions/subagents/src/domain.ts +++ b/extensions/subagents/src/domain.ts @@ -69,6 +69,8 @@ export interface SpawnTask { readonly tools?: readonly string[]; /** Agent type that supplied the above, for the session label. */ readonly agentTypeName?: string; + /** Optional JSON Schema for one terminating, validated child result. */ + readonly outputSchema?: unknown; /** * Isolated git worktree this child runs in, created by the tool layer. The * backend only reclaims it when the session scope closes; it does not know @@ -142,7 +144,11 @@ export interface QueuedMessage { // --- Events ------------------------------------------------------------------ export type RunOutcome = - | { readonly _tag: "Completed"; readonly finalText: string } + | { + readonly _tag: "Completed"; + readonly finalText: string; + readonly structuredResult?: StructuredSubagentResult; + } | { readonly _tag: "Failed"; readonly errorText: string; @@ -232,10 +238,19 @@ export interface SubagentSnapshot { readonly queued: ReadonlyArray; /** Final text of the most recent completed run (v1 `finalOutput`). */ readonly finalText: string; + /** Present only when this run supplied and satisfied output_schema. */ + readonly structuredResult?: StructuredSubagentResult; /** Count of finalized assistant messages (for subagent_check). */ readonly turns: number; } +export interface StructuredSubagentResult { + readonly value: unknown; + readonly json: string; + readonly byteLength: number; + readonly artifactPath: string; +} + /** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */ export function latestText(snap: SubagentSnapshot) { const live = snap.liveAssistant?.text.trim(); diff --git a/extensions/subagents/src/manager.ts b/extensions/subagents/src/manager.ts index 033bcd30..4df2098a 100644 --- a/extensions/subagents/src/manager.ts +++ b/extensions/subagents/src/manager.ts @@ -123,6 +123,7 @@ interface MutableSnapshot { liveTools: LiveToolState[]; queued: SubagentSnapshot["queued"]; finalText: string; + structuredResult?: SubagentSnapshot["structuredResult"]; turns: number; } @@ -363,6 +364,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.outcome = "completed"; s.errorText = undefined; s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH); + s.structuredResult = outcome.structuredResult; break; case "Failed": s.status = "error"; @@ -373,6 +375,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => 0, FINAL_TEXT_MAX_LENGTH, ); + s.structuredResult = undefined; break; case "Interrupted": s.status = "error"; @@ -382,6 +385,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => 0, FINAL_TEXT_MAX_LENGTH, ); + s.structuredResult = undefined; break; } s.liveAssistant = undefined; @@ -446,6 +450,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.outcome = undefined; s.settledAt = undefined; s.errorText = undefined; + s.structuredResult = undefined; armWatchdog(entry); break; case "RunSettled": diff --git a/extensions/subagents/src/prompt.ts b/extensions/subagents/src/prompt.ts index 438893e6..9f4139c9 100644 --- a/extensions/subagents/src/prompt.ts +++ b/extensions/subagents/src/prompt.ts @@ -17,8 +17,8 @@ export const SUBAGENT_SCHEMA_BUDGETS = Object.freeze({ /** Describes subagent_spawn, including the fixed concurrency cap. */ export const SUBAGENT_SPAWN_TOOL_DESCRIPTION = - "Spawn a background in-process Pi subagent with its own context, child-safe tools, and normal host permissions. Returns immediately; its final result is delivered automatically. The child cannot see this conversation, ask the user, or orchestrate agents/workflows. Use only trusted working directories. " + - `Max ${MAX_RUNNING} subagents can be running at once.`; + "Spawn a background Pi subagent with isolated context and child-safe tools. Returns immediately; its result arrives automatically. It cannot see this chat, ask the user, or orchestrate. Use trusted directories. " + + `Max ${MAX_RUNNING} subagents can run at once.`; /** UTF-8 bounded, whitespace-normalized text for the parent-facing roster. */ function boundedPurpose(description: string) { @@ -152,6 +152,7 @@ export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = { 'Optional "provider/model-id" or current-provider model override. Omit to use the preset, configured role, or parent default. Never guess a model name.', reasoningEffort: "Optional child thinking level. Honor the user's requested level. Otherwise choose a level supported by the resolved child model based on the selected role and task difficulty. An explicit value overrides a role default.", + outputSchema: "Optional result JSON Schema.", }; /** The exact name/description/wire-schema source used by registration/tests. */ @@ -193,6 +194,15 @@ export function createSubagentSpawnToolSurface( description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort, }), ), + output_schema: Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.outputSchema, + }, + ), + ), }), }; } @@ -207,6 +217,7 @@ export function buildSubagentSpawnResult(options: { agentTypeName?: string; tools?: readonly string[]; worktreeBranch?: string; + structured?: boolean; }) { const typeNote = options.agentTypeName ? ` Agent type "${options.agentTypeName}" applied.` @@ -226,8 +237,11 @@ export function buildSubagentSpawnResult(options: { const worktreeNote = options.worktreeBranch ? ` Isolated in its own worktree on branch "${options.worktreeBranch}" — its edits are invisible here until you merge that branch. The checkout stays available for later send/review and is reclaimed on Session retirement only when bounded inspection proves it empty.` : ""; + const structuredNote = options.structured + ? " This run must finish with the requested validated structured result." + : ""; return ( - `Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}\n` + + `Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}${structuredNote}\n` + `It runs in the background — keep working on independent work. If none remains in an interactive session, briefly tell the user it is still running and end your turn; its result is delivered automatically and you are automatically re-invoked when it finishes. Do not poll or call subagent_wait merely because a later step depends on it. ` + `Use subagent_wait(ids: ["${options.id}"]) only if the user explicitly asked you to keep the current response open for this result, or a non-interactive automation must return it in the same invocation; subagent_cancel stops it, subagent_check peeks at a running one, subagent_list shows all.` ); diff --git a/extensions/subagents/src/result-artifact.ts b/extensions/subagents/src/result-artifact.ts index 066dd3da..c6cd1b5b 100644 --- a/extensions/subagents/src/result-artifact.ts +++ b/extensions/subagents/src/result-artifact.ts @@ -77,6 +77,38 @@ export function persistResultArtifact(agentDir: string, content: string) { return artifactPath; } +/** Persist one complete validated structured value under a JSON identity. */ +export function persistStructuredResultArtifact( + agentDir: string, + content: string, +) { + let directory = path.resolve(agentDir); + for (const segment of RESULT_ARTIFACT_DIR) { + directory = ensureDirectory(directory, segment); + } + + const digest = createHash("sha256").update(content).digest("hex"); + const artifactPath = path.join(directory, `${digest}.json`); + try { + writeFileSync(artifactPath, content, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const stat = lstatSync(artifactPath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + readFileSync(artifactPath, "utf8") !== content + ) { + throw new Error(`Structured result artifact collision: ${artifactPath}`); + } + } + return artifactPath; +} + /** * Build the single model-visible projection used by automatic delivery and * explicit waits. Short answers pass through byte-for-byte. Long answers keep diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index a56afddc..31c6cc45 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -75,14 +75,6 @@ export function buildWorkflowAgentPrompt(prompt: string) { return prompt; } -/** Instructs structured workflow children to terminate with exactly one structured_output call. */ -export const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION = - "When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it."; - -/** Describes the terminating structured_output tool and its final-action contract. */ -export const STRUCTURED_OUTPUT_TOOL_DESCRIPTION = - "Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it."; - /** Builds the workflow completion report returned to the parent model. */ export function buildWorkflowResultMessage( details: WorkflowDetails, diff --git a/extensions/workflows/runner.ts b/extensions/workflows/runner.ts index d99463da..12c9686e 100644 --- a/extensions/workflows/runner.ts +++ b/extensions/workflows/runner.ts @@ -16,14 +16,12 @@ import { type AgentSessionEventListener, createAgentSession, DefaultResourceLoader, - defineTool, type ExtensionAPI, type ExtensionContext, SessionManager, SettingsManager, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { type TSchema, Type } from "typebox"; import { AgentToolRenderLedger } from "../shared/agent-tool-renderer.ts"; import { bindChildSessionExtensions, @@ -34,10 +32,11 @@ import { import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts"; import { type AgentUsage, emptyUsage, type TranscriptEntry } from "./model.ts"; import { - buildWorkflowAgentPrompt, + childToolsWithStructuredOutput, + createStructuredOutputTool, STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION, - STRUCTURED_OUTPUT_TOOL_DESCRIPTION, -} from "./prompt.ts"; +} from "../shared/structured-output.ts"; +import { buildWorkflowAgentPrompt } from "./prompt.ts"; import { AgentProgressProjection, type ProgressAssistantMessage, @@ -142,9 +141,7 @@ export function workflowChildTools( tools: readonly string[] | undefined, structured: boolean, ) { - return tools - ? [...new Set([...tools, ...(structured ? ["structured_output"] : [])])] - : undefined; + return childToolsWithStructuredOutput(tools, structured); } interface WorkflowToolSession { @@ -179,68 +176,6 @@ export function guardWorkflowChildTools( }); } -function isJsonSchema(value: unknown): value is TSchema { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const seen = new WeakSet(); - let nodes = 0; - const validate = (current: unknown, depth: number): boolean => { - if (++nodes > 10_000 || depth > 24) return false; - if ( - current === null || - typeof current === "string" || - typeof current === "boolean" - ) { - return true; - } - if (typeof current === "number") return Number.isFinite(current); - if (Array.isArray(current)) { - return current.every((item) => validate(item, depth + 1)); - } - if (typeof current !== "object") return false; - if (seen.has(current)) return false; - seen.add(current); - return Object.keys(current).every((key) => { - if (key === "__proto__" || key === "constructor" || key === "prototype") { - return false; - } - return validate((current as Record)[key], depth + 1); - }); - }; - return validate(value, 0); -} - -/** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */ -function jsonSchemaToTypebox(schema: unknown): TSchema { - if (!isJsonSchema(schema)) { - throw new Error("structured output schema must be a bounded JSON object"); - } - return Type.Unsafe(schema); -} - -/** - * One-shot terminating tool injected when a schema is supplied: the subagent - * calls it as its final action and we capture the validated object. - */ -function makeStructuredOutputTool( - schema: unknown, - capture: (value: unknown) => void, -): ToolDefinition { - return defineTool({ - name: "structured_output", - label: "Structured Output", - description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION, - parameters: jsonSchemaToTypebox(schema), - async execute(_toolCallId, params) { - capture(params); - return { - content: [{ type: "text", text: "Recorded structured result." }], - details: params, - terminate: true, - }; - }, - }); -} - type AssistantMessage = ProgressAssistantMessage; export { transcriptFromMessages }; @@ -475,7 +410,7 @@ export async function runAgent( customTools = options.schema !== undefined ? [ - makeStructuredOutputTool(options.schema, (value) => { + createStructuredOutputTool(options.schema, (value) => { if (!settled) structured = value; }), ] diff --git a/skills/subagents/REFERENCE.md b/skills/subagents/REFERENCE.md index 7d44b306..99ff4fc0 100644 --- a/skills/subagents/REFERENCE.md +++ b/skills/subagents/REFERENCE.md @@ -153,8 +153,9 @@ So `tools: [read, grep, find, ls]` yields a child that genuinely has no `write`, `edit`, or `bash` tool to call — not one that has been asked not to. Parent-only names are removed before the generated roster and spawn result are shown, so a type that lists `subagent_spawn` never advertises it as usable. -A structured Workflow child additionally receives only its terminating -`structured_output` tool; this does not restore any denied repository tool. +A Workflow child with a schema, or a Direct Subagent spawned with +`output_schema`, additionally receives only its terminating `structured_output` +tool; this does not restore any denied repository tool. While `/plan` is armed, `isolation: "worktree"` is rejected before Git is changed. A selected type whose declared tools plan mode would narrow (such as diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 3a0da05b..c454cb35 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -13,6 +13,7 @@ The tool definitions are canonical for parameters, limits, model syntax, isolati - Prefer a matching agent type when one exists; its tool restriction is enforced. Model precedence is explicit spawn override, selected type-file model, configured built-in role model, then parent model. Reasoning precedence is explicit spawn override, selected type default, then parent effort. Types live in `~/.pi/agent/agents/*.md` and, for trusted projects, `.pi/agents/*.md`; see [Agent types](REFERENCE.md). - Isolate concurrent writers in worktrees according to the `subagent_spawn` schema so they cannot overwrite one checkout or git index. While Plan Mode is active, use only read-only exploration types (or no type); worktree isolation and types narrowed by Plan Mode are rejected. - After spawning, continue useful parent work. In an interactive session, if none remains, tell the user the child is still running and end the turn; automatic result delivery will re-invoke the parent when it settles. Do not block merely because the next step depends on the result or because there is nothing else to do. Use `subagent_wait` only when the user explicitly asks to keep the current response open for the result, or when non-interactive automation must return it in the same invocation. +- Use optional `output_schema` when downstream work needs a machine-validated result rather than prose. The child then receives one terminating `structured_output` tool, and the run fails if it finishes without submitting a matching value. Keep schemas small and task-specific; the validated JSON is delivered to the parent and preserved in a private content-addressed artifact. Omit the option for ordinary text reports. ## Worktree isolation diff --git a/tests/extensions/shared/structured-output.test.ts b/tests/extensions/shared/structured-output.test.ts new file mode 100644 index 00000000..14173449 --- /dev/null +++ b/tests/extensions/shared/structured-output.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + createStructuredOutputTool, + encodeStructuredResult, + jsonSchemaToTypebox, + STRUCTURED_RESULT_LIMITS, +} from "../../../extensions/shared/structured-output.ts"; + +test("the shared terminating tool captures one complete validated JSON value", async () => { + const captured: unknown[] = []; + const tool = createStructuredOutputTool( + { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + additionalProperties: false, + }, + (result) => captured.push(result), + ); + const result = await tool.execute( + "call-1", + { answer: "ready" }, + undefined, + undefined, + {} as ExtensionContext, + ); + + assert.equal(result.terminate, true); + assert.deepEqual(captured, [{ answer: "ready" }]); + assert.deepEqual(encodeStructuredResult(captured[0]), { + value: { answer: "ready" }, + json: '{"answer":"ready"}', + byteLength: 18, + }); +}); + +test("schema and result bounds fail closed without producing partial JSON", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + assert.throws(() => jsonSchemaToTypebox(cyclic), /bounded JSON object/); + assert.throws(() => encodeStructuredResult(cyclic), /acyclic JSON values/); + assert.throws( + () => + encodeStructuredResult({ + text: "x".repeat(STRUCTURED_RESULT_LIMITS.resultStringBytes + 1), + }), + /oversized string/, + ); + assert.throws( + () => encodeStructuredResult({ value: Number.NaN }), + /only acyclic JSON values/, + ); +}); diff --git a/tests/extensions/subagents/index.test.ts b/tests/extensions/subagents/index.test.ts index a183ec3a..ff826f19 100644 --- a/tests/extensions/subagents/index.test.ts +++ b/tests/extensions/subagents/index.test.ts @@ -89,6 +89,52 @@ test("subagent results render before the hidden wake-up message", () => { ]); }); +test("automatic delivery exposes structured data and its canonical artifact", () => { + let entry: { content: string; details: Record } | undefined; + const pi = { + appendEntry( + _customType: string, + data: { content: string; details: Record }, + ) { + entry = data; + }, + sendMessage() {}, + } as unknown as ExtensionAPI; + const dispatch = createSubagentResultDispatcher(pi); + dispatch([ + { + id: "sa-structured", + origin: "model", + backend: "pi", + title: "structured review", + prompt: "review", + cwd: process.cwd(), + status: "done", + outcome: "completed", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcriptVersion: 0, + transcript: [], + liveTools: [], + queued: [], + finalText: "", + structuredResult: { + value: { verdict: "pass" }, + json: '{"verdict":"pass"}', + byteLength: 18, + artifactPath: "/tmp/structured.json", + }, + turns: 1, + }, + ]); + + assert.match(entry?.content ?? "", /\{"verdict":"pass"\}/); + assert.deepEqual(entry?.details.structured, { verdict: "pass" }); + assert.equal(entry?.details.structuredArtifactPath, "/tmp/structured.json"); +}); + test("automatic result projection keeps both ends and persists the exact final answer", () => { const finalText = `BEGIN\n${"evidence\n".repeat(100)}FINAL-VERDICT`; let persisted = ""; diff --git a/tests/extensions/subagents/pi-backend-lifecycle.test.ts b/tests/extensions/subagents/pi-backend-lifecycle.test.ts index 8a39f828..307efea7 100644 --- a/tests/extensions/subagents/pi-backend-lifecycle.test.ts +++ b/tests/extensions/subagents/pi-backend-lifecycle.test.ts @@ -1,8 +1,12 @@ import assert from "node:assert/strict"; +import { readFile, rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; import type { AgentSession, CreateAgentSessionOptions, + ExtensionContext, ModelRegistry, } from "@earendil-works/pi-coding-agent"; import { Effect, Layer, ManagedRuntime, Stream } from "effect"; @@ -140,7 +144,7 @@ function createManagerRuntime( async function spawnDirect( backend: SubagentBackend, spawnTask: SpawnTask, - drive: (session: SubagentSession) => void, + drive: (session: SubagentSession) => void | Promise, ) { const events: SubagentEvent[] = []; const firstSettlement = deferred(); @@ -156,7 +160,7 @@ async function spawnDirect( }), ), ); - drive(session); + yield* Effect.promise(() => Promise.resolve(drive(session))); yield* Effect.promise(() => firstSettlement.promise); }), ), @@ -257,6 +261,98 @@ test("the production Pi adapter bridges startup, first response, tools, usage, a assert.equal(harness.calls.disposals, 1); }); +test("a direct subagent validates, persists, and delivers structured output", async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-structured-child-"), + ); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + const fixtures = harnessFactory(); + const backend = makePiBackend({ + sessionFactory: fixtures.factory, + shutdownTimeoutMs: 50, + }); + const events = await spawnDirect( + backend, + task("return a verdict", { + outputSchema: { + type: "object", + properties: { verdict: { type: "string" } }, + required: ["verdict"], + additionalProperties: false, + }, + }), + async () => { + const creation = fixtures.creations[0]; + const harness = fixtures.harnesses[0]; + assert.ok(creation); + assert.ok(harness); + assert.deepEqual(creation.tools, ["read", "structured_output"]); + assert.equal(creation.customTools?.length, 1); + await creation.customTools?.[0]?.execute( + "structured", + { verdict: "pass" }, + undefined, + undefined, + {} as ExtensionContext, + ); + harness.setStreaming(true); + harness.emit({ type: "agent_start" }); + harness.emitAssistant(""); + harness.setStreaming(false); + harness.emit({ type: "agent_settled" }); + harness.resolvePrompt(); + }, + ); + const settled = events.find((event) => event._tag === "RunSettled"); + assert.equal(settled?._tag, "RunSettled"); + if (settled?._tag !== "RunSettled") return; + assert.equal(settled.outcome._tag, "Completed"); + if (settled.outcome._tag !== "Completed") return; + assert.deepEqual(settled.outcome.structuredResult?.value, { + verdict: "pass", + }); + const artifactPath = settled.outcome.structuredResult?.artifactPath; + assert.ok(artifactPath); + assert.equal(await readFile(artifactPath, "utf8"), '{"verdict":"pass"}'); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("a direct structured subagent fails when it never submits", async () => { + const fixtures = harnessFactory(); + const backend = makePiBackend({ + sessionFactory: fixtures.factory, + shutdownTimeoutMs: 50, + }); + const events = await spawnDirect( + backend, + task("forget structured output", { + outputSchema: { type: "object", additionalProperties: true }, + }), + () => { + const harness = fixtures.harnesses[0]; + assert.ok(harness); + harness.setStreaming(true); + harness.emit({ type: "agent_start" }); + harness.emitAssistant("plain text only"); + harness.setStreaming(false); + harness.emit({ type: "agent_settled" }); + harness.resolvePrompt(); + }, + ); + const settled = events.find((event) => event._tag === "RunSettled"); + assert.equal(settled?._tag, "RunSettled"); + if (settled?._tag !== "RunSettled") return; + assert.equal(settled.outcome._tag, "Failed"); + if (settled.outcome._tag !== "Failed") return; + assert.match(settled.outcome.errorText, /without calling structured_output/); +}); + test("prompt rejection wins over an earlier agent_settled event", async () => { const prompt = deferred(); const fixtures = harnessFactory(() => ({ diff --git a/tests/extensions/subagents/prompt.test.ts b/tests/extensions/subagents/prompt.test.ts index 5bdfab62..a62df2bb 100644 --- a/tests/extensions/subagents/prompt.test.ts +++ b/tests/extensions/subagents/prompt.test.ts @@ -115,6 +115,24 @@ test("an explicit user-selected reasoning level remains available", () => { ); }); +test("output_schema is optional and validates the schema container", () => { + const schema = + createSubagentSpawnToolSurface(BUILT_IN_AGENT_TYPES).parameters; + const task = { prompt: "Review", name: "review" }; + assert.equal(Value.Check(schema, task), true); + assert.equal( + Value.Check(schema, { + ...task, + output_schema: { + type: "object", + properties: { verdict: { type: "string" } }, + }, + }), + true, + ); + assert.equal(Value.Check(schema, { ...task, output_schema: [] }), false); +}); + test("the default spawn surface stays within its resident budget", () => { assert.ok( spawnSurfaceBytes(BUILT_IN_AGENT_TYPES) <= diff --git a/tests/extensions/subagents/result-artifact.test.ts b/tests/extensions/subagents/result-artifact.test.ts index 2749289c..e77c33a6 100644 --- a/tests/extensions/subagents/result-artifact.test.ts +++ b/tests/extensions/subagents/result-artifact.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import test from "node:test"; import { persistResultArtifact, + persistStructuredResultArtifact, projectResult, } from "../../../extensions/subagents/src/result-artifact.ts"; @@ -146,6 +147,23 @@ test("content-addressed artifacts are exact, private, and reusable", async () => } }); +test("structured artifacts use an immutable JSON identity", async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-structured-result-artifact-"), + ); + try { + const content = '{"verdict":"pass"}'; + const first = persistStructuredResultArtifact(agentDir, content); + const second = persistStructuredResultArtifact(agentDir, content); + assert.equal(first, second); + assert.match(first, /\.json$/); + assert.equal(await readFile(first, "utf8"), content); + assert.equal((await lstat(first)).mode & 0o777, 0o600); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + test("artifact persistence refuses a symlinked cache component", async () => { const agentDir = await mkdtemp(path.join(tmpdir(), "openpi-result-symlink-")); const outside = await mkdtemp(path.join(tmpdir(), "openpi-result-outside-")); From 6c49c2779b66979652a5a54011f28b1b097e08c5 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:14:10 +0800 Subject: [PATCH 2/2] ci: retry Node 22 checks