Skip to content

feat(run): enforce output schemas with bounded corrective retries (aictrl#5958) - #129

Merged
byapparov merged 11 commits into
mainfrom
feat/5958-output-schema
Oct 7, 2026
Merged

byapparov merged 11 commits into
mainfrom
feat/5958-output-schema

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Implements aictrl-dev/aictrl#5958: enforce the requested output schema in headless runs, with bounded corrective retries.

Problem

format: { type: "json_schema" } and the StructuredOutput tool existed, but:

  • the tool wrapped the schema with jsonSchema() and no validator, so malformed arguments were captured as a successful result (reproduced through the real tool boundary before the fix: a missing required field was stored as {});
  • retryCount was declared but never read;
  • aictrl run had no way to pass a schema or receive the validated result.

What changes

CLI surface (aictrl run):

  • --output-schema <file>: JSON Schema (object root) for the final result.
  • --output-schema-retries <n>: additional corrective attempts (default 2). 0 means exactly one attempt; N means at most N+1.
  • --output-result <file>: written atomically (temp file + rename) with only the validated value, and only on success. Without it, formatted mode prints the value and JSON mode carries it in the terminal event.
  • Exit codes: 2 = schema configuration error (detected before any model request); 3 = structured output failed (exhausted / missing / step limit). Existing codes are unchanged.
  • NDJSON (additive, schema "1"): structured_output_rejected per rejected attempt (bounded diagnostics: path, keyword, message; never submitted values) and exactly one terminal structured_output (accepted with the value, or failed with exhausted | missing | step_limit | aborted | error). Documented in EVENTS.md.

Enforcement:

  • Ajv 8.18.0 with default strict mode and allErrors; no coercion, defaults or stripping. This matches the executor's result checker, so verdicts agree (parity test over a hostile corpus).
  • The validator is attached to the tool, re-checked in execute, again at session acceptance, and again before the result file is written.
  • Invalid or truncated arguments go back to the model through the existing invalid-tool repair route, with diagnostics capped at 10 errors, 64-char path segments and under 2 KB, never echoing values. Logs in schema mode drop raw errors and inputs.
  • toolChoice: "required" stays in schema mode. A prose-only finish is a counted corrective attempt with a reminder; the run fails only when the budget is spent.
  • The provider-facing tool schema goes through ProviderTransform.schema (Gemini sanitisation); local validation always uses the canonical schema.
  • The budget is restored when the processor discards a stream for a provider retry. A valid call wins over a parallel invalid call in the same step. Compaction runs before the reminder. A genuine plain follow-up message clears schema mode.

Also fixed (pre-existing, separate commits)

  • --attach was broken for every run: the legacy SDK client takes { path, body }, but run passed flat { sessionID, ... }, so prompts never reached the session and the CLI hung. A single adapter now shapes all attach calls; a regression test fails without it.
  • Unhandled TimeoutError from the models.dev catalog refresh when the response body stalled (response.text() was outside the catch). Fixed, with a stalled-body regression test.

Verification

  • packages/cli: bun run test → 1646 pass, 7 skip, 0 fail. bun turbo typecheck green; prettier and git diff --check clean.
  • Hostile tests run through the real fake-provider HTTP/SSE → AI SDK → tool → session loop (missing field, wrong scalar/nested type, extra property, truncated JSON, 0/2/4 retries, prose finish, parallel calls, provider retry mid-stream, compaction, permission stop, abort/timeout/provider error), plus CLI subprocess tests for every config error, exit code and result-file case. Mutation checks: reverting the validator, the retry bound, the diagnostic cap, missing-as-attempt, the attach adapter, budget restore and parallel ordering each turns its test red.
  • Live, on real providers (compiled lite binary, strict code-review schema):
    • Vertex gemini-3.8-flash ×3 and GLM-5.2 ×2: all accepted, with Ajv-valid result files. One GLM run was rejected once (missing severity) and repaired.
    • Hostile pattern schema: repaired on the second attempt with retries 1; exit 3 / exhausted with no result file at retries 0.
    • Config errors: exit 2 in ~0.3 s with no model call. A plain run is unchanged.
    • Re-run at the final head: Vertex and GLM accepted. A permission-stopped run now reports missing (exit 3, no file). The hostile schema repaired on attempt 2. Config errors carry their specific cause in NDJSON.

Out of scope

Native provider constraints (aictrl-dev/aictrl#5959) and executor/server integration (aictrl-dev/aictrl#5960, which will call aictrl run --output-schema .contract/result.schema.json --output-result <work file>).

🤖 Generated with Claude Code

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
}
console.error(cause)
console.error(schema ? "Structured output run failed" : cause)
process.exitCode = 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 reject() overwrites structured-failure exit 3 with 1.

Suggested change
process.exitCode = 1
In reject(), do not clobber the structured exit code: `if (process.exitCode !== OUTPUT_FAILED_EXIT) process.exitCode = 1` (or `process.exitCode ??= 1` semantics), so a 3 set by complete() or the session_error handler survives while genuine provider errors still get 1.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.ts:979-980):

Problem: reject() overwrites structured-failure exit 3 with 1
Detail: complete() sets process.exitCode = OUTPUT_FAILED_EXIT (3) guarded by `!process.exitCode`, but every reject() path (prompt/command `.catch(reject)` via throwOnError in attach mode, loopDone `.catch(reject)`) unconditionally executes `process.exitCode = 1` right after `await complete(error)`. When the run fails with a structured-output failure (e.g. a StructuredOutputError already set exit 3 via the session_error handler, then the events loop throws and routes through reject()), reject() overwrites 3 with 1, so the process exit code disagrees with the emitted terminal structured_output event and the documented 0/2/3 contract. Genuine provider errors should keep 1, so the fix must distinguish rather than clobber.
Suggested fix: In reject(), do not clobber the structured exit code: `if (process.exitCode !== OUTPUT_FAILED_EXIT) process.exitCode = 1` (or `process.exitCode ??= 1` semantics), so a 3 set by complete() or the session_error handler survives while genuine provider errors still get 1.

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

complete() sets process.exitCode = OUTPUT_FAILED_EXIT (3) guarded by !process.exitCode, but every reject() path (prompt/command .catch(reject) via throwOnError in attach mode, loopDone .catch(reject)) unconditionally executes process.exitCode = 1 right after await complete(error). When the run fails with a structured-output failure (e.g. a StructuredOutputError already set exit 3 via the session_error handler, then the events loop throws and routes through reject()), reject() overwrites 3 with 1, so the process exit code disagrees with the emitted terminal structured_output event and the documented 0/2/3 contract. Genuine provider errors should keep 1, so the fix must distinguish rather than clobber.

const lastThree = history.slice(-DOOM_LOOP_THRESHOLD)

if (
!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Doom-loop guard trusts model-controlled tool input.

Suggested change
!(
Exclude only tool parts with internal provenance: tag repaired calls with a server-side marker (synthetic flag or dedicated part type) instead of matching the model-controlled input.tool field; never branch a safety guard on unvalidated model input.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/processor.ts:236-240):

Problem: Doom-loop guard trusts model-controlled tool input
Detail: The doom-loop exclusion added for structured output keys on `value.input?.tool === "StructuredOutput"`, but `input` is model-submitted data: a model can repeatedly emit a tool call named "invalid" with `{tool:"StructuredOutput"}` and permanently suppress the repetition guard, enabling an unbounded tool-call loop (agent.steps defaults to Infinity) and unbounded LLM cost. Conversely, if tool parts store input as a JSON string, the exclusion never matches the PR's own repaired calls, so legitimate corrective retries can falsely trip the doom loop. A safety guard should not branch on unvalidated model input.
Suggested fix: Exclude only tool parts with internal provenance: tag repaired calls with a server-side marker (synthetic flag or dedicated part type) instead of matching the model-controlled input.tool field; never branch a safety guard on unvalidated model input.

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 doom-loop exclusion added for structured output keys on value.input?.tool === "StructuredOutput", but input is model-submitted data: a model can repeatedly emit a tool call named "invalid" with {tool:"StructuredOutput"} and permanently suppress the repetition guard, enabling an unbounded tool-call loop (agent.steps defaults to Infinity) and unbounded LLM cost. Conversely, if tool parts store input as a JSON string, the exclusion never matches the PR's own repaired calls, so legitimate corrective retries can falsely trip the doom loop. A safety guard should not branch on unvalidated model input.

])
}, 25000)

test("schema config NDJSON clips the specific compilation cause", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Last spawn test missing explicit 25s timeout.

--- a/packages/cli/test/cli/run-output-schema.test.ts
+++ b/packages/cli/test/cli/run-output-schema.test.ts
@@ -633,3 +633,3 @@
   expect(event?.message).toStartWith("schema.json: strict mode: unknown keyword:")
   expect((event?.message as string).length).toBe(300)
-})
+}, 25000)
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/test/cli/run-output-schema.test.ts:627-635):

Problem: Last spawn test missing explicit 25s timeout
Detail: The test "schema config NDJSON clips the specific compilation cause" spawns a full `bun run` child process (CLI boot + config load + Ajv compile) but, unlike every other spawning test in this file (all pass 25000), it omits the timeout argument, so it runs under bun:test's 5s default. On slow/loaded CI the child alone can exceed 5s, producing an unrelated timeout failure instead of testing the 300-char clipping contract.
Suggested fix: Append the sibling timeout: `}, 25000)`.

Suggested patch:
--- a/packages/cli/test/cli/run-output-schema.test.ts
+++ b/packages/cli/test/cli/run-output-schema.test.ts
@@ -633,3 +633,3 @@
   expect(event?.message).toStartWith("schema.json: strict mode: unknown keyword:")
   expect((event?.message as string).length).toBe(300)
-})
+}, 25000)


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 test "schema config NDJSON clips the specific compilation cause" spawns a full bun run child process (CLI boot + config load + Ajv compile) but, unlike every other spawning test in this file (all pass 25000), it omits the timeout argument, so it runs under bun:test's 5s default. On slow/loaded CI the child alone can exceed 5s, producing an unrelated timeout failure instead of testing the 300-char clipping contract.

}),
)
await Bun.write(path.join(tmp.path, "schema.json"), options.content ?? JSON.stringify(schema))
if (options.locked) await fs.mkdir(path.join(tmp.path, "locked"), { mode: 0o555 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 0o555 "unwritable parent" test fails when run as root.

Suggested change
if (options.locked) await fs.mkdir(path.join(tmp.path, "locked"), { mode: 0o555 })
Make the case root-independent: skip when `process.getuid?.() === 0`, or replace the mode-bit fixture with a root-proof condition (parent is a file — already covered separately — or mock fs.access).
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/test/cli/run-output-schema.test.ts:106):

Problem: 0o555 "unwritable parent" test fails when run as root
Detail: The "unwritable result parent" config-error case relies on `fs.mkdir(mode 0o555)` making the directory non-writable, but the implementation check (run.output.ts) uses `fs.access(dir, W_OK|X_OK)`. POSIX grants root W_OK on any directory regardless of mode bits, so when the suite runs as root (the default in most Docker/CI containers) the config check passes, the CLI proceeds to a model request, and the assertions `expect(result.exit).toBe(2)` and `expect(result.requests).toEqual([])` fail — the suite is red only because of the runner's uid.
Suggested fix: Make the case root-independent: skip when `process.getuid?.() === 0`, or replace the mode-bit fixture with a root-proof condition (parent is a file — already covered separately — or mock fs.access).

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 "unwritable result parent" config-error case relies on fs.mkdir(mode 0o555) making the directory non-writable, but the implementation check (run.output.ts) uses fs.access(dir, W_OK|X_OK). POSIX grants root W_OK on any directory regardless of mode bits, so when the suite runs as root (the default in most Docker/CI containers) the config check passes, the CLI proceeds to a model request, and the assertions expect(result.exit).toBe(2) and expect(result.requests).toEqual([]) fail — the suite is red only because of the runner's uid.

code,
message: `Invocation failed during ${phase}`,
message:
code === "OUTPUT_SCHEMA_CONFIG"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.invocation.ts:38):

