Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Breaking changes

- **StructuredOutput publishes the validated result file** — In `--output-schema` mode the `StructuredOutput` tool now takes only `{ "path": "<file>" }`. The model writes its final result as JSON to a file in the working directory and passes the path; the CLI follows symlinks, keeps the file inside the working directory, caps it at 2 MiB, validates it with Ajv and publishes the parsed file. Passing the result object as tool arguments is no longer accepted. Callers whose prompts say "call StructuredOutput with the result" must say to write a file and pass its path. Only `{ path }` reaches the provider, so provider schema conversion can no longer drop result fields (Gemini emptied map-typed fields, #139), and the model no longer re-emits the whole result as output tokens. Rejected files are counted corrective turns that ask the model to fix the file; their fixed diagnostics are listed in EVENTS.md. The prose-only `missing` diagnostic and reminder now read "write the final result as JSON to a file in the working directory, then call StructuredOutput with its path". (#140)

### Fixes

- **Provider error finishes fail headless execution** — Normal streams ending in `error` or `content-filter` now persist a structured provider failure, emit consistent failure events, and exit nonzero while retaining partial output and usage. Empty successful responses remain successful. (#108)
Expand Down
30 changes: 24 additions & 6 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,9 @@ does not coerce types, insert defaults or remove extra properties.
Serialized schemas are limited to 64 KiB, depth 64 and 10,000 nested objects.
The schema file is trusted instruction input: annotations (`description`, `title`,
`examples`) are shown to the model in the StructuredOutput tool description.
StructuredOutput takes only `{ "path": "<file>" }`: the model writes the result as
JSON to a file inside the working directory and passes its path. The CLI publishes
the parsed file contents (#140).

`--output-schema-retries <n>` permits N additional corrective turns (0–10, default 2).
Zero permits only the initial turn; N permits the initial turn and at most N
Expand All @@ -557,8 +560,8 @@ value in the terminal event below.

### `structured_output_rejected`

Emitted once per rejected StructuredOutput attempt, including unparseable JSON
and a model turn that finishes in prose without a final tool call:
Emitted once per rejected StructuredOutput attempt, including an unusable result
file and a model turn that finishes in prose without a final tool call:

```json
{
Expand All @@ -577,13 +580,28 @@ is under 2 KB and never includes submitted values or raw JSON arguments. A `path
can contain property names submitted by the model (for example, in map or
`patternProperties` schemas), never their values. Each path segment is clipped
to 64 characters; paths also have an overall length bound.
Validation feedback goes to the model in the same session through the `invalid` tool.
A result file that cannot be used emits one fixed diagnostic with `path: ""`:

| `keyword` | `message` |
|---|---|
| `file` | `working directory unavailable` |
| `file` | `file not found` |
| `file` | `path must stay inside the working directory` |
| `file` | `file not readable` |
| `file` | `path must be a regular file` |
| `file` | `file must not exceed 2097152 bytes` |
| `parse` | `file must contain complete, valid JSON` |

A file that parses but fails the schema emits Ajv diagnostics as above.
Feedback for a rejected file goes to the model as the StructuredOutput tool error and
asks it to fix the file at the JSON-encoded path. Malformed tool arguments (not a
`{ path }` object) go to the model through the `invalid` tool.
A prose-only finish emits the fixed diagnostic
`{ "path": "", "keyword": "missing", "message": "call StructuredOutput with the final result" }`
`{ "path": "", "keyword": "missing", "message": "write the final result as JSON to a file in the working directory, then call StructuredOutput with its path" }`
and receives an ephemeral reminder before the next turn while budget remains.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Prose-only diagnostic still teaches old contract.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #142, EVENTS.md:591-592):

Problem: Prose-only diagnostic still teaches old contract
Detail: This PR rewires the StructuredOutput contract to file form and updates the ephemeral reminder text, but the retained fixed diagnostic for a prose-only finish (emitted in prompt.ts and asserted unchanged in the tests) still tells the model "call StructuredOutput with the final result" — the old contract wording this PR explicitly removes everywhere else. On a prose-only retry the model receives contradictory instructions (diagnostic vs reminder), inviting it to call the tool with the result as arguments, which is now rejected.
Suggested fix: Update the missing-tool-call diagnostic in prompt.ts (and this EVENTS.md example) to the file form, e.g. `"message": "write the final result as JSON to a file in the working directory, then call StructuredOutput with its path"`, keeping the diagnostic and reminder consistent.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

This PR rewires the StructuredOutput contract to file form and updates the ephemeral reminder text, but the retained fixed diagnostic for a prose-only finish (emitted in prompt.ts and asserted unchanged in the tests) still tells the model "call StructuredOutput with the final result" — the old contract wording this PR explicitly removes everywhere else. On a prose-only retry the model receives contradictory instructions (diagnostic vs reminder), inviting it to call the tool with the result as arguments, which is now rejected.

object) go to the model through the `invalid` tool.\nA prose-only finish emits the fixed diagnostic\n{ \"path\": \"\", \"keyword\": \"missing\", \"message\": \"call StructuredOutput with the final result\" }\nand receives an ephemeral reminder before the next turn while budget remains.

Other tools remain usable until a valid final result; their outputs are not final
results. Provider-compatible tool schemas may be transformed, while local
validation always uses the canonical schema.
results. Only the `{ path }` tool schema is sent to the provider; local validation
of the file always uses the canonical schema.

### `structured_output`

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ aictrl run --format json "review this PR" | jq '.type'
`aictrl run --output-schema <file>` validates the final JSON result.
Use `--output-schema-retries <n>` for additional corrective turns (0–10, default 2)
and `--output-result <file>` to atomically write the accepted value.
The agent writes the result to a JSON file in the working directory and calls
`StructuredOutput` with its path; the CLI validates and publishes that file.
Zero retries permits only the initial turn; N permits the initial turn and at
most N corrective turns. Serialized schemas are limited to 64 KiB, depth 64 and 10,000
nested objects.
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ aictrl run --output-schema result.schema.json --output-schema-retries 2 \
Relative `--output-schema` and `--output-result` paths resolve against `--dir` when
it is given (as `--file` paths do); configuration errors print the resolved path.

**StructuredOutput contract (#140).** The model writes its final result as JSON
to a file in the working directory (`--dir`, or the attached server's session
directory), then calls `StructuredOutput` with
`{ "path": "<file>" }`. The tool takes only that argument; the canonical schema is
text in the tool description, so provider schema conversion cannot drop result
fields. The CLI reads the file and publishes its parsed contents, not a re-emitted
copy. The file must:

- resolve inside the working directory after following symlinks (relative paths
resolve against it; absolute paths inside it are accepted);
- be a regular file of at most 2 MiB;
- contain complete JSON that the schema accepts.

A file that fails any of these is a counted rejection. The model is told to fix the
file at that path and call `StructuredOutput` again with its path. Calling
`StructuredOutput` with the result object as arguments is no longer supported.
Callers whose prompts say to pass the result as tool arguments must say to write a
file and pass its path instead.

Ajv strict mode rejects unknown schema keywords and unregistered `format`
values such as `date-time` and `uri` as configuration errors (exit **2**).
The result parent directory must exist and be writable at configuration time.
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,11 @@ export const RunCommand = cmd({
default: "default",
describe: "format: default (formatted) or json (raw JSON events)",
})
.option("output-schema", { type: "string", describe: "JSON Schema file for the final result (object root)" })
.option("output-schema", {
type: "string",
describe:
"JSON Schema file for the final result (object root); the agent writes the result to a JSON file in the working directory and passes its path to StructuredOutput",
})
.option("output-schema-retries", { type: "number", describe: "additional corrective turns (0-10, default: 2)" })
.option("output-result", {
type: "string",
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/session/output-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Ajv, { type ErrorObject } from "ajv"
import Ajv2020 from "ajv/dist/2020"
import z from "zod"
import { createHash } from "crypto"
import path from "path"
import fs from "fs/promises"

export namespace OutputSchema {
export const Diagnostic = z.object({ path: z.string(), keyword: z.string(), message: z.string() })
Expand Down Expand Up @@ -88,6 +90,93 @@ export namespace OutputSchema {
}
}

// Path form (#140): the model writes its result to a file and passes the path, so the
// published value is the file it validated, never a provider-schema-constrained re-emission.
export const PATH_SCHEMA: Record<string, unknown> = {
type: "object",
properties: {
path: {
type: "string",
maxLength: 1024,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 PATH_SCHEMA says relative; absolute in-root works.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #142, packages/cli/src/session/output-schema.ts:100):

Problem: PATH_SCHEMA says relative; absolute in-root works
Detail: The provider-facing `path` property description reads "Path of the JSON result file, relative to the working directory", but `OutputSchema.file()` also accepts absolute paths that resolve inside the root — `packages/cli/README.md` explicitly documents "absolute paths inside it are accepted" and the new test "publishes the validated file contents" asserts an absolute in-root path succeeds. This description is the contract the provider and model see (it is the only schema sent to the provider), so it misstates accepted inputs and can make the model avoid valid absolute paths or distrust the tool description.
Suggested fix: Word the description to match behavior and docs, e.g. "Path of the JSON result file in the working directory (relative, or absolute inside it)".

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The provider-facing path property description reads "Path of the JSON result file, relative to the working directory", but OutputSchema.file() also accepts absolute paths that resolve inside the root — packages/cli/README.md explicitly documents "absolute paths inside it are accepted" and the new test "publishes the validated file contents" asserts an absolute in-root path succeeds. This description is the contract the provider and model see (it is the only schema sent to the provider), so it misstates accepted inputs and can make the model avoid valid absolute paths or distrust the tool description.

description: "Path of the JSON result file in the working directory (relative, or absolute inside it)",
},
},
required: ["path"],
additionalProperties: false,
}
export const shape = compile(PATH_SCHEMA)
const MAX_FILE_BYTES = 2 * 1024 * 1024
const OUTSIDE = "path must stay inside the working directory"
// POSIX only; Windows defines neither flag. There, containment rests on the post-open
// inode re-check below, and named pipes live outside the filesystem (\\.\pipe\), so an
// in-directory path cannot open one.
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0
const NONBLOCK = fs.constants.O_NONBLOCK ?? 0

function contains(base: string, target: string) {
const relative = path.relative(base, target)
return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
}

// Reads at most buffer.length bytes, however much the file grows while it is read.
async function fill(handle: fs.FileHandle, buffer: Buffer, offset = 0): Promise<number> {
if (offset === buffer.length) return offset
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (!read.bytesRead) return offset
return fill(handle, buffer, offset + read.bytesRead)
}

// Every failure is a fixed diagnostic, so it counts as a rejection and never echoes OS error text.
export async function file(
root: string,
input: string,
validate: ReturnType<typeof compile>,
): Promise<{ value: unknown } | { errors: Diagnostic[] }> {
const fail = (keyword: string, message: string) => ({ errors: [{ path: "", keyword, message }] })
const base = await fs.realpath(root).catch(() => undefined)
if (!base) return fail("file", "working directory unavailable")
// realpath follows symlinks, so a link pointing outside the root is rejected too.
const target = await fs.realpath(path.resolve(base, input)).catch(() => undefined)
if (!target) return fail("file", "file not found")
if (!contains(base, target)) return fail("file", OUTSIDE)
// O_NOFOLLOW refuses a final component swapped for a symlink after the check (ELOOP).
// O_NONBLOCK makes opening a FIFO return at once; fstat then rejects it as non-regular.
const handle = await fs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 O_NOFOLLOW/O_NONBLOCK silently no-op on Windows.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #142, packages/cli/src/session/output-schema.ts:139-140):

Problem: O_NOFOLLOW/O_NONBLOCK silently no-op on Windows
Detail: On win32, Node's `fs.constants.O_NOFOLLOW` and `O_NONBLOCK` are undefined, so `O_RDONLY | undefined | undefined` coerces to plain `O_RDONLY`: the final-component symlink refusal (ELOOP arm) and the non-blocking FIFO open silently no-op on Windows. Path containment still holds via the post-open realpath + dev/ino re-check, but the defense-in-depth the comments claim is POSIX-only, the ELOOP catch arm is dead code on Windows, and the untested-hang protection for named pipes is weakened there. The test suite explicitly supports Windows (FIFO test skips on win32), so this is a supported platform.
Suggested fix: Detect platform support once, e.g. `const NOFOLLOW = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW` (same for O_NONBLOCK), and add a comment that Windows containment relies solely on the post-open realpath + dev/ino re-check.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

On win32, Node's fs.constants.O_NOFOLLOW and O_NONBLOCK are undefined, so O_RDONLY | undefined | undefined coerces to plain O_RDONLY: the final-component symlink refusal (ELOOP arm) and the non-blocking FIFO open silently no-op on Windows. Path containment still holds via the post-open realpath + dev/ino re-check, but the defense-in-depth the comments claim is POSIX-only, the ELOOP catch arm is dead code on Windows, and the untested-hang protection for named pipes is weakened there. The test suite explicitly supports Windows (FIFO test skips on win32), so this is a supported platform.

.open(target, fs.constants.O_RDONLY | NOFOLLOW | NONBLOCK)
.catch((error: NodeJS.ErrnoException) => (error.code === "ELOOP" ? OUTSIDE : "file not readable"))
if (typeof handle === "string") return fail("file", handle)
try {
// One handle for stat and read, so the checked file is the file read.
const stat = await handle.stat().catch(() => undefined)
if (!stat) return fail("file", "file not readable")
if (!stat.isFile()) return fail("file", "path must be a regular file")
// A parent directory swapped for a symlink after the check is followed by open, so the
// opened file must still be the file the path resolves to inside the working directory.
const again = await fs.realpath(target).catch(() => undefined)
const current = again && contains(base, again) ? await fs.stat(again).catch(() => undefined) : undefined
if (!current || current.dev !== stat.dev || current.ino !== stat.ino) return fail("file", OUTSIDE)
const size = `file must not exceed ${MAX_FILE_BYTES} bytes`
if (stat.size > MAX_FILE_BYTES) return fail("file", size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ 2 MiB zeroing Buffer.alloc on every call.

Suggested change
if (stat.size > MAX_FILE_BYTES) return fail("file", size)
Use `Buffer.allocUnsafe(MAX_FILE_BYTES + 1)` — safe because only the filled prefix is parsed via subarray — keeping the per-call allocation to avoid concurrent-call aliasing.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #142, packages/cli/src/session/output-schema.ts:154):

Problem: 2 MiB zeroing Buffer.alloc on every call
Detail: Every StructuredOutput invocation (including each corrective retry) allocates and zeroes a 2 MiB+1 buffer via `Buffer.alloc`, but only the filled prefix `[0, length)` is ever used (`fill` reads at most buffer.length; `buffer.subarray(0, length)` is parsed). The zero-fill is unnecessary work repeated per attempt and dominates the read cost for small result files. Impact is small (a single memset per tool call), hence NIT.
Suggested fix: Use `Buffer.allocUnsafe(MAX_FILE_BYTES + 1)` — safe because only the filled prefix is parsed via subarray — keeping the per-call allocation to avoid concurrent-call aliasing.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Every StructuredOutput invocation (including each corrective retry) allocates and zeroes a 2 MiB+1 buffer via Buffer.alloc, but only the filled prefix [0, length) is ever used (fill reads at most buffer.length; buffer.subarray(0, length) is parsed). The zero-fill is unnecessary work repeated per attempt and dominates the read cost for small result files. Impact is small (a single memset per tool call), hence NIT.

// Unzeroed per call: only the filled prefix is parsed, and calls never share a buffer.
const buffer = Buffer.allocUnsafe(MAX_FILE_BYTES + 1)
const length = await fill(handle, buffer).catch(() => undefined)
if (length === undefined) return fail("file", "file not readable")
if (length > MAX_FILE_BYTES) return fail("file", size)
const value = (() => {
try {
return { parsed: JSON.parse(buffer.subarray(0, length).toString("utf8")) as unknown }
} catch {
return undefined
}
})()
if (!value) return fail("parse", "file must contain complete, valid JSON")
if (!validate(value.parsed)) return { errors: diagnostics(validate.errors) }
return { value: value.parsed }
} finally {
await handle.close().catch(() => undefined)
}
}

export function summary(error: unknown) {
const value = error && typeof error === "object" ? error : {}
return {
Expand Down
52 changes: 35 additions & 17 deletions packages/cli/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,18 @@ import { Truncate } from "@/tool/truncation"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false

const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to submit your final response as a JSON file.

IMPORTANT:
- Call this tool at the end of your response; repair rejected arguments within the allowed budget
- The input must be valid JSON matching the required schema
- Complete all necessary research and tool calls BEFORE calling this tool
- This tool provides your final answer - no further actions are taken after calling it`
- First write the final result as JSON to a file in the working directory, matching the schema below
- Then call this tool with that file's path; the file is validated and published as written
- If the file is rejected, fix the file and call this tool again with its path
- Complete all necessary research and tool calls BEFORE calling this tool`

const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
const STRUCTURED_OUTPUT_MISSING =
"write the final result as JSON to a file in the working directory, then call StructuredOutput with its path"

const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. Write your final result as JSON to a file in the working directory, then call the StructuredOutput tool with that file's path. Do NOT respond with plain text.`

export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
Expand Down Expand Up @@ -738,7 +741,8 @@ export namespace SessionPrompt {

const reject = (raw: string) => {
if (!contract) throw new Error("Missing output schema")
return rejection(OutputSchema.parse(raw, contract.validate))
// Only the {path} arguments reach the SDK boundary; file content errors come from execute.
return rejection(OutputSchema.parse(raw, OutputSchema.shape))
}

const available = !!contract && attempts < contract.format.retryCount + 1
Expand All @@ -748,6 +752,8 @@ export namespace SessionPrompt {
schema: format.schema,
validate: contract.validate,
model,
root: Instance.directory,
onReject: rejection,
async onSuccess(output) {
if (structuredOutput !== undefined) return
if (!available) throw new Error("StructuredOutput corrective turn budget exhausted")
Expand Down Expand Up @@ -806,7 +812,7 @@ export namespace SessionPrompt {
? [
{
role: "user" as const,
content: "<system-reminder>call StructuredOutput with the final result</system-reminder>",
content: `<system-reminder>${STRUCTURED_OUTPUT_MISSING}</system-reminder>`,
},
]
: []),
Expand Down Expand Up @@ -869,7 +875,7 @@ export namespace SessionPrompt {
reminder =
!!contract && (await missingStructuredOutput(processor.message, () => MessageV2.parts(processor.message.id)))
if (reminder) {
await rejection([{ path: "", keyword: "missing", message: "call StructuredOutput with the final result" }])
await rejection([{ path: "", keyword: "missing", message: STRUCTURED_OUTPUT_MISSING }])
}

if (contract && !processor.message.error && rejected > contract.format.retryCount) {
Expand Down Expand Up @@ -1121,6 +1127,10 @@ export namespace SessionPrompt {
schema: Record<string, unknown>
validate?: ReturnType<typeof OutputSchema.compile>
model?: Provider.Model
/** The tool takes {path} to the JSON result file, which must stay inside this directory (#140). */
root: string
/** Counts the rejection against the corrective budget and returns the feedback text. */
onReject: (errors: OutputSchema.Diagnostic[]) => Promise<string>
onSuccess: (output: unknown) => void | Promise<void>
}): AITool & { id: "StructuredOutput" } {
OutputSchema.canonical(input.schema)
Expand All @@ -1133,16 +1143,24 @@ export namespace SessionPrompt {
: `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(Buffer.from(canonical.text).subarray(0, 8 * 1024))}`
const result = tool({
description: `${STRUCTURED_OUTPUT_DESCRIPTION}\n\n${description}`,
inputSchema: jsonSchema(input.model ? ProviderTransform.schema(input.model, schema) : schema, {
validate(value) {
return validate(value)
? { success: true, value }
: { success: false, error: OutputSchema.error(OutputSchema.diagnostics(validate.errors)) }
// Only {path} reaches the provider, so provider schema conversion cannot drop result fields.
inputSchema: jsonSchema<{ path: string }>(
input.model ? ProviderTransform.schema(input.model, OutputSchema.PATH_SCHEMA) : OutputSchema.PATH_SCHEMA,
{
validate(value) {
return OutputSchema.shape(value)
? { success: true, value: value as { path: string } }
: { success: false, error: OutputSchema.error(OutputSchema.diagnostics(OutputSchema.shape.errors)) }
},
},
}),
),
async execute(args) {
if (!validate(args)) throw OutputSchema.error(OutputSchema.diagnostics(validate.errors))
await input.onSuccess(args)
const loaded = await OutputSchema.file(input.root, args.path, validate)
if ("errors" in loaded)
throw new Error(
`Fix the file at ${JSON.stringify(args.path)}, then call StructuredOutput again with its path. ${await input.onReject(loaded.errors)}`,
)
await input.onSuccess(loaded.value)
return {
output: "Structured output captured successfully.",
title: "Structured Output",
Expand Down
Loading
Loading