Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

</details>

<details>
Expand Down
154 changes: 154 additions & 0 deletions extensions/shared/structured-output.ts
Original file line number Diff line number Diff line change
@@ -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<object>();
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<string, unknown>)[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<object>();
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;
}
43 changes: 39 additions & 4 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ interface SpawnResultDetails {
readonly harness?: string;
readonly model?: string;
readonly agentType?: string;
readonly structured?: boolean;
}

interface SubagentFinishedData {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
{
Expand Down Expand Up @@ -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,
Expand All @@ -388,6 +404,12 @@ export function createSubagentResultDispatcher(
...(projections[index]!.artifactSaveFailed
? { artifactSaveFailed: true }
: {}),
...(snap.structuredResult
? {
structured: snap.structuredResult.value,
structuredArtifactPath: snap.structuredResult.artifactPath,
}
: {}),
})),
};
pi.appendEntry<SubagentResultEntryData>("subagent-result", {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1000,6 +1025,9 @@ export default function (
...(worktree ? { worktreeBranch: worktree.branch } : {}),
...(agentType ? { agentTypeName: agentType.name } : {}),
...(childTools ? { tools: childTools } : {}),
...(params.output_schema !== undefined
? { structured: true }
: {}),
}),
},
],
Expand All @@ -1010,6 +1038,7 @@ export default function (
harness,
model: snap.meta.modelLabel,
...(agentType ? { agentType: agentType.name } : {}),
...(params.output_schema !== undefined ? { structured: true } : {}),
},
};
},
Expand Down Expand Up @@ -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(),
{
Expand Down Expand Up @@ -1182,6 +1211,12 @@ export default function (
...(artifactSaveFailures.has(id)
? { artifactSaveFailed: true }
: {}),
...(snap?.structuredResult
? {
structured: snap.structuredResult.value,
structuredArtifactPath: snap.structuredResult.artifactPath,
}
: {}),
};
}),
},
Expand Down
Loading
Loading