Problem: OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files
Detail: run.ts passes the bare literal "OUTPUT_SCHEMA_CONFIG" to fail()/invocation.abort, and run.invocation.ts compares `code === "OUTPUT_SCHEMA_CONFIG"` to switch message behaviour. The protocol string is hardcoded in two modules with no shared constant, even though the sibling exit codes were properly exported from run.output.ts (OUTPUT_CONFIG_EXIT/OUTPUT_FAILED_EXIT). A typo in either copy silently reverts the config-error message to the generic "Invocation failed during ..." text documented in EVENTS.md.
Suggested fix: Export a shared constant (e.g. OUTPUT_SCHEMA_CONFIG_CODE alongside OUTPUT_CONFIG_EXIT in run.output.ts) and use it in both run.ts and the run.invocation.ts comparison.

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

run.ts passes the bare literal "OUTPUT_SCHEMA_CONFIG" to fail()/invocation.abort, and run.invocation.ts compares code === "OUTPUT_SCHEMA_CONFIG" to switch message behaviour. The protocol string is hardcoded in two modules with no shared constant, even though the sibling exit codes were properly exported from run.output.ts (OUTPUT_CONFIG_EXIT/OUTPUT_FAILED_EXIT). A typo in either copy silently reverts the config-error message to the generic "Invocation failed during ..." text documented in EVENTS.md.

Comment thread packages/cli/src/cli/cmd/run.output.ts Outdated
@@ -0,0 +1,62 @@
import path from "path"
import fs from "fs/promises"
import { OutputSchema } from "../../session/output-schema"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Relative import bypasses "@/session" alias.

Suggested change
import { OutputSchema } from "../../session/output-schema"
Use `import { OutputSchema } from "@/session/output-schema"` in both run.output.ts and run.ts to match generate.ts and the other cmd/ files.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.output.ts:3):

Problem: Relative import bypasses "@/session" alias
Detail: run.output.ts imports OutputSchema via "../../session/output-schema" and run.ts adds the same relative import, while the sibling command file generate.ts (same directory) uses the alias form "@/session/message-v2" and "@/provider/termination" for cross-directory imports. The new cross-module imports deviate from the established '@/' alias convention.
Suggested fix: Use `import { OutputSchema } from "@/session/output-schema"` in both run.output.ts and run.ts to match generate.ts and the other cmd/ files.

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

run.output.ts imports OutputSchema via "../../session/output-schema" and run.ts adds the same relative import, while the sibling command file generate.ts (same directory) uses the alias form "@/session/message-v2" and "@/provider/termination" for cross-directory imports. The new cross-module imports deviate from the established '@/' alias convention.

OUTPUT_CONFIG_EXIT,
)
return undefined
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Unreachable return after process.exit in fail().

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.ts:425):

Problem: Unreachable return after process.exit in fail()
Detail: The outputSchema catch handler awaits fail(...) and then returns undefined, but fail() ends with process.exit(OUTPUT_CONFIG_EXIT), which never returns, so the `return undefined` (and downstream `schema`-is-undefined handling) is dead code. Neighbouring failure paths in this file carry no post-exit returns; the unreachable fallback implies execution continues without a schema, which never happens.
Suggested fix: Type the catch as never (e.g. `.catch(async (error: unknown): Promise<never> => { await fail(...); throw error })`), or make fail() return a discriminated result instead of process.exit so the undefined path is genuinely reachable.

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 outputSchema catch handler awaits fail(...) and then returns undefined, but fail() ends with process.exit(OUTPUT_CONFIG_EXIT), which never returns, so the return undefined (and downstream schema-is-undefined handling) is dead code. Neighbouring failure paths in this file carry no post-exit returns; the unreachable fallback implies execution continues without a schema, which never happens.

Comment thread packages/cli/src/provider/models.ts Outdated
})
if (result && result.ok) {
await Filesystem.write(filepath, await result.text())
.then((response) => (response.ok ? response.text() : undefined))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Body-read failure mislabeled as fetch failure.

Suggested change
.then((response) => (response.ok ? response.text() : undefined))
Split the handlers: catch on the fetch logs "Failed to fetch models.dev"; a separate catch around response.text() logs "Failed to read models.dev response".
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/provider/models.ts:113-117):

Problem: Body-read failure mislabeled as fetch failure
Detail: The single `.catch` now spans both the fetch and the `response.text()` read, logging "Failed to fetch models.dev" for body-read failures (e.g. connection reset mid-transfer after a successful response). The fetch itself succeeded, so the log message points operators at the wrong failure stage.
Suggested fix: Split the handlers: catch on the fetch logs "Failed to fetch models.dev"; a separate catch around response.text() logs "Failed to read models.dev response".

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 single .catch now spans both the fetch and the response.text() read, logging "Failed to fetch models.dev" for body-read failures (e.g. connection reset mid-transfer after a successful response). The fetch itself succeeded, so the log message points operators at the wrong failure stage.

// Inject StructuredOutput tool if JSON schema mode enabled
if (lastUser.format?.type === "json_schema") {
const reject = async (input: string | OutputSchema.Diagnostic[]) => {
if (!contract) throw new Error("Missing output schema")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ reject(input) shadows outer prompt input param.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/prompt.ts:727):

Problem: reject(input) shadows outer prompt input param
Detail: The new `const reject = async (input: string | OutputSchema.Diagnostic[])` is declared inside `prompt = fn(PromptInput, async (input) => ...)`, shadowing the outer PromptInput parameter named `input` that is still live in the same scope (used earlier in the OutputSchema.compile preflight). The shadow invites copy-paste mistakes; the nearby `contract!.format.retryCount` non-null assertion also duplicates the guard that `if (!contract)` / `available` already establish.
Suggested fix: Rename the parameter (e.g. `reject(raw: string | OutputSchema.Diagnostic[])`) and rely on the already-narrowed `contract.format` instead of `contract!`.

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 new const reject = async (input: string | OutputSchema.Diagnostic[]) is declared inside prompt = fn(PromptInput, async (input) => ...), shadowing the outer PromptInput parameter named input that is still live in the same scope (used earlier in the OutputSchema.compile preflight). The shadow invites copy-paste mistakes; the nearby contract!.format.retryCount non-null assertion also duplicates the guard that if (!contract) / available already establish.

"export type Event =\n | EventSessionStructuredOutput\n | EventSessionStructuredOutputRejected\n",
)
}
await legacy.write(source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Event-union patch guard checks one of two members.

Suggested change
await legacy.write(source)
Guard on both inserted members (or the combined two-line snippet), and restore the "; update the SDK schema bridge" hint to the thrown errors.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/sdk/script/build.ts:79):

Problem: Event-union patch guard checks one of two members
Detail: The idempotency guard for the legacy Event union tests `source.includes("  | EventSessionStructuredOutput\n")` but the replacement inserts both EventSessionStructuredOutput and EventSessionStructuredOutputRejected. If the generated file ever contains only the first member (partial regeneration or future edit), the block silently re-inserts the Rejected member a second time. The new error strings also drop the old "update the SDK schema bridge" remediation hint the previous single-type patch carried, making build failures less actionable.
Suggested fix: Guard on both inserted members (or the combined two-line snippet), and restore the "; update the SDK schema bridge" hint to the thrown errors.

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 idempotency guard for the legacy Event union tests source.includes(" | EventSessionStructuredOutput\n") but the replacement inserts both EventSessionStructuredOutput and EventSessionStructuredOutputRejected. If the generated file ever contains only the first member (partial regeneration or future edit), the block silently re-inserts the Rejected member a second time. The new error strings also drop the old "update the SDK schema bridge" remediation hint the previous single-type patch carried, making build failures less actionable.

@aictrl-dev

aictrl-dev Bot commented Oct 7, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 4 · 🟡 13 · ⚪ 7 · 0/24 resolved

  • ⚪ packages/cli/README.md:103-107 — Drive-by markdown reformatting in feature PR
  • 🟡 packages/cli/src/cli/cmd/run.invocation.ts:38 — OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files
  • ⚪ packages/cli/src/cli/cmd/run.output.ts:3 — Relative import bypasses "@/session" alias
  • ⚪ packages/cli/src/cli/cmd/run.output.ts:15 — Retries error wrongly prefixed with schema path
  • ⚪ packages/cli/src/cli/cmd/run.ts:425 — Unreachable return after process.exit in fail()
  • 🟡 packages/cli/src/cli/cmd/run.ts:641 — Outcome.parse throw kills the whole events loop
  • 🟠 packages/cli/src/cli/cmd/run.ts:979-980 — reject() overwrites structured-failure exit 3 with 1
  • 🟡 packages/cli/src/cli/cmd/run.ts:1039 — Old attach server silently strips format flag
  • ⚪ packages/cli/src/provider/models.ts:113-117 — Body-read failure mislabeled as fetch failure
  • 🟡 packages/cli/src/session/llm.ts:177 — Structured mode discards real error identity
  • 🟡 packages/cli/src/session/output-schema.ts:28-29 — Non-canonical 2020-12 $schema falls to draft-07
  • 🟡 packages/cli/src/session/output-schema.ts:32 — Schema regexes compiled without ReDoS guard
  • 🟠 packages/cli/src/session/processor.ts:236-240 — Doom-loop guard trusts model-controlled tool input
  • 🟡 packages/cli/src/session/processor.ts:472-474 — Redacted log but raw error persists to session
  • 🟡 packages/cli/src/session/prompt.ts:413-418 — Step-limit path may delete prior accepted output
  • ⚪ packages/cli/src/session/prompt.ts:727 — reject(input) shadows outer prompt input param
  • 🟡 packages/cli/src/session/prompt.ts:728 — reject() echoes non-string input without bounding
  • 🟡 packages/cli/src/session/prompt.ts:739 — reject() still invites repair after budget spent
  • 🟠 packages/cli/test/cli/run-output-schema.test.ts:106 — 0o555 "unwritable parent" test fails when run as root
  • 🟡 packages/cli/test/cli/run-output-schema.test.ts:488-489 — Attach-matrix SSE server lacks idleTimeout: 0
  • 🟠 packages/cli/test/cli/run-output-schema.test.ts:627-635 — Last spawn test missing explicit 25s timeout
  • 🟡 packages/cli/test/provider/models.test.ts:40 — 10s abort vs 15s test timeout leaves little slack
  • ⚪ packages/sdk/script/build.ts:79 — Event-union patch guard checks one of two members
  • 🟡 README.md:52-87 — Result section duplicated verbatim in two READMEs
🤖 Fix all 24 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #129 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/README.md:103-107 — Drive-by markdown reformatting in feature PR
   Detail: Besides the new feature section, this diff reformats the pre-existing Models table to padded-cell alignment and inserts blank lines after "This adds a GitHub Action that can:" and the `aictrl pr` code block. This prettier-style churn is unrelated to the output-schema feature; the same unpadded table style remains in other docs, so markdown formatting is now inconsistent across files and the diff carries noise.
   Suggested fix: Revert the table padding and blank-line insertions (or apply the same formatting pass repo-wide in a separate chore commit) so this PR contains only feature changes.
