/** * Result submission tool for subagent output. * * Subagents can call this tool incrementally or terminally depending on `type`. */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { TSchema } from "@oh-my-pi/pi-ai/types"; import { dereferenceJsonSchema, isValidJsonSchema, type JsonSchemaValidationResult, sanitizeSchemaForStrictMode, tryEnforceStrictSchema, } from "@oh-my-pi/pi-ai/utils/schema"; import { subprocessToolRegistry } from "../task/subprocess-tool-registry"; import type { ToolSession } from "."; import { buildOutputValidator, formatAllValidationIssues } from "./output-schema-validator"; export interface YieldDetails { /** Successful result payload, or omitted when `useLastTurn` requests last-turn extraction. */ data?: unknown; status: "success" | "aborted"; error?: string; /** Optional result section/classification supplied by the yield caller. */ type?: string | string[]; /** True when the caller intentionally omitted success data so the executor uses the last assistant turn. */ useLastTurn?: boolean; /** * Set when the yield tool exhausted its in-tool schema-retry budget * (MAX_SCHEMA_RETRIES) and accepted the data anyway. Surfaced so the * executor's post-mortem finalizer can honor the override instead of * re-rejecting the same payload with `schema_violation` — keeping the * subagent's acceptance and the parent's view of the result in lockstep. */ schemaOverridden?: boolean; } function formatSchema(schema: unknown): string { if (schema === undefined) return "No schema provided."; if (typeof schema === "string") return schema; try { return JSON.stringify(schema, null, 2); } catch { return "[unserializable schema]"; } } function looseRecordSchema(description: string): Record { return { type: "object", additionalProperties: true, description, }; } function hasUnresolvedRefs(schema: unknown): boolean { if (schema == null) return false; if (Array.isArray(schema)) { for (const item of schema) { if (hasUnresolvedRefs(item)) return true; } return false; } if (typeof schema !== "object") return false; const record = schema as Record; if (typeof record.$ref === "string") return true; for (const key in record) { if (key === "const" || key === "default" || key === "enum" || key === "examples") continue; if (hasUnresolvedRefs(record[key])) return true; } return false; } const yieldTypeSchema: Record = { anyOf: [ { type: "string" }, { type: "array", minItems: 1, items: { type: "string" }, }, ], description: "Optional result type. A non-empty string array is incremental; a string is terminal.", }; function isYieldType(value: unknown): value is string | string[] { return ( typeof value === "string" || (Array.isArray(value) && value.length > 0 && value.every(item => typeof item === "string")) ); } function parseYieldType(value: unknown): string | string[] | undefined { // Strict-mode providers (OpenAI/Codex) make the optional `type` property // required+nullable, so an untyped final yield arrives as `type: null`. if (value === undefined || value === null) return undefined; if (isYieldType(value)) return value; throw new Error("type must be a string or non-empty array of strings"); } /** * Render an incremental yield's `type: [...]` labels as a quoted, comma-separated list for * model-facing retry messages — keeps the failed section labelled even when the yield carried * multiple labels at once. */ function formatYieldLabels(labels: readonly string[]): string { if (labels.length === 0) return '""'; return labels.map(label => `"${label}"`).join(", "); } /** * Expand a plain-object `data` schema into a strict union that ALSO accepts each * top-level section value (and array element) on its own. Agents that yield * incrementally (`type: ["findings"]`, `type: ["confidence"]`, …) submit one * section per call, so `data` is a single finding object or a lone verdict value * — never the full output object. Without this, strict-mode providers constrain * `data` to the whole schema and reject/—under constrained decoding—forbid the * partial. Every branch is a typed sub-schema, so strict representability holds; * the full-output object stays the first (terminal) branch. The assembled whole * is still validated against the full schema at finalization. Non-object / loose * schemas are returned unchanged. */ function withSectionVariants(dataSchema: Record): Record { if (dataSchema.type !== "object") return dataSchema; const props = dataSchema.properties; if (props === null || typeof props !== "object") return dataSchema; const propRecord = props as Record; const { description, ...fullWithoutDescription } = dataSchema; const branches: unknown[] = []; const seen = new Set(); const add = (schema: unknown): void => { if (schema === null || typeof schema !== "object") return; const key = JSON.stringify(schema); if (seen.has(key)) return; seen.add(key); branches.push(schema); }; add(fullWithoutDescription); for (const name in propRecord) { const prop = propRecord[name]; add(prop); if (prop !== null && typeof prop === "object") { const propObj = prop as Record; if (propObj.type === "array") add(propObj.items); } } if (branches.length <= 1) return dataSchema; return description !== undefined ? { description, anyOf: branches } : { anyOf: branches }; } function wrapYieldParameters(dataSchema: Record): Record { const successResultSchema = { type: "object", additionalProperties: false, description: "task succeeded", properties: { data: dataSchema }, required: ["data"], }; const errorResultSchema = { type: "object", additionalProperties: false, properties: { error: { type: "string", description: "error message" }, }, required: ["error"], }; const lastTurnResultSchema = { type: "object", additionalProperties: false, description: "typed task succeeded; data omitted so the last assistant turn is used", properties: {}, required: [], }; // The "an empty `result` (last-turn) requires a `type`" invariant is enforced // in `execute()` at runtime, NOT in this schema: a top-level combinator // (`allOf`/`anyOf`/`oneOf`/...) makes OpenAI/Codex Responses reject the whole // tool with `invalid_function_parameters`, so the wrapper stays a plain object. return { type: "object", additionalProperties: false, description: "submit data or error", properties: { type: yieldTypeSchema, result: { anyOf: [successResultSchema, errorResultSchema, lastTurnResultSchema], }, }, required: ["result"], }; } /** * Max consecutive schema-validation failures before the yield tool overrides validation * and lets non-conforming data through. The override is a safety net for schemas the * JTD→JSON-Schema converter cannot fully express; it should not be reached during normal * model retries. Three matches the existing "3 reminders" pattern elsewhere in the agent * runtime. */ const MAX_SCHEMA_RETRIES = 3; export class YieldTool implements AgentTool { readonly name = "yield"; readonly approval = "read" as const; readonly label = "Submit Result"; readonly description = "Submit subagent output. Omit `type` for the usual final structured result.\n\n" + 'Pass `type: ["section"]` to submit an incremental, non-terminal section that accumulates. Pass `type: "result"` to finalize; when `data` is omitted, your last assistant turn becomes the raw final result.\n' + 'Use `result: { data: }` for success, or `result: { error: "message" }` for failure. Keep the `result` wrapper.'; readonly parameters: TSchema; strict = true; readonly intent = "omit" as const; lenientArgValidation = true; readonly #validate?: (value: unknown) => JsonSchemaValidationResult; readonly #validateSection?: ReadonlyMap JsonSchemaValidationResult>; #schemaValidationFailures = 0; constructor(session: ToolSession) { let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined; let validateSection: ReadonlyMap JsonSchemaValidationResult> | undefined; let parameters: TSchema; try { const { validator, jsonSchema: normalizedSchema, normalized, error: schemaError, } = buildOutputValidator(session.outputSchema); if (validator) { validate = value => validator.validate(value); validateSection = validator.validateSection; } const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema); const schemaDescription = schemaError ? `Structured JSON output (output schema invalid; accepting unconstrained object): ${schemaError}` : `Structured output matching the schema:\n${schemaHint}`; let sanitizedSchema: Record | undefined; if (!schemaError && normalizedSchema !== undefined) { const strictProbe = tryEnforceStrictSchema(normalizedSchema); if (strictProbe.strict) { sanitizedSchema = sanitizeSchemaForStrictMode(normalizedSchema); } else { sanitizedSchema = normalizedSchema; this.strict = false; } } else if (!schemaError && normalized === true) { sanitizedSchema = {}; this.strict = false; } let dataSchema: Record; if (sanitizedSchema !== undefined) { const resolved = dereferenceJsonSchema({ ...sanitizedSchema, description: schemaDescription, }) as Record; if (hasUnresolvedRefs(resolved)) { throw new Error("schema contains unresolved $ref after dereferencing"); } dataSchema = withSectionVariants(resolved); } else { this.strict = false; dataSchema = looseRecordSchema( schemaError ? schemaDescription : "Structured JSON output (no schema specified)", ); } parameters = wrapYieldParameters(dataSchema); JSON.stringify(parameters); if (!isValidJsonSchema(parameters)) throw new Error("yield parameters schema is invalid"); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); parameters = wrapYieldParameters( looseRecordSchema(`Structured JSON output (schema processing failed: ${errorMsg})`), ); validate = undefined; this.strict = false; } this.#validate = validate; this.#validateSection = validateSection; this.parameters = parameters; } async execute( _toolCallId: string, params: unknown, _signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, ): Promise> { const raw = params as Record; const rawResult = raw.result; if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) { throw new Error("result must be an object containing either data or error"); } const resultRecord = rawResult as Record; const errorMessage = typeof resultRecord.error === "string" ? resultRecord.error : undefined; const data = resultRecord.data; const yieldType = parseYieldType(raw.type); const useLastTurn = errorMessage === undefined && data === undefined && yieldType !== undefined && !("error" in resultRecord); // Incremental array-typed sections carry partial data (one finding, one // field) that cannot satisfy the full output schema; the assembled result // is validated as a whole at finalization (executor finalizeSubprocessOutput). const isIncremental = Array.isArray(yieldType) && yieldType.length > 0; if (errorMessage !== undefined && data !== undefined) { throw new Error("result cannot contain both data and error"); } if (errorMessage === undefined && data === undefined && yieldType === undefined) { throw new Error( 'result must contain either `data` or `error`. Use `{result: {data: }}` for success or `{result: {error: "message"}}` for failure.', ); } const status = errorMessage !== undefined ? "aborted" : "success"; let schemaValidationOverridden = false; if (status === "success" && !useLastTurn) { if (data === null) { throw new Error("data is required when yield indicates success"); } const sectionFailure = isIncremental ? this.#validateIncrementalSection(yieldType as string[], data) : this.#validate ? this.#validate(data) : undefined; if (sectionFailure && !sectionFailure.success) { this.#schemaValidationFailures++; if (this.#schemaValidationFailures <= MAX_SCHEMA_RETRIES) { const remaining = MAX_SCHEMA_RETRIES - this.#schemaValidationFailures; const retryHint = remaining > 0 ? ` Call yield again with the corrected shape — ${remaining} retry attempt(s) remain before the schema constraint is dropped.` : " Call yield again with the corrected shape — this is the final retry before the schema constraint is dropped."; const scope = isIncremental ? `Section ${formatYieldLabels(yieldType as string[])}` : "Output"; throw new Error( `${scope} does not match schema: ${formatAllValidationIssues(sectionFailure.issues)}.${retryHint}`, ); } schemaValidationOverridden = true; } } const responseText = status === "aborted" ? `Task aborted: ${errorMessage}` : schemaValidationOverridden ? `Result submitted (schema validation overridden after ${this.#schemaValidationFailures} failed attempt(s)).` : "Result submitted."; return { content: [{ type: "text", text: responseText }], details: { data, status, error: errorMessage, type: yieldType, useLastTurn: useLastTurn || undefined, schemaOverridden: schemaValidationOverridden || undefined, }, }; } /** * Validate the `data` payload of an incremental yield (`type: ["