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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,90 @@ Emitted when a permission request resolves to `allow` (either by matching an `al
"input": { "command": "ls" }
}
```

## Structured final results

These events are additive in schema version `"1"` and emitted only when
`--output-schema <file>` is supplied. Other runs retain their event types and order.
The schema is validated before any model request, with Ajv defaults and
`allErrors: true` (draft-07, or Ajv2020 for a declared draft 2020-12). Validation
does not coerce types, insert defaults or remove extra properties.

`--output-schema-retries <n>` permits N additional corrective attempts (default 2).
Zero permits exactly one attempt. `--output-result <file>` writes only accepted
JSON using two-space indentation and a trailing newline, with a temporary file
in the destination directory followed by an atomic rename. Failures preserve the
file. Both flags require `--output-schema`; the destination directory must exist and be writable at configuration time.
Without `--output-result`, formatted mode prints final JSON; NDJSON includes the
value in the terminal event below.

### `structured_output_rejected`

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

```json
{
"type": "structured_output_rejected",
"attempt": 1,
"maxAttempts": 3,
"errors": [{ "path": "/count", "keyword": "type", "message": "must be integer" }]
}
```

The usual event envelope fields also apply. `attempt` is one-based;
`maxAttempts` equals `retryCount + 1`. Diagnostics include at most ten errors,
with bounded JSON-pointer paths, keywords and messages. Their encoded payload
is under 2 KB and never includes submitted values or raw JSON arguments. A `path`
can contain property names submitted by the model (for example, in map or
`patternProperties` schemas), never their values. Each path segment is clipped
to 64 characters; paths also have an overall length bound.
Validation feedback goes to the model in the same session through the `invalid` tool.
A prose-only finish emits the fixed diagnostic
`{ "path": "", "keyword": "missing", "message": "call StructuredOutput with the final result" }`
and receives an ephemeral reminder before the next turn while budget remains.
Other tools remain usable until a valid final result; their outputs are not final
results. Provider-compatible tool schemas may be transformed, while local
validation always uses the canonical schema.

### `structured_output`

Exactly one terminal event per schema-enabled session, before `session_complete`:

```json
{ "type": "structured_output", "status": "accepted", "attempts": 2, "value": { "result": "ok" } }
```

or:

```json
{ "type": "structured_output", "status": "failed", "reason": "exhausted", "attempts": 3 }
```

`attempts` counts StructuredOutput calls, including rejected/unparseable ones,
and prose-only finishes without a final call. Other tool calls do not count.
Compaction turns do not consume a missing-result attempt. Discarded provider
streams restore the budget and captured result; their rejection events remain
as telemetry. A valid call in a parallel step wins regardless of call order if
that step started with budget remaining, and its attempt count stays within the
configured maximum.
When the budget is spent, the last rejection determines the reason: `missing`
for a prose-only finish, otherwise `exhausted`. Failure reasons are:

- `exhausted`: invalid or unparseable calls spent the corrective budget.
- `missing`: prose finishes spent the budget, or the session ended normally
without an accepted result (including headless permission rejection).
- `step_limit`: the agent reached its configured step cap without a result.
- `aborted`: the invocation was cancelled, including SIGINT/SIGTERM.
- `error`: a provider or session error, including stream idle timeout or an
invalid accepted result received from a remote session.

Failed events have no `value`. No prose fallback is accepted.
Configuration failures happen before a session starts and use the existing
`invocation_error`/`invocation_complete` envelope, with code `OUTPUT_SCHEMA_CONFIG` and the specific cause clipped to 300 characters.

Exit codes are **0** on accepted results, **2** for schema configuration errors,
**3** for exhausted attempts, missing output and step limits. Provider errors and
timeouts keep **1**; SIGINT and SIGTERM keep **130** and **143**.
`StructuredOutputError.data.retries` records the number of corrective
attempts used (initial attempt excluded), including prose-only corrective turns.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ For programmatic consumption, use `--format json` to get raw events.
aictrl run --format json "review this PR" | jq '.type'
```

### Validated Final Results

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Result section duplicated verbatim in two READMEs.

Suggested change
### Validated Final Results
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.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #129, README.md:52-87):

Problem: 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.

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 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.


`aictrl run --output-schema <file>` validates the final JSON result.
Use `--output-schema-retries <n>` for additional corrective attempts (default 2)
and `--output-result <file>` to atomically write the accepted value.
Exit codes: **0** accepted, **2** configuration error, **3** missing or invalid result;
provider failures and stream timeouts use **1**, signals use **130**/**143**.
See the [full contract](packages/cli/README.md#validated-final-results)
and [JSON event reference](EVENTS.md).

### Non-Interactive Execution

In headless mode, Aictrl automatically rejects all interactive permission requests (like `question` or `plan_enter`), ensuring your pipelines never hang.
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,43 @@ For programmatic pipes:
aictrl run --format json "scan for secrets" | jq '.properties.part.text'
```

### Validated Final Results

`aictrl run --output-schema <file>` enforces a JSON Schema on the final result
while the agent can continue using tools. The schema root must be `type: "object"`.
Schemas use Ajv defaults (strict validation, draft-07); a declared draft 2020-12
schema uses Ajv2020. Invalid configuration exits before any model request.

```bash
aictrl run --output-schema result.schema.json --output-schema-retries 2 \
--output-result result.json "review this change"
```

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
- `--output-schema-retries <n>`: additional corrective attempts, integer >= 0,
default 2. Zero permits one attempt; N permits at most N+1 attempts.
- `--output-result <file>`: atomically write only the validated JSON value,
indented with two spaces and a trailing newline. Failures preserve an existing
file and leave an absent file absent. The destination directory must exist.

Ajv strict mode rejects unknown schema keywords and unregistered `format`
values such as `date-time` and `uri` as configuration errors (exit **2**).
The result parent directory must exist and be writable at configuration time.

The retries and result flags require `--output-schema`. Without a result file,
formatted mode prints validated JSON at the end; `--format json` emits it in a
terminal `structured_output` event. Rejections emit `structured_output_rejected`
with bounded JSON-pointer diagnostics. See [EVENTS.md](../../EVENTS.md) for the contract.
No valid result means failure, including missing output and the agent step limit;
there is no prose fallback. A prose-only finish consumes one attempt and receives
a reminder to call StructuredOutput while retries remain. Earlier tools are not
replayed for corrective attempts.

Exit codes: **0** for accepted results, **2** for schema configuration errors,
**3** for exhausted attempts, missing output or a step limit without a result.
Provider failures and stream timeouts retain **1**; SIGINT/SIGTERM retain
**130**/**143**. Invocations without `--output-schema` keep their existing output.

### Auto-Reject Permissions

In headless mode, Aictrl automatically rejects permissions that would otherwise prompt a user.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ajv": "8.18.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/cli/cmd/generate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CommandModule } from "yargs"
import z from "zod"
import { MessageV2 } from "@/session/message-v2"
import { Session } from "@/session"
import { ProviderTermination } from "@/provider/termination"

export const GenerateCommand = {
Expand All @@ -10,9 +11,26 @@ export const GenerateCommand = {
const registry = z.registry<{ id: string }>()
registry.add(MessageV2.StepFinishPart, { id: "StepFinishPart" })
registry.add(ProviderTermination.Info, { id: "ProviderTermination" })
registry.add(MessageV2.Format, { id: "OutputFormat" })
registry.add(MessageV2.Assistant, { id: "AssistantMessage" })
registry.add(
z.object({
type: z.literal(Session.Event.StructuredOutput.type),
properties: Session.Event.StructuredOutput.properties,
}),
{ id: "EventSessionStructuredOutput" },
)
registry.add(
z.object({
type: z.literal(Session.Event.StructuredOutputRejected.type),
properties: Session.Event.StructuredOutputRejected.properties,
}),
{ id: "EventSessionStructuredOutputRejected" },
)
const components = z.toJSONSchema(registry, {
metadata: z.registry(),
target: "draft-2020-12",
io: "input",
uri: (id) => `#/components/schemas/${id}`,
})
// Component JSON pointers are references, not standalone schema resource IDs.
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/cli/cmd/run.invocation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Stdout } from "../stdout"
import { SCHEMA_VERSION } from "./run.errors"
import { OUTPUT_SCHEMA_CONFIG } from "./run.output"

export type RunInvocationPhase = "validation" | "stdin" | "bootstrap" | "session"

Expand Down Expand Up @@ -27,14 +28,17 @@ export function createRunInvocation(enabled: boolean) {

emit("invocation_start")

function error(_error: unknown, code = `INVOCATION_${phase.toUpperCase()}_FAILED`) {
function error(cause: unknown, code = `INVOCATION_${phase.toUpperCase()}_FAILED`) {
if (!id || failed || completed) return
failed = true
if (sessionID) return
emit("invocation_error", {
phase,
code,
message: `Invocation failed during ${phase}`,
message:
code === OUTPUT_SCHEMA_CONFIG
? (cause instanceof Error ? cause.message : String(cause)).slice(0, 300)
: `Invocation failed during ${phase}`,
})
}

Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/cli/cmd/run.output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import path from "path"
import fs from "fs/promises"
import { OutputSchema } from "@/session/output-schema"

export const OUTPUT_SCHEMA_CONFIG = "OUTPUT_SCHEMA_CONFIG"
export const OUTPUT_CONFIG_EXIT = 2
export const OUTPUT_FAILED_EXIT = 3

export async function outputSchema(input: { schema?: string; retries?: number; result?: string }) {
if (!input.schema) {
if (input.retries !== undefined || input.result !== undefined)
throw new Error("--output-schema-retries and --output-result require --output-schema")
return
}
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"

.text()
.catch(() => {
throw new Error(`${input.schema}: cannot read schema file`)
})
const schema: unknown = (() => {
try {
return JSON.parse(text)
} catch {
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"`)

throw new Error(`${input.schema}: schema root must have type "object"`)
const canonical = schema as Record<string, unknown>
const validate = (() => {
try {
return OutputSchema.compile(canonical)
} catch (error) {
throw new Error(`${input.schema}: ${error instanceof Error ? error.message : "cannot compile schema"}`)
}
})()
if (input.result) {
const directory = path.dirname(input.result)
const writable = await fs
.stat(directory)
.then(async (stat) => {
if (!stat.isDirectory()) return false
await fs.access(directory, fs.constants.W_OK | fs.constants.X_OK)
return true
})
.catch(() => false)
if (!writable) throw new Error(`${input.result}: output-result parent directory must exist and be writable`)
}
return { format: { type: "json_schema" as const, schema: canonical, retryCount: input.retries ?? 2 }, validate }
}

export async function outputResult(file: string, value: unknown, aborted = () => false) {
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomUUID()}.tmp`)
try {
await Bun.write(temp, JSON.stringify(value, null, 2) + "\n", { createPath: false })
if (aborted()) throw new Error("Structured output cancelled")
await fs.rename(temp, file)
} finally {
await fs.rm(temp, { force: true })
}
}
Loading
Loading