2. packages/cli/src/cli/cmd/run.invocation.ts:38 — OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files
   Detail: run.ts passes the bare literal "OUTPUT_SCHEMA_CONFIG" to fail()/invocation.abort, and run.invocation.ts compares `code === "OUTPUT_SCHEMA_CONFIG"` to switch message behaviour. The protocol string is hardcoded in two modules with no shared constant, even though the sibling exit codes were properly exported from run.output.ts (OUTPUT_CONFIG_EXIT/OUTPUT_FAILED_EXIT). A typo in either copy silently reverts the config-error message to the generic "Invocation failed during ..." text documented in EVENTS.md.
   Suggested fix: Export a shared constant (e.g. OUTPUT_SCHEMA_CONFIG_CODE alongside OUTPUT_CONFIG_EXIT in run.output.ts) and use it in both run.ts and the run.invocation.ts comparison.
3. packages/cli/src/cli/cmd/run.output.ts:3 — Relative import bypasses "@/session" alias
   Detail: run.output.ts imports OutputSchema via "../../session/output-schema" and run.ts adds the same relative import, while the sibling command file generate.ts (same directory) uses the alias form "@/session/message-v2" and "@/provider/termination" for cross-directory imports. The new cross-module imports deviate from the established '@/' alias convention.
   Suggested fix: Use `import { OutputSchema } from "@/session/output-schema"` in both run.output.ts and run.ts to match generate.ts and the other cmd/ files.
4. packages/cli/src/cli/cmd/run.output.ts:15 — Retries error wrongly prefixed with schema path
   Detail: The `--output-schema-retries` validation failure message is prefixed with `${input.schema}`, producing e.g. "schema.json: --output-schema-retries must be an integer >= 0". The failure has nothing to do with the schema file's contents or readability, so the prefix misdirects users (and tooling keying on the prefix) toward the schema file.
   Suggested fix: Drop the schema-path prefix for the retries error: `throw new Error("--output-schema-retries must be an integer >= 0")`.
5. packages/cli/src/cli/cmd/run.ts:425 — Unreachable return after process.exit in fail()
   Detail: The outputSchema catch handler awaits fail(...) and then returns undefined, but fail() ends with process.exit(OUTPUT_CONFIG_EXIT), which never returns, so the `return undefined` (and downstream `schema`-is-undefined handling) is dead code. Neighbouring failure paths in this file carry no post-exit returns; the unreachable fallback implies execution continues without a schema, which never happens.
   Suggested fix: Type the catch as never (e.g. `.catch(async (error: unknown): Promise<never> => { await fail(...); throw error })`), or make fail() return a discriminated result instead of process.exit so the undefined path is genuinely reachable.
6. packages/cli/src/cli/cmd/run.ts:641 — Outcome.parse throw kills the whole events loop
   Detail: Inside the `for await (const event of events.stream)` loop, `OutputSchema.Outcome.parse(event.properties.outcome)` throws a ZodError if the outcome payload deviates from the schema — realistic during mixed-version rollout in attach mode, where the SSE bridge forwards bus events without revalidation. The throw aborts the entire events loop: subsequent events (session.status idle, session_complete) are dropped and the run terminates via reject() with exit 1 instead of degrading gracefully for one malformed event.
   Suggested fix: Use OutputSchema.Outcome.safeParse(event.properties.outcome) and skip (or synthesize a failed outcome for) events that fail to parse, so one bad event cannot terminate event consumption.
7. packages/cli/src/cli/cmd/run.ts:979-980 — reject() overwrites structured-failure exit 3 with 1
   Detail: complete() sets process.exitCode = OUTPUT_FAILED_EXIT (3) guarded by `!process.exitCode`, but every reject() path (prompt/command `.catch(reject)` via throwOnError in attach mode, loopDone `.catch(reject)`) unconditionally executes `process.exitCode = 1` right after `await complete(error)`. When the run fails with a structured-output failure (e.g. a StructuredOutputError already set exit 3 via the session_error handler, then the events loop throws and routes through reject()), reject() overwrites 3 with 1, so the process exit code disagrees with the emitted terminal structured_output event and the documented 0/2/3 contract. Genuine provider errors should keep 1, so the fix must distinguish rather than clobber.
   Suggested fix: In reject(), do not clobber the structured exit code: `if (process.exitCode !== OUTPUT_FAILED_EXIT) process.exitCode = 1` (or `process.exitCode ??= 1` semantics), so a 3 set by complete() or the session_error handler survives while genuine provider errors still get 1.
8. packages/cli/src/cli/cmd/run.ts:1039 — Old attach server silently strips format flag
   Detail: The CLI forwards `format: schema.format` in the prompt/command body, but an older (pre-PR) attach server's PromptInput zod schema strips the unknown `format` key without erroring. The run then executes with no StructuredOutput tool, no required tool choice and no reminder loop, and the CLI synthesizes {status:"failed", reason:"missing"} with exit 3 and "Structured output failed: missing" — with no indication the schema was never actually enforced, misleading users during mixed-version rollout.
   Suggested fix: Detect non-enforcement client-side (e.g. no StructuredOutput tool observed in the tool_catalog event) and fail with an explicit incompatible-server diagnostic instead of a generic "missing" failure.
9. packages/cli/src/provider/models.ts:113-117 — Body-read failure mislabeled as fetch failure
   Detail: The single `.catch` now spans both the fetch and the `response.text()` read, logging "Failed to fetch models.dev" for body-read failures (e.g. connection reset mid-transfer after a successful response). The fetch itself succeeded, so the log message points operators at the wrong failure stage.
   Suggested fix: Split the handlers: catch on the fetch logs "Failed to fetch models.dev"; a separate catch around response.text() logs "Failed to read models.dev response".
10. packages/cli/src/session/llm.ts:177 — Structured mode discards real error identity
   Detail: With structured output enabled, llm.ts onError logs only the literal "Structured output stream failed" (no error object), processor.ts logs the same string with stack: undefined, and run.ts reject() prints console.error("Structured output run failed") instead of the cause. Unlike the diagnostics bounding in OutputSchema (scoped to model-visible content), these paths discard the error class/status entirely, so auth failures, 429s and timeouts during a --output-schema run leave no identifiable trace in logs or stderr for operators to debug.
   Suggested fix: Log a redacted summary with stable metadata instead of dropping identity, e.g. `{ message: "Structured output stream failed", name: error.name, statusCode: error.statusCode, isRetryable: error.isRetryable }`, and keep the classified message/code (not the raw cause) on the reject path's stderr output.
11. packages/cli/src/session/output-schema.ts:28-29 — Non-canonical 2020-12 $schema falls to draft-07
   Detail: Ajv2020 is selected only when $schema matches `/^https:\/\/json-schema\.org\/draft\/2020-12\/schema#?$/` exactly. Common variants — trailing slash, http:// scheme — fall through to draft-07 Ajv: valid 2020-12 schemas using keywords like prefixItems/unevaluatedProperties then hit strict-mode "unknown keyword" compile errors (a confusing exit 2 for a valid schema), and keyword-light schemas are silently validated under weaker draft-07 semantics, accepting outputs the author intended to reject.
   Suggested fix: Normalize the $schema URI before matching (trim trailing `#` and `/`, accept http/https), and reject recognized-but-unsupported dialect URIs (e.g. 2019-09) with an explicit error instead of compiling under draft-07.
12. packages/cli/src/session/output-schema.ts:32 — Schema regexes compiled without ReDoS guard
   Detail: compile() passes user/session-supplied schemas straight to Ajv, which converts `pattern`/`patternProperties` values into native RegExp. A pathological regex embedded in a schema (the comment says validators are shared with schemas "loaded from session storage", not only the invoking user's CLI flag file) can cause catastrophic backtracking while validating model output, hanging the session thread.
   Suggested fix: Cap schema size and reject or sandbox `pattern`/`patternProperties` regexes (linear-time engine or safe-regex lint) before compiling, especially for schemas sourced from persisted sessions rather than the invoking user.
13. packages/cli/src/session/processor.ts:236-240 — Doom-loop guard trusts model-controlled tool input
   Detail: The doom-loop exclusion added for structured output keys on `value.input?.tool === "StructuredOutput"`, but `input` is model-submitted data: a model can repeatedly emit a tool call named "invalid" with `{tool:"StructuredOutput"}` and permanently suppress the repetition guard, enabling an unbounded tool-call loop (agent.steps defaults to Infinity) and unbounded LLM cost. Conversely, if tool parts store input as a JSON string, the exclusion never matches the PR's own repaired calls, so legitimate corrective retries can falsely trip the doom loop. A safety guard should not branch on unvalidated model input.
   Suggested fix: Exclude only tool parts with internal provenance: tag repaired calls with a server-side marker (synthetic flag or dedicated part type) instead of matching the model-controlled input.tool field; never branch a safety guard on unvalidated model input.
14. packages/cli/src/session/processor.ts:472-474 — Redacted log but raw error persists to session
   Detail: The PR redacts the `log.error("process", ...)` line for structured runs, but the same raw exception `e` (e.g. an APICallError whose responseBody can echo the model's rejected tool-call JSON) still flows into `MessageV2.APIError.isInstance(e) ? e : ...`, is stored on the message, persisted to the session, and re-emitted on error events. Model-submitted values can therefore still leak through persisted error payloads even though the log line is clean — the redaction is incomplete.
   Suggested fix: Apply the same redaction downstream for structured runs: sanitize/replace e.message/responseBody before it is assigned to processor.message.error or published on error events, not just at the log call.
15. packages/cli/src/session/prompt.ts:413-418 — Step-limit path may delete prior accepted output
   Detail: When `step > maxSteps` fires under a schema contract, the code mutates the previous assistant message: `delete lastAssistant.structured; lastAssistant.error = ...; Session.updateMessage(lastAssistant)`. On a resumed session whose last assistant message already carries an accepted `.structured` from an earlier run, hitting the step limit on a new prompt (before a fresh assistant message exists) permanently erases that previously accepted stored result and overwrites the message with a StructuredOutputError — degrading stored history for a failure of the new run.
   Suggested fix: Only mutate lastAssistant when it belongs to the current run's request (e.g. created after the current request message id), or write the step-limit error to the current run's pending assistant message instead of deleting stored state.
16. packages/cli/src/session/prompt.ts:727 — reject(input) shadows outer prompt input param
   Detail: The new `const reject = async (input: string | OutputSchema.Diagnostic[])` is declared inside `prompt = fn(PromptInput, async (input) => ...)`, shadowing the outer PromptInput parameter named `input` that is still live in the same scope (used earlier in the OutputSchema.compile preflight). The shadow invites copy-paste mistakes; the nearby `contract!.format.retryCount` non-null assertion also duplicates the guard that `if (!contract)` / `available` already establish.
   Suggested fix: Rename the parameter (e.g. `reject(raw: string | OutputSchema.Diagnostic[])`) and rely on the already-narrowed `contract.format` instead of `contract!`.
17. packages/cli/src/session/prompt.ts:728 — reject() echoes non-string input without bounding
   Detail: `reject` treats any non-string `input` as an already-bounded Diagnostic[] without a runtime shape check. If a caller hands it something else (e.g. llm.ts passes `failed.toolCall.input`, which can be a parsed object depending on AI SDK version), that entire model-submitted payload is JSON.stringify'd into `OutputSchema.error(errors).message` and echoed into the conversation with none of the 96-char/1600-byte clipping that diagnostics() enforces, defeating the bounding invariant this PR adds.
   Suggested fix: Validate the non-string branch at the boundary (e.g. `input.every((d) => OutputSchema.Diagnostic.safeParse(d).success)`) or coerce unexpected shapes through diagnostics()-style clipping before building the error message.
18. packages/cli/src/session/prompt.ts:739 — reject() still invites repair after budget spent
   Detail: reject() returns `OutputSchema.error(errors).message` (a "fix these errors" repair invitation) even when `attempts` has already reached retryCount + 1; the counter stops incrementing but the corrective prompt keeps flowing back to the model from experimental_repairToolCall, so termination depends entirely on the post-stream exhaustion check and stream/step limits rather than the budget itself. Within a single stream the model can be re-armed into further corrective calls after the budget is spent.
   Suggested fix: When attempts >= retryCount + 1, return a terminal message (e.g. "StructuredOutput budget exhausted; respond with text") instead of a repair invitation, so the repair path cannot be re-armed within a stream.
19. packages/cli/test/cli/run-output-schema.test.ts:106 — 0o555 "unwritable parent" test fails when run as root
   Detail: The "unwritable result parent" config-error case relies on `fs.mkdir(mode 0o555)` making the directory non-writable, but the implementation check (run.output.ts) uses `fs.access(dir, W_OK|X_OK)`. POSIX grants root W_OK on any directory regardless of mode bits, so when the suite runs as root (the default in most Docker/CI containers) the config check passes, the CLI proceeds to a model request, and the assertions `expect(result.exit).toBe(2)` and `expect(result.requests).toEqual([])` fail — the suite is red only because of the runner's uid.
   Suggested fix: Make the case root-independent: skip when `process.getuid?.() === 0`, or replace the mode-bit fixture with a root-proof condition (parent is a file — already covered separately — or mock fs.access).
20. packages/cli/test/cli/run-output-schema.test.ts:488-489 — Attach-matrix SSE server lacks idleTimeout: 0
   Detail: The attach-matrix fixture's Bun.serve (serving the /event SSE stream) does not set idleTimeout, unlike the bridge server in run() which sets idleTimeout: 0. Bun's default idle timeout closes an idle connection after ~10s; if the child takes longer between the ': ready' comment and the /message events (cold bun boot on slow CI), the server drops the SSE stream and the run terminates early, intermittently flipping exit-code and structured_output assertions.
   Suggested fix: Add `idleTimeout: 0` to this Bun.serve options object, matching the bridge server, so the /event stream stays open for the whole test.
21. packages/cli/test/cli/run-output-schema.test.ts:627-635 — Last spawn test missing explicit 25s timeout
   Detail: The test "schema config NDJSON clips the specific compilation cause" spawns a full `bun run` child process (CLI boot + config load + Ajv compile) but, unlike every other spawning test in this file (all pass 25000), it omits the timeout argument, so it runs under bun:test's 5s default. On slow/loaded CI the child alone can exceed 5s, producing an unrelated timeout failure instead of testing the 300-char clipping contract.
   Suggested fix: Append the sibling timeout: `}, 25000)`.
22. packages/cli/test/provider/models.test.ts:40 — 10s abort vs 15s test timeout leaves little slack
   Detail: The fixture server enqueues '{' and never closes the body, so ModelsDev.refresh() only returns when its AbortSignal.timeout(10s) fires. The child therefore runs ~10s deterministically, plus bun startup and stream teardown, against a 15000ms test timeout — only ~4.5s of CI slack. Any runner stall turns this into an intermittent timeout failure unrelated to the regression it guards (refresh surviving a body-read timeout).
   Suggested fix: Raise the test timeout to 25000 (matching the other spawn-based tests), or make the refresh abort window configurable via env so the fixture does not depend on the full 10s signal.
23. packages/sdk/script/build.ts:79 — Event-union patch guard checks one of two members
   Detail: The idempotency guard for the legacy Event union tests `source.includes("  | EventSessionStructuredOutput\n")` but the replacement inserts both EventSessionStructuredOutput and EventSessionStructuredOutputRejected. If the generated file ever contains only the first member (partial regeneration or future edit), the block silently re-inserts the Rejected member a second time. The new error strings also drop the old "update the SDK schema bridge" remediation hint the previous single-type patch carried, making build failures less actionable.
   Suggested fix: Guard on both inserted members (or the combined two-line snippet), and restore the "; update the SDK schema bridge" hint to the thrown errors.
24. README.md:52-87 — Result section duplicated verbatim in two READMEs
   Detail: The new 34-line "Validated Final Results" section is duplicated word-for-word between README.md and packages/cli/README.md (only the EVENTS.md link path differs). The two READMEs do not verbatim-mirror each other today — neighbouring sections are tailored per audience ("For programmatic consumption..." with jq '.type' in the root vs "For programmatic pipes:" with jq '.properties.part.text' in packages/cli). Verbatim duplication breaks that pattern and creates dual-maintenance drift risk for exit codes, defaults and bounds that must stay in sync with EVENTS.md and the code.
   Suggested fix: Keep the full flag/exit-code contract in one place — a short pointer from packages/cli/README.md to the root README section (or a shared docs include) — or at minimum tailor each copy the way the surrounding sections already do.
📋 Out-of-diff findings (24)
Sev Location Finding
⚪ packages/cli/README.md:103-107 Drive-by markdown reformatting in feature PR
🟡 packages/cli/src/cli/cmd/run.invocation.ts:38 OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files
⚪ packages/cli/src/cli/cmd/run.output.ts:3 Relative import bypasses "@/session" alias
⚪ packages/cli/src/cli/cmd/run.output.ts:15 Retries error wrongly prefixed with schema path
⚪ packages/cli/src/cli/cmd/run.ts:425 Unreachable return after process.exit in fail()
🟡 packages/cli/src/cli/cmd/run.ts:641 Outcome.parse throw kills the whole events loop
🟠 packages/cli/src/cli/cmd/run.ts:979-980 reject() overwrites structured-failure exit 3 with 1
🟡 packages/cli/src/cli/cmd/run.ts:1039 Old attach server silently strips format flag
⚪ packages/cli/src/provider/models.ts:113-117 Body-read failure mislabeled as fetch failure
🟡 packages/cli/src/session/llm.ts:177 Structured mode discards real error identity
🟡 packages/cli/src/session/output-schema.ts:28-29 Non-canonical 2020-12 $schema falls to draft-07
🟡 packages/cli/src/session/output-schema.ts:32 Schema regexes compiled without ReDoS guard
🟠 packages/cli/src/session/processor.ts:236-240 Doom-loop guard trusts model-controlled tool input
🟡 packages/cli/src/session/processor.ts:472-474 Redacted log but raw error persists to session
🟡 packages/cli/src/session/prompt.ts:413-418 Step-limit path may delete prior accepted output
⚪ packages/cli/src/session/prompt.ts:727 reject(input) shadows outer prompt input param
🟡 packages/cli/src/session/prompt.ts:728 reject() echoes non-string input without bounding
🟡 packages/cli/src/session/prompt.ts:739 reject() still invites repair after budget spent
🟠 packages/cli/test/cli/run-output-schema.test.ts:106 0o555 "unwritable parent" test fails when run as root
🟡 packages/cli/test/cli/run-output-schema.test.ts:488-489 Attach-matrix SSE server lacks idleTimeout: 0
🟠 packages/cli/test/cli/run-output-schema.test.ts:627-635 Last spawn test missing explicit 25s timeout
🟡 packages/cli/test/provider/models.test.ts:40 10s abort vs 15s test timeout leaves little slack
⚪ packages/sdk/script/build.ts:79 Event-union patch guard checks one of two members
🟡 README.md:52-87 Result section duplicated verbatim in two READMEs

Reviewed 24 files · 0 inline · view all 24 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #129

Triage of the aictrl code review (24 findings) at head 20df10f76e. Fixes pushed to this PR as 286c329f21, 368a386c18, ff1d6d15ba and cef40a5db6 (head cef40a5db6). Full CLI suite: 1652 pass / 0 fail. Typecheck green. Mutation checks for findings 1, 2 and 6 fail without the fix.

Issues addressed (pushed to this PR)

  • reject() overwrites structured-failure exit 3 with 1: packages/cli/src/cli/cmd/run.ts:979: reject() now keeps exit 3 when a structured failure already set it; regression test sends StructuredOutputError then a loop rejection (commit 286c329f21)
  • Doom-loop guard trusts model-controlled tool input: packages/cli/src/session/processor.ts:236: doom-loop exemption now uses server-side provenance (toolCallIds produced by the structured repair path); a direct invalid call with tool=StructuredOutput still trips the guard (commit 286c329f21)
  • Last spawn test missing explicit 25s timeout: packages/cli/test/cli/run-output-schema.test.ts:627: added the 25000 ms timeout (commit 286c329f21)
  • 0o555 "unwritable parent" test fails when run as root: packages/cli/test/cli/run-output-schema.test.ts:106: 0o555 case skipped under uid 0 with an explanatory comment (commit 286c329f21)
  • OUTPUT_SCHEMA_CONFIG literal duplicated in 2 files: packages/cli/src/cli/cmd/run.invocation.ts:38: single exported OUTPUT_SCHEMA_CONFIG constant used by run.ts and run.invocation.ts (commit 286c329f21)
  • Outcome.parse throw kills the whole events loop: packages/cli/src/cli/cmd/run.ts:641: Outcome.safeParse; a malformed outcome becomes failed/error without breaking the event loop; attach regression test (commit 286c329f21)
  • Structured mode discards real error identity: packages/cli/src/session/llm.ts:177: structured-mode logs carry bounded name/statusCode/isRetryable; stderr prints the classified reason/code/message; tests prove no submitted value leaks (commit 286c329f21)
  • Attach-matrix SSE server lacks idleTimeout: 0: packages/cli/test/cli/run-output-schema.test.ts:488: idleTimeout: 0 on the attach-matrix server (commit 286c329f21)
  • 10s abort vs 15s test timeout leaves little slack: packages/cli/test/provider/models.test.ts:40: timeout raised to 25000 (commit 368a386c18)
  • Result section duplicated verbatim in two READMEs: README.md:52: full contract kept in packages/cli/README.md; root README has a short summary + links (commit ff1d6d15ba)
  • Drive-by markdown reformatting in feature PR: packages/cli/README.md:103: reverted unrelated table/blank-line churn; README diff is additions only (commit ff1d6d15ba)
  • Retries error wrongly prefixed with schema path: packages/cli/src/cli/cmd/run.output.ts:15: retries error no longer prefixed with the schema path (commit 286c329f21)
  • Relative import bypasses "@/session" alias: packages/cli/src/cli/cmd/run.output.ts:3: @/session/output-schema alias (commit 286c329f21)
  • Unreachable return after process.exit in fail(): packages/cli/src/cli/cmd/run.ts:425: catch handler typed Promise; dead return removed (commit 286c329f21)
  • Body-read failure mislabeled as fetch failure: packages/cli/src/provider/models.ts:113: separate fetch vs body-read failure messages, both still handled (commit 368a386c18)
  • reject(input) shadows outer prompt input param: packages/cli/src/session/prompt.ts:727: reject(raw: string) split from the missing-result path; contract! removed (commit 286c329f21)
  • Event-union patch guard checks one of two members: packages/sdk/script/build.ts:79: guard checks both inserted members; "update the SDK schema bridge" hint restored (commit cef40a5db6)

Review claims verified false (no change needed)

  • "Non-canonical 2020-12 $schema falls to draft-07": verified false. non-canonical $schema URIs (http, trailing slash, 2019-09) throw "no schema with key or ref" at compile, so exit 2, never silently draft-07.
  • "reject() still invites repair after budget spent": verified false. each processor.process() is one model step and the exhaustion check runs before any further request, so the model cannot act on a repair message after the budget is spent.
  • "reject() echoes non-string input without bounding": verified false. the sole caller passes failed.toolCall.input, a string in AI SDK v5; the union was removed anyway by the f23 split.
  • "Step-limit path may delete prior accepted output": verified false. agent steps is a positive int, so step>maxSteps fires only after the current run created its own assistant message.

Not addressed here

reject() preserved exit 3 whenever process.exitCode already held it, but
complete() sets 3 for any structured failure and runs first inside reject().
When the provider rejected the prompt before the SSE session.error arrived,
a genuine provider failure therefore exited 3. Track the session-reported
StructuredOutputError explicitly and key the exit code on that instead.

The attach provider-rejection fixture now answers before sending any event,
pinning the ordering that CI hit; reverting the fix makes it fail (1 vs 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@byapparov
byapparov merged commit abb6261 into main Oct 7, 2026
4 checks passed
@byapparov
byapparov deleted the feat/5958-output-schema branch October 7, 2026 12:50

export function compile(schema: Record<string, unknown>) {
if (schema.type !== "object") throw new Error('schema root must have type "object"')
const key = JSON.stringify(schema)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Unbounded client schema compiled with no size guard.

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

Problem: Unbounded client schema compiled with no size guard
Detail: OutputSchema.compile() accepts any schema with no size, depth or complexity bound, and it now sits on a trust boundary: prompt() compiles `input.format.schema` straight from PromptInput/CommandInput (`format: MessageV2.Format.optional()`), which any SDK/attach-server client can supply, and schemas are also persisted on user messages and re-compiled from storage on resume. The cache key is `JSON.stringify(schema)` over the raw object — every prompt-loop iteration re-serializes the whole schema even on cache hits, and the 32-entry cache retains arbitrarily large key strings (memory/CPU amplification). Ajv's own docs warn that compiling untrusted schemas is unsafe: deeply nested $ref chains can exhaust the stack during codegen and a pathological `pattern` compiles to a native RegExp that ReDoSes the session thread on every model-submitted argument validation (this ReDoS aspect was previously raised and remains unaddressed).
Suggested fix: Enforce a hard byte cap on the serialized schema before stringify/compile (e.g. reject when Buffer.byteLength(JSON.stringify(schema)) > ~64 KB with a config-style error), use a truncated SHA-256 of the serialized schema as the cache key instead of the raw string, and consider Ajv `code: { regExp }` with a safe engine (RE2-style) so pattern compilation cannot backtrack catastrophically.

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

OutputSchema.compile() accepts any schema with no size, depth or complexity bound, and it now sits on a trust boundary: prompt() compiles input.format.schema straight from PromptInput/CommandInput (format: MessageV2.Format.optional()), which any SDK/attach-server client can supply, and schemas are also persisted on user messages and re-compiled from storage on resume. The cache key is JSON.stringify(schema) over the raw object — every prompt-loop iteration re-serializes the whole schema even on cache hits, and the 32-entry cache retains arbitrarily large key strings (memory/CPU amplification). Ajv's own docs warn that compiling untrusted schemas is unsafe: deeply nested $ref chains can exhaust the stack during codegen and a pathological pattern compiles to a native RegExp that ReDoSes the session thread on every model-submitted argument validation (this ReDoS aspect was previously raised and remains unaddressed).

  export function compile(schema: Record<string, unknown>) {
    if (schema.type !== "object") throw new Error('schema root must have type "object"')
    const key = JSON.stringify(schema)
    const cached = cache.get(key)
    if (cached) return cached

if (lastUser.format?.type === "json_schema") {
const rejection = async (errors: OutputSchema.Diagnostic[]) => {
if (!contract) throw new Error("Missing output schema")
if (attempts < contract.format.retryCount + 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Omitted retryCount makes budget NaN, breaks SDK runs.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/prompt.ts:728):

Problem: Omitted retryCount makes budget NaN, breaks SDK runs
Detail: The wire contract (packages/sdk/src/v2/gen/types.gen.ts in this PR) declares OutputFormat retryCount as optional (`retryCount?: number`), and prompt()/command() store client-supplied `format` verbatim (`format: input.format`) with no default. Every budget computation then does arithmetic on undefined: `attempts < contract.format.retryCount + 1` (prompt.ts:728) → `0 < NaN` → false, so `available` (prompt.ts:745) is false from the first step and onSuccess throws "StructuredOutput attempt budget exhausted" on the model's FIRST valid StructuredOutput call. `attempts = Math.min(attempts + 1, format.retryCount + 1)` yields NaN, and the `rejected > contract.format.retryCount` exhaustion check (prompt.ts:877) is never true, so the run can only end as missing/step_limit. Any SDK/attach client that sends `format: {type: "json_schema", schema}` without retryCount can never get an accepted result even with perfectly valid model output. The CLI flag path masks this because run.output.ts always fills `retryCount: input.retries ?? 2`.
Suggested fix: Normalize once where the contract is built (prompt.ts ~382-385): `contract = request.format?.type === "json_schema" ? { format: { ...request.format, retryCount: request.format.retryCount ?? 2 }, validate: OutputSchema.compile(request.format.schema) } : undefined` — or add a `.default(2)`/`.catch(2)` on the retryCount field of MessageV2.Format so every consumer (rejection(), available, onSuccess Math.min, exhaustion check, maxAttempts publish) sees a real number.

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 wire contract (packages/sdk/src/v2/gen/types.gen.ts in this PR) declares OutputFormat retryCount as optional (retryCount?: number), and prompt()/command() store client-supplied format verbatim (format: input.format) with no default. Every budget computation then does arithmetic on undefined: attempts < contract.format.retryCount + 1 (prompt.ts:728) → 0 < NaN → false, so available (prompt.ts:745) is false from the first step and onSuccess throws "StructuredOutput attempt budget exhausted" on the model's FIRST valid StructuredOutput call. attempts = Math.min(attempts + 1, format.retryCount + 1) yields NaN, and the rejected > contract.format.retryCount exhaustion check (prompt.ts:877) is never true, so the run can only end as missing/step_limit. Any SDK/attach client that sends format: {type: "json_schema", schema} without retryCount can never get an accepted result even with perfectly valid model output. The CLI flag path masks this because run.output.ts always fills retryCount: input.retries ?? 2.

      })

      const rejection = async (errors: OutputSchema.Diagnostic[]) => {
        if (!contract) throw new Error("Missing output schema")
        if (attempts < contract.format.retryCount + 1) {
          attempts++
          rejected++

throw new Error(`${input.schema}: invalid JSON`)
}
})()
if (!schema || typeof schema !== "object" || Array.isArray(schema))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Two distinct config errors share identical message.

--- a/packages/cli/src/cli/cmd/run.output.ts
+++ b/packages/cli/src/cli/cmd/run.output.ts
@@ -29,2 +29,2 @@
-    throw new Error(`${input.schema}: schema root must have type "object"`)
+    throw new Error(`${input.schema}: schema root must be a JSON object`)
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.output.ts:29-30):

Problem: Two distinct config errors share identical message
Detail: run.output.ts:29-30 throws `"${input.schema}: schema root must have type \"object\""` when the parsed schema file is an array/scalar, and OutputSchema.compile (output-schema.ts:23) throws the byte-identical `'schema root must have type "object"'` when an object root lacks `type: "object"` — which run.output.ts:42 re-wraps into the exact same message. Two distinct failure conditions (non-object JSON vs object without type "object") produce identical configuration errors, so operators cannot distinguish them, and the first message is factually wrong (that check never inspected `type`).
Suggested fix: Use a distinct message for the JSON-shape check, e.g. `${input.schema}: schema root must be a JSON object`, and leave the `type: "object"` wording exclusively to the OutputSchema.compile failure path.

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.output.ts
+++ b/packages/cli/src/cli/cmd/run.output.ts
@@ -29,2 +29,2 @@
-    throw new Error(`${input.schema}: schema root must have type "object"`)
+    throw new Error(`${input.schema}: schema root must be a JSON object`)


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

run.output.ts:29-30 throws "${input.schema}: schema root must have type \"object\"" when the parsed schema file is an array/scalar, and OutputSchema.compile (output-schema.ts:23) throws the byte-identical 'schema root must have type "object"' when an object root lacks type: "object" — which run.output.ts:42 re-wraps into the exact same message. Two distinct failure conditions (non-object JSON vs object without type "object") produce identical configuration errors, so operators cannot distinguish them, and the first message is factually wrong (that check never inspected type).

  const schema: unknown = (() => {
    try {
      return JSON.parse(text)
    } catch {
      throw new Error(`${input.schema}: invalid JSON`)
    }
  })()
  if (!schema || typeof schema !== "object" || Array.isArray(schema))
    throw new Error(`${input.schema}: schema root must have type "object"`)

abort()
await Shutdown.flush()
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Early return leaves events loop running after flush.

Suggested change
}
Settle the loop before returning: `abort(); await Promise.race([loopDone, Shutdown.flush()])` (or break the events stream), or drop the early return and reuse the existing await loopDone / race-with-promptResult path so complete()/reject run exactly once.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.ts:1048-1052):

Problem: Early return leaves events loop running after flush
Detail: With --output-schema, a failed prompt/command request is swallowed via `request.catch(reject)` (reject runs complete() → emits structured_output failed + terminal lifecycle and sets the exit code), then the handler executes `abort(); await Shutdown.flush(); return` without awaiting loopDone. The events-consumption loop is still live: when the aborted session goes idle, loop() resolves and fires complete() a second time, and any structured_output_rejected/status events still in flight are emitted after the Shutdown.flush. If loop() itself rejects, its attached `.catch(reject)` re-runs reject, printing a duplicate `console.error(JSON.stringify(classified))` after the handler returned. NDJSON consumers can observe late/interleaved events or duplicated stderr racing process exit.
Suggested fix: Settle the loop before returning: `abort(); await Promise.race([loopDone, Shutdown.flush()])` (or break the events stream), or drop the early return and reuse the existing await loopDone / race-with-promptResult path so complete()/reject run exactly once.

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

With --output-schema, a failed prompt/command request is swallowed via request.catch(reject) (reject runs complete() → emits structured_output failed + terminal lifecycle and sets the exit code), then the handler executes abort(); await Shutdown.flush(); return without awaiting loopDone. The events-consumption loop is still live: when the aborted session goes idle, loop() resolves and fires complete() a second time, and any structured_output_rejected/status events still in flight are emitted after the Shutdown.flush. If loop() itself rejects, its attached .catch(reject) re-runs reject, printing a duplicate console.error(JSON.stringify(classified)) after the handler returned. NDJSON consumers can observe late/interleaved events or duplicated stderr racing process exit.

        const request = sdk.session.prompt(input(sessionID, opts))
        await (schema ? request.catch(reject) : request)
      }

      if (schema && error) {
        abort()
        await Shutdown.flush()
        return
      }

continue
}
await SessionCompaction.prune({ sessionID })
if (contract) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 "Terminal" structured_output re-emitted per prompt run.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/prompt.ts:896-903):

Problem: "Terminal" structured_output re-emitted per prompt run
Detail: EVENTS.md (added in this PR) specifies `structured_output` is "Exactly one terminal event per schema-enabled session". The implementation publishes Session.Event.StructuredOutput once per prompt() loop completion with no per-session guard. A schema-enabled session that receives a second prompt (SDK session reuse, attach mode, or a resumed session whose loop restarts with the preserved outputFormat on the last user message) emits the "terminal" event again for the same session, violating the documented once-per-session invariant. The CLI side dedups with `!structured` (run.ts), but the session-side publisher has no such guard.
Suggested fix: Either track per-session emission state so the terminal event is published only once per schema-enabled session, or relax EVENTS.md to "exactly one terminal event per schema-enabled prompt run".

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

EVENTS.md (added in this PR) specifies structured_output is "Exactly one terminal event per schema-enabled session". The implementation publishes Session.Event.StructuredOutput once per prompt() loop completion with no per-session guard. A schema-enabled session that receives a second prompt (SDK session reuse, attach mode, or a resumed session whose loop restarts with the preserved outputFormat on the last user message) emits the "terminal" event again for the same session, violating the documented once-per-session invariant. The CLI side dedups with !structured (run.ts), but the session-side publisher has no such guard.

      continue
    }
    await SessionCompaction.prune({ sessionID })
    if (contract) {
      await Bus.publish(Session.Event.StructuredOutput, {
        sessionID,
        outcome: outcome ?? {

if (lastUser.format?.type === "json_schema") {
const rejection = async (errors: OutputSchema.Diagnostic[]) => {
if (!contract) throw new Error("Missing output schema")
if (attempts < contract.format.retryCount + 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Rejection telemetry skipped once retry budget spent.

Suggested change
if (attempts < contract.format.retryCount + 1) {
Always publish Session.Event.StructuredOutputRejected and count the attempt for every rejected call; keep the budget gate only for deciding whether another corrective turn is allowed (the reminder/exhaustion logic), or amend EVENTS.md to document that post-budget rejections are not emitted.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/prompt.ts:728-738):

Problem: Rejection telemetry skipped once retry budget spent
Detail: EVENTS.md (added in this PR) states `structured_output_rejected` is "Emitted once per rejected StructuredOutput attempt, including unparseable JSON and a model turn that finishes in prose without a final tool call", and that `attempts` in the terminal event "counts StructuredOutput calls, including rejected/unparseable ones". But rejection() only increments `attempts`/`rejected` and publishes the Bus event inside `if (attempts < contract.format.retryCount + 1)`. Once the budget is spent (e.g. a second unparseable/parallel StructuredOutput call in the same step, or an unparseable argument routed through the structured reject path on a later step), rejected attempts produce no `structured_output_rejected` telemetry at all and are not counted in `attempts`, contradicting the documented contract and undercounting the terminal event.
Suggested fix: Always publish Session.Event.StructuredOutputRejected and count the attempt for every rejected call; keep the budget gate only for deciding whether another corrective turn is allowed (the reminder/exhaustion logic), or amend EVENTS.md to document that post-budget rejections are not emitted.

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

EVENTS.md (added in this PR) states structured_output_rejected is "Emitted once per rejected StructuredOutput attempt, including unparseable JSON and a model turn that finishes in prose without a final tool call", and that attempts in the terminal event "counts StructuredOutput calls, including rejected/unparseable ones". But rejection() only increments attempts/rejected and publishes the Bus event inside if (attempts < contract.format.retryCount + 1). Once the budget is spent (e.g. a second unparseable/parallel StructuredOutput call in the same step, or an unparseable argument routed through the structured reject path on a later step), rejected attempts produce no structured_output_rejected telemetry at all and are not counted in attempts, contradicting the documented contract and undercounting the terminal event.

      const rejection = async (errors: OutputSchema.Diagnostic[]) => {
        if (!contract) throw new Error("Missing output schema")
        if (attempts < contract.format.retryCount + 1) {
          attempts++
          rejected++
          await Bus.publish(Session.Event.StructuredOutputRejected, {
            sessionID,
            attempt: attempts,
            maxAttempts: contract.format.retryCount + 1,
            errors,
          })
        }
        return OutputSchema.error(errors).message
      }

if (lastUser.format?.type === "json_schema") {
const rejection = async (errors: OutputSchema.Diagnostic[]) => {
if (!contract) throw new Error("Missing output schema")
if (attempts < contract.format.retryCount + 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Wire-supplied retryCount never clamped or bounded.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/session/prompt.ts:728):

Problem: Wire-supplied retryCount never clamped or bounded
Detail: The corrective-retry budget comes from client-supplied `format.retryCount` (PromptInput/CommandInput via SDK/attach), not from the validated `--output-schema-retries` CLI flag, and nothing clamps it: run.output.ts only validates the CLI flag (safe integer >= 0). A negative retryCount makes `attempts < retryCount + 1` false from step one, so the contract is permanently unavailable and every run fails; a huge value (e.g. 1e9) turns each prompt into up to retryCount+1 billable model round-trips before giving up — a cost/DoS amplification from any API client. (Whether MessageV2.Format's zod schema bounds this is not visible in the diff; if it does not, this is live.)
Suggested fix: Clamp at the trust boundary when building the contract: `retryCount = Math.max(0, Math.min(Math.trunc(format.retryCount ?? 2), 10))`, and/or add `.int().min(0).max(10)` to the retryCount field of MessageV2.Format so server- and SDK-supplied values cannot bypass the CLI flag limits.

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 corrective-retry budget comes from client-supplied format.retryCount (PromptInput/CommandInput via SDK/attach), not from the validated --output-schema-retries CLI flag, and nothing clamps it: run.output.ts only validates the CLI flag (safe integer >= 0). A negative retryCount makes attempts < retryCount + 1 false from step one, so the contract is permanently unavailable and every run fails; a huge value (e.g. 1e9) turns each prompt into up to retryCount+1 billable model round-trips before giving up — a cost/DoS amplification from any API client. (Whether MessageV2.Format's zod schema bounds this is not visible in the diff; if it does not, this is live.)

      const rejection = async (errors: OutputSchema.Diagnostic[]) => {
        if (!contract) throw new Error("Missing output schema")
        if (attempts < contract.format.retryCount + 1) {
          attempts++
          rejected++

: [{ type: "session.status", properties: { sessionID: "ses_fixture", status: { type: "idle" } } }]),
])
controller!.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
if (name === "provider rejection")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Attach fixture enqueues via controller! before ready.

Suggested change
if (name === "provider rejection")
Buffer events until the controller is ready: keep a `pending: string[]` filled by the message handler and flushed in the /event start(), or have the message handler await a promise resolved in start() (with a timeout), instead of the `controller!` non-null assertion.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/test/cli/run-output-schema.test.ts:575):

Problem: Attach fixture enqueues via controller! before ready
Detail: In the attach-matrix fixture, `let controller` is assigned only inside the /event ReadableStream's start(), while the /session/ses_fixture/message handler enqueues with a non-null assertion `controller!.enqueue(...)`. If the CLI's POST to /message reaches the server before the /event stream's start() has executed (connect scheduling under heavy CI load), controller is undefined, the TypeError turns the fetch into a 500 and the test fails intermittently. The handler also keeps enqueuing into whatever controller was last assigned with no guard for a cancelled/dropped SSE stream, so enqueue can throw after a mid-test disconnect.
Suggested fix: Buffer events until the controller is ready: keep a `pending: string[]` filled by the message handler and flushed in the /event start(), or have the message handler await a promise resolved in start() (with a timeout), instead of the `controller!` non-null assertion.

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

In the attach-matrix fixture, let controller is assigned only inside the /event ReadableStream's start(), while the /session/ses_fixture/message handler enqueues with a non-null assertion controller!.enqueue(...). If the CLI's POST to /message reaches the server before the /event stream's start() has executed (connect scheduling under heavy CI load), controller is undefined, the TypeError turns the fetch into a 500 and the test fails intermittently. The handler also keeps enqueuing into whatever controller was last assigned with no guard for a cancelled/dropped SSE stream, so enqueue can throw after a mid-test disconnect.

              : name === "provider rejection"
                ? []
                : [{ type: "session.status", properties: { sessionID: "ses_fixture", status: { type: "idle" } } }]),
          ])
            controller!.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
          if (name === "provider rejection")

stderr: "pipe",
},
)
using cleanup = { [Symbol.dispose]: () => proc.kill() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Spawned child lacks SIGKILL watchdog; leaks on hang.

Suggested change
using cleanup = { [Symbol.dispose]: () => proc.kill() }
Add `const timer = setTimeout(() => proc.kill("SIGKILL"), 20000)` right after spawn and `clearTimeout(timer)` in a finally once proc.exited resolves, matching the sibling spawning tests in run-output-schema.test.ts.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/test/provider/models.test.ts:32-37):

Problem: Spawned child lacks SIGKILL watchdog; leaks on hang
Detail: The test spawns `bun -e` running ModelsDev.refresh() against a fixture server whose response body never completes, then awaits Promise.all([stdout, stderr, proc.exited]) with no watchdog. The only kill is `using cleanup = { [Symbol.dispose]: () => proc.kill() }`, which runs when the enclosing async scope completes — if the child does not exit on its own, the pending Promise.all keeps the test body suspended forever and Symbol.dispose never executes for either `cleanup` or `server`: the `bun -e` child is orphaned and the fixture server keeps its port. Every sibling spawning test added in this PR (run-output-schema.test.ts) guards with `setTimeout(() => proc.kill("SIGKILL"), 20000)`; this one omits it.
Suggested fix: Add `const timer = setTimeout(() => proc.kill("SIGKILL"), 20000)` right after spawn and `clearTimeout(timer)` in a finally once proc.exited resolves, matching the sibling spawning tests in run-output-schema.test.ts.

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 test spawns bun -e running ModelsDev.refresh() against a fixture server whose response body never completes, then awaits Promise.all([stdout, stderr, proc.exited]) with no watchdog. The only kill is using cleanup = { [Symbol.dispose]: () => proc.kill() }, which runs when the enclosing async scope completes — if the child does not exit on its own, the pending Promise.all keeps the test body suspended forever and Symbol.dispose never executes for either cleanup or server: the bun -e child is orphaned and the fixture server keeps its port. Every sibling spawning test added in this PR (run-output-schema.test.ts) guards with setTimeout(() => proc.kill("SIGKILL"), 20000); this one omits it.

  using cleanup = { [Symbol.dispose]: () => proc.kill() }
  const [stdout, stderr, exit] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
    proc.exited,
  ])

}
if (input.retries !== undefined && (!Number.isSafeInteger(input.retries) || input.retries < 0))
throw new Error("--output-schema-retries must be an integer >= 0")
const text = await Bun.file(input.schema)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Module mixes Bun and node fs APIs, skips Filesystem util.

Suggested change
const text = await Bun.file(input.schema)
Route reads/writes through the existing Filesystem utility (as models.ts does), or at minimum settle on a single runtime API instead of mixing `Bun.file`/`Bun.write` with `fs.stat`/`fs.rename` in the same file.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, packages/cli/src/cli/cmd/run.output.ts:17):

Problem: Module mixes Bun and node fs APIs, skips Filesystem util
Detail: The new module reads the schema via `Bun.file(...).text()`, writes the result via `Bun.write(...)`, and stats/renames via node `fs/promises`, while sibling code in the same PR (packages/cli/src/provider/models.ts refresh()) uses the codebase's `Filesystem` wrapper for writes. Mixing three IO styles in one 63-line module diverges from the established file-access pattern and makes the atomicity guarantees EVENTS.md documents harder to audit.
Suggested fix: Route reads/writes through the existing Filesystem utility (as models.ts does), or at minimum settle on a single runtime API instead of mixing `Bun.file`/`Bun.write` with `fs.stat`/`fs.rename` in the same file.

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 new module reads the schema via Bun.file(...).text(), writes the result via Bun.write(...), and stats/renames via node fs/promises, while sibling code in the same PR (packages/cli/src/provider/models.ts refresh()) uses the codebase's Filesystem wrapper for writes. Mixing three IO styles in one 63-line module diverges from the established file-access pattern and makes the atomicity guarantees EVENTS.md documents harder to audit.

import path from "path"
import fs from "fs/promises"
import { OutputSchema } from "@/session/output-schema"

@aictrl-dev

aictrl-dev Bot commented Oct 7, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 2 · 🟡 7 · ⚪ 1 · 0/10 resolved

  • ⚪ packages/cli/src/cli/cmd/run.output.ts:17 — Module mixes Bun and node fs APIs, skips Filesystem util
  • 🟡 packages/cli/src/cli/cmd/run.output.ts:29-30 — Two distinct config errors share identical message
  • 🟡 packages/cli/src/cli/cmd/run.ts:1048-1052 — Early return leaves events loop running after flush
  • 🟠 packages/cli/src/session/output-schema.ts:24 — Unbounded client schema compiled with no size guard
  • 🟠 packages/cli/src/session/prompt.ts:728 — Omitted retryCount makes budget NaN, breaks SDK runs
  • 🟡 packages/cli/src/session/prompt.ts:728-738 — Rejection telemetry skipped once retry budget spent
  • 🟡 packages/cli/src/session/prompt.ts:728 — Wire-supplied retryCount never clamped or bounded
  • 🟡 packages/cli/src/session/prompt.ts:896-903 — "Terminal" structured_output re-emitted per prompt run
  • 🟡 packages/cli/test/cli/run-output-schema.test.ts:575 — Attach fixture enqueues via controller! before ready
  • 🟡 packages/cli/test/provider/models.test.ts:32-37 — Spawned child lacks SIGKILL watchdog; leaks on hang
🤖 Fix all 10 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #129 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.output.ts:17 — Module mixes Bun and node fs APIs, skips Filesystem util
   Detail: The new module reads the schema via `Bun.file(...).text()`, writes the result via `Bun.write(...)`, and stats/renames via node `fs/promises`, while sibling code in the same PR (packages/cli/src/provider/models.ts refresh()) uses the codebase's `Filesystem` wrapper for writes. Mixing three IO styles in one 63-line module diverges from the established file-access pattern and makes the atomicity guarantees EVENTS.md documents harder to audit.
   Suggested fix: Route reads/writes through the existing Filesystem utility (as models.ts does), or at minimum settle on a single runtime API instead of mixing `Bun.file`/`Bun.write` with `fs.stat`/`fs.rename` in the same file.
2. packages/cli/src/cli/cmd/run.output.ts:29-30 — Two distinct config errors share identical message
   Detail: run.output.ts:29-30 throws `"${input.schema}: schema root must have type \"object\""` when the parsed schema file is an array/scalar, and OutputSchema.compile (output-schema.ts:23) throws the byte-identical `'schema root must have type "object"'` when an object root lacks `type: "object"` — which run.output.ts:42 re-wraps into the exact same message. Two distinct failure conditions (non-object JSON vs object without type "object") produce identical configuration errors, so operators cannot distinguish them, and the first message is factually wrong (that check never inspected `type`).
   Suggested fix: Use a distinct message for the JSON-shape check, e.g. `${input.schema}: schema root must be a JSON object`, and leave the `type: "object"` wording exclusively to the OutputSchema.compile failure path.
3. packages/cli/src/cli/cmd/run.ts:1048-1052 — Early return leaves events loop running after flush
   Detail: With --output-schema, a failed prompt/command request is swallowed via `request.catch(reject)` (reject runs complete() → emits structured_output failed + terminal lifecycle and sets the exit code), then the handler executes `abort(); await Shutdown.flush(); return` without awaiting loopDone. The events-consumption loop is still live: when the aborted session goes idle, loop() resolves and fires complete() a second time, and any structured_output_rejected/status events still in flight are emitted after the Shutdown.flush. If loop() itself rejects, its attached `.catch(reject)` re-runs reject, printing a duplicate `console.error(JSON.stringify(classified))` after the handler returned. NDJSON consumers can observe late/interleaved events or duplicated stderr racing process exit.
   Suggested fix: Settle the loop before returning: `abort(); await Promise.race([loopDone, Shutdown.flush()])` (or break the events stream), or drop the early return and reuse the existing await loopDone / race-with-promptResult path so complete()/reject run exactly once.
4. packages/cli/src/session/output-schema.ts:24 — Unbounded client schema compiled with no size guard
   Detail: OutputSchema.compile() accepts any schema with no size, depth or complexity bound, and it now sits on a trust boundary: prompt() compiles `input.format.schema` straight from PromptInput/CommandInput (`format: MessageV2.Format.optional()`), which any SDK/attach-server client can supply, and schemas are also persisted on user messages and re-compiled from storage on resume. The cache key is `JSON.stringify(schema)` over the raw object — every prompt-loop iteration re-serializes the whole schema even on cache hits, and the 32-entry cache retains arbitrarily large key strings (memory/CPU amplification). Ajv's own docs warn that compiling untrusted schemas is unsafe: deeply nested $ref chains can exhaust the stack during codegen and a pathological `pattern` compiles to a native RegExp that ReDoSes the session thread on every model-submitted argument validation (this ReDoS aspect was previously raised and remains unaddressed).
   Suggested fix: Enforce a hard byte cap on the serialized schema before stringify/compile (e.g. reject when Buffer.byteLength(JSON.stringify(schema)) > ~64 KB with a config-style error), use a truncated SHA-256 of the serialized schema as the cache key instead of the raw string, and consider Ajv `code: { regExp }` with a safe engine (RE2-style) so pattern compilation cannot backtrack catastrophically.
5. packages/cli/src/session/prompt.ts:728 — Omitted retryCount makes budget NaN, breaks SDK runs
   Detail: The wire contract (packages/sdk/src/v2/gen/types.gen.ts in this PR) declares OutputFormat retryCount as optional (`retryCount?: number`), and prompt()/command() store client-supplied `format` verbatim (`format: input.format`) with no default. Every budget computation then does arithmetic on undefined: `attempts < contract.format.retryCount + 1` (prompt.ts:728) → `0 < NaN` → false, so `available` (prompt.ts:745) is false from the first step and onSuccess throws "StructuredOutput attempt budget exhausted" on the model's FIRST valid StructuredOutput call. `attempts = Math.min(attempts + 1, format.retryCount + 1)` yields NaN, and the `rejected > contract.format.retryCount` exhaustion check (prompt.ts:877) is never true, so the run can only end as missing/step_limit. Any SDK/attach client that sends `format: {type: "json_schema", schema}` without retryCount can never get an accepted result even with perfectly valid model output. The CLI flag path masks this because run.output.ts always fills `retryCount: input.retries ?? 2`.
   Suggested fix: Normalize once where the contract is built (prompt.ts ~382-385): `contract = request.format?.type === "json_schema" ? { format: { ...request.format, retryCount: request.format.retryCount ?? 2 }, validate: OutputSchema.compile(request.format.schema) } : undefined` — or add a `.default(2)`/`.catch(2)` on the retryCount field of MessageV2.Format so every consumer (rejection(), available, onSuccess Math.min, exhaustion check, maxAttempts publish) sees a real number.
6. packages/cli/src/session/prompt.ts:728-738 — Rejection telemetry skipped once retry budget spent
   Detail: EVENTS.md (added in this PR) states `structured_output_rejected` is "Emitted once per rejected StructuredOutput attempt, including unparseable JSON and a model turn that finishes in prose without a final tool call", and that `attempts` in the terminal event "counts StructuredOutput calls, including rejected/unparseable ones". But rejection() only increments `attempts`/`rejected` and publishes the Bus event inside `if (attempts < contract.format.retryCount + 1)`. Once the budget is spent (e.g. a second unparseable/parallel StructuredOutput call in the same step, or an unparseable argument routed through the structured reject path on a later step), rejected attempts produce no `structured_output_rejected` telemetry at all and are not counted in `attempts`, contradicting the documented contract and undercounting the terminal event.
   Suggested fix: Always publish Session.Event.StructuredOutputRejected and count the attempt for every rejected call; keep the budget gate only for deciding whether another corrective turn is allowed (the reminder/exhaustion logic), or amend EVENTS.md to document that post-budget rejections are not emitted.
7. packages/cli/src/session/prompt.ts:728 — Wire-supplied retryCount never clamped or bounded
   Detail: The corrective-retry budget comes from client-supplied `format.retryCount` (PromptInput/CommandInput via SDK/attach), not from the validated `--output-schema-retries` CLI flag, and nothing clamps it: run.output.ts only validates the CLI flag (safe integer >= 0). A negative retryCount makes `attempts < retryCount + 1` false from step one, so the contract is permanently unavailable and every run fails; a huge value (e.g. 1e9) turns each prompt into up to retryCount+1 billable model round-trips before giving up — a cost/DoS amplification from any API client. (Whether MessageV2.Format's zod schema bounds this is not visible in the diff; if it does not, this is live.)
   Suggested fix: Clamp at the trust boundary when building the contract: `retryCount = Math.max(0, Math.min(Math.trunc(format.retryCount ?? 2), 10))`, and/or add `.int().min(0).max(10)` to the retryCount field of MessageV2.Format so server- and SDK-supplied values cannot bypass the CLI flag limits.
8. packages/cli/src/session/prompt.ts:896-903 — "Terminal" structured_output re-emitted per prompt run
   Detail: EVENTS.md (added in this PR) specifies `structured_output` is "Exactly one terminal event per schema-enabled session". The implementation publishes Session.Event.StructuredOutput once per prompt() loop completion with no per-session guard. A schema-enabled session that receives a second prompt (SDK session reuse, attach mode, or a resumed session whose loop restarts with the preserved outputFormat on the last user message) emits the "terminal" event again for the same session, violating the documented once-per-session invariant. The CLI side dedups with `!structured` (run.ts), but the session-side publisher has no such guard.
   Suggested fix: Either track per-session emission state so the terminal event is published only once per schema-enabled session, or relax EVENTS.md to "exactly one terminal event per schema-enabled prompt run".
9. packages/cli/test/cli/run-output-schema.test.ts:575 — Attach fixture enqueues via controller! before ready
   Detail: In the attach-matrix fixture, `let controller` is assigned only inside the /event ReadableStream's start(), while the /session/ses_fixture/message handler enqueues with a non-null assertion `controller!.enqueue(...)`. If the CLI's POST to /message reaches the server before the /event stream's start() has executed (connect scheduling under heavy CI load), controller is undefined, the TypeError turns the fetch into a 500 and the test fails intermittently. The handler also keeps enqueuing into whatever controller was last assigned with no guard for a cancelled/dropped SSE stream, so enqueue can throw after a mid-test disconnect.
   Suggested fix: Buffer events until the controller is ready: keep a `pending: string[]` filled by the message handler and flushed in the /event start(), or have the message handler await a promise resolved in start() (with a timeout), instead of the `controller!` non-null assertion.
10. packages/cli/test/provider/models.test.ts:32-37 — Spawned child lacks SIGKILL watchdog; leaks on hang
   Detail: The test spawns `bun -e` running ModelsDev.refresh() against a fixture server whose response body never completes, then awaits Promise.all([stdout, stderr, proc.exited]) with no watchdog. The only kill is `using cleanup = { [Symbol.dispose]: () => proc.kill() }`, which runs when the enclosing async scope completes — if the child does not exit on its own, the pending Promise.all keeps the test body suspended forever and Symbol.dispose never executes for either `cleanup` or `server`: the `bun -e` child is orphaned and the fixture server keeps its port. Every sibling spawning test added in this PR (run-output-schema.test.ts) guards with `setTimeout(() => proc.kill("SIGKILL"), 20000)`; this one omits it.
   Suggested fix: Add `const timer = setTimeout(() => proc.kill("SIGKILL"), 20000)` right after spawn and `clearTimeout(timer)` in a finally once proc.exited resolves, matching the sibling spawning tests in run-output-schema.test.ts.
📋 Out-of-diff findings (10)
Sev Location Finding
⚪ packages/cli/src/cli/cmd/run.output.ts:17 Module mixes Bun and node fs APIs, skips Filesystem util
🟡 packages/cli/src/cli/cmd/run.output.ts:29-30 Two distinct config errors share identical message
🟡 packages/cli/src/cli/cmd/run.ts:1048-1052 Early return leaves events loop running after flush
🟠 packages/cli/src/session/output-schema.ts:24 Unbounded client schema compiled with no size guard
🟠 packages/cli/src/session/prompt.ts:728 Omitted retryCount makes budget NaN, breaks SDK runs
🟡 packages/cli/src/session/prompt.ts:728-738 Rejection telemetry skipped once retry budget spent
🟡 packages/cli/src/session/prompt.ts:728 Wire-supplied retryCount never clamped or bounded
🟡 packages/cli/src/session/prompt.ts:896-903 "Terminal" structured_output re-emitted per prompt run
🟡 packages/cli/test/cli/run-output-schema.test.ts:575 Attach fixture enqueues via controller! before ready
🟡 packages/cli/test/provider/models.test.ts:32-37 Spawned child lacks SIGKILL watchdog; leaks on hang

Reviewed 24 files · 0 inline · view all 10 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@aictrl-dev

aictrl-dev Bot commented Oct 13, 2026

Copy link
Copy Markdown

Terms used here

  • JSON Schema / Ajv: a schema describes the shape a JSON value must have; Ajv 8 checks values against it in strict mode: no coercion, defaults or stripping.
  • StructuredOutput tool: the tool a model must call to deliver its final answer in schema mode; its arguments were never validated before.
  • Corrective retry budget: extra repair attempts after the first (default 2). Rejected, unparseable and missing calls all charge it.
  • NDJSON events: JSON lines aictrl run --format json prints to stdout; this PR adds structured_output_rejected and a terminal structured_output.

The problem

The pieces existed but enforced nothing. format: { type: "json_schema" } and a StructuredOutput tool were declared, and the message schema even declared retryCount — but nothing read it. The old tool wrapped the schema in the AI SDK's jsonSchema() helper without a validator, so malformed calls were captured as successful results. aictrl run had no way to pass a schema or receive a validated result, so a headless consumer could ask for JSON and silently get garbage.

The idea: one validator, one budget, enforced at every boundary

The schema is compiled once with Ajv (Ajv2020 when it declares draft 2020-12; allErrors on) and that canonical validator is reused everywhere: CLI preflight, the tool's input schema, the tool's execute, session acceptance, and just before the result file is written — the same validator the executor's checker uses, so verdicts agree (parity-tested over a hostile corpus).

Invalid or truncated arguments go back to the model through the existing invalid-tool repair channel with bounded diagnostics: at most 10 errors, 64-character path segments, under 2 KB total, never submitted values. Each rejection emits structured_output_rejected. When the budget is spent the run fails with a StructuredOutputError, a terminal event naming the reason (exhausted, missing, step_limit, aborted, error), and exit 3. Configuration problems — unreadable file, non-object root, bad retries, unwritable result directory — fail in preflight with exit 2.

The parts that look easy and aren't

  • Parallel calls: a valid call must beat an invalid one in the same step regardless of order; availability is decided once per step and the first valid capture sticks.
  • Provider retries mid-stream: the processor snapshots the budget before each provider attempt and restores it when a retryable error discards the stream, so a flaky provider cannot eat it.
  • Prose-only finishes: plain text instead of a tool call is a counted attempt plus a reminder next turn, not instant failure; compaction runs before the reminder.
  • Exit-code races: the CLI keys exit 3 on whether the session itself reported a StructuredOutputError, so provider failures stay at exit 1 regardless of SSE ordering.
  • Log hygiene: schema-mode logs keep only a bounded error summary; provider errors can echo the whole prompt.

The enforcement loop

The lifecycle spans the CLI preflight, the session loop and the repair channel. Config errors exit 2 before any model call; rejected attempts loop back while budget remains; only a value surviving final re-validation reaches the result file.

flowchart TD
  flags["--output-schema file"] --> preflight["Preflight: read, parse, compile schema"]
  preflight -->|config error| cfgfail["Exit 2 before any model call"]
  preflight --> turn["Model turn with StructuredOutput tool, toolChoice required"]
  turn --> step_call{"Valid JSON matching the schema?"}
  step_call -->|no| repair["Rejected attempt: invalid-tool repair with bounded diagnostics"]
  repair --> budget{"Attempt budget left?"}
  budget -->|yes| turn
  budget -->|no| fail3["Failed: exhausted or missing, exit 3"]
  step_call -->|yes| capture["Capture value and re-validate at acceptance"]
  capture --> accept["Accepted: result file written atomically, exit 0"]
classDef fail fill:#fef2f2,stroke:#dc2626,color:#991b1b
classDef retry fill:#fffbeb,stroke:#d97706,color:#92400e
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#166534
class fail3,cfgfail fail
class accept ok
Loading

What is live today

aictrl run accepts --output-schema <file> (object-root JSON Schema, local and --attach), --output-schema-retries <n> (default 2; 0 means one attempt) and --output-result <file>, written atomically (temp plus rename) with only the validated value, only on success. Without a result file, formatted mode prints the value and JSON mode carries it in the terminal event. EVENTS.md documents both events as additive in schema "1". New exit codes: 2 (config), 3 (structured failure); existing codes and plain-run event order are unchanged, pinned by a regression test.

Two pre-existing bugs ride along: --attach was broken for every run (the legacy SDK client expects { path, body } but run passed a flat { sessionID, ... }; one adapter now shapes all attach calls), and the models.dev refresh leaked an unhandled TimeoutError on a stalled body. Native provider constraints (#5959) and executor integration (#5960) are out of scope.

Reviewer notes

Reading order. Start at packages/cli/src/session/output-schema.ts (new, 80 lines): compile at line 22 (object-root guard, Ajv2020 for a declared 2020-12 $schema, allErrors: true, a 32-entry compile cache), diagnostics at line 38 (10 errors, 64-char path segments, <2 KB encoded bound), parse at line 54 (JSON failures return a fixed parse diagnostic that never echoes input) and summary at line 65 (bounded log identity). Then packages/cli/src/cli/cmd/run.output.ts (new): outputSchema (lines 9–52) is the preflight — flag combinations, integer retries ≥ 0, readable/valid JSON, compilable object-root schema, writable result directory — returning { format: { type: "json_schema", schema, retryCount: retries ?? 2 }, validate }; outputResult (lines 54–63) writes temp + rename with an abort check between. Then the core loop in packages/cli/src/session/prompt.ts.

Enforcement chain. prompt() compiles the schema up front (prompt.ts:186) so a bad schema fails before the loop. Inside loop(), state lives in prompt.ts:330-342 and the contract is re-derived from the last user message (379-389) — which is also why a genuine plain follow-up clears schema mode. The rejection/reject closures (726-744) increment attempts/rejected, publish Session.Event.StructuredOutputRejected and return the error text for the repair channel. The tool registration (746-760) captures availability per step and enforces the budget in onSuccess (attempts = Math.min(attempts + 1, format.retryCount + 1); first valid capture wins). Acceptance re-validation is prompt.ts:847-858 (contract?.validate(structuredOutput) guards message.structured), exhaustion/missing at 877-888 persists a StructuredOutputError (message-v2.ts:43-49, retries = corrective attempts used), and 896-905 publishes the single terminal event with the aborted | error | missing fallback. createStructuredOutputTool (1122-1154) attaches the validator via jsonSchema(..., { validate }), re-checks in execute, and sends the provider a ProviderTransform.schema-sanitised copy while validating locally against the canonical schema.

Repair channel and processor interplay. llm.ts:181-192 converts a failed StructuredOutput call into an invalid tool call whose input embeds await input.structured.reject(...); repairs records the call ID so the processor's doom-loop guard (processor.ts:236-249) never blocks legitimate repairs. The processor snapshots the budget per provider attempt (processor.ts:86) and restores it on a retryable error via reset?.() (processor.ts:481). Schema-mode logs drop stacks and use OutputSchema.summary (llm.ts:176-180, processor.ts:467-471).

CLI wiring. Flags at run.ts:352-357; preflight fail with OUTPUT_SCHEMA_CONFIG/exit 2 at run.ts:414-424 (constants in run.output.ts:5-7). complete() (run.ts:586-613) writes the result file only for an accepted, re-validated outcome with no error and no signal, else emits a failed terminal event and sets exit 3. The event loop (run.ts:622-646) forwards both bus events; session.error sets exit 1, or 3 when the error is a StructuredOutputError (run.ts:812-817, invalid flag); reject() keeps provider failures at exit 1 regardless of SSE ordering (run.ts:986, the last commit 487f616). Config-error causes are clipped to 300 chars in invocation_error (run.invocation.ts:39-42).

Side fixes. Attach adapter: run.ts:487-490 ({ path: { id } } / { body } / throwOnError) now shapes fork/create/share/prompt/command calls. models.dev: models.ts:118-120 wraps result.text() in a catch so a stalled body only logs.

Probe points. (1) Does a plain run change? run-output-schema.test.ts:416 pins event types/order. (2) Can a value bypass validation? It must pass four checks — tool schema validate, execute re-check, acceptance contract?.validate, and the CLI-side schema.validate before the file write (run.ts:588). (3) Does a provider retry eat budget? Check checkpoint/reset pairing (prompt.ts:831-838, processor.ts:86/481) and the restoring test (output-schema.test.ts:560). (4) Do diagnostics leak values? parse returns a fixed string; diagnostics clips paths/keywords/messages only. (5) Gemini parity: the provider sees a transformed schema, local validation stays canonical (prompt.ts:1128-1137, test at output-schema.test.ts:479).

Test coverage. packages/cli/test/session/output-schema.test.ts (new, 647 lines) drives the real fake-provider HTTP/SSE → AI SDK → tool → session loop: missing field across the real tool boundary (line 300), truncated JSON (326), retry bounds 0/2/4 (340), bounded diagnostics (350), parallel-call ordering (378, 581), prose-finish-as-attempt (491, 518), compaction before reminder (528), budget restore (560), permission denial → missing (590), Ajv verdict parity over the corpus in test/fixture/output-schema.ts (433), draft-2020-12 dialect (444, 463). packages/cli/test/cli/run-output-schema.test.ts (new, 690 lines) covers CLI subprocesses: atomic validated writes (298), zero-retry failure after exactly one request (344), exhaustion preserves the result file (366), plain and schema attach through the HTTP boundary (435, 454, 494), cancellation cleans temp files (477), JSON-mode value-in-terminal-event only (657), permission rejection → missing with exit 3 (666), config-cause clipping in NDJSON (682). models.test.ts:4 adds the stalled-body regression. Per the PR description (not verifiable here): the full suite reports 1646 pass / 7 skip, mutation checks flip specific tests red, and live Vertex/GLM runs accepted with one repaired rejection — treat those as author-reported.


Written at 487f616 by aictrl · Explore the diagrams and code interactively in aictrl →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant