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
60 changes: 56 additions & 4 deletions packages/agent-harness/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ constrained modes).
the count and add your id, or the suite fails.
- [`tests/harnesses.test.ts`](tests/harnesses.test.ts) has a `describe` block
per harness asserting metadata (`name`, `displayName`, `defaultPath`,
`promptFlag`, `supportsMaxTurns`) and exact `buildArgs()` output for empty /
partial / full option sets, including mode combinations. Mirror that layout.
`promptFlag`, `supportsMaxTurns`, `supportsStructuredOutput`) and exact
`buildArgs()` output for empty / partial / full option sets, including mode
combinations. Mirror that layout.
- If you extend shared behavior (prompt delivery, attachments, limit
patterns), the relevant suites live alongside:
patterns, structured output), the relevant suites live alongside:
[`tests/prompt-args.test.ts`](tests/prompt-args.test.ts),
[`tests/attachments.test.ts`](tests/attachments.test.ts),
[`tests/detect-usage-limit.test.ts`](tests/detect-usage-limit.test.ts), etc.
[`tests/detect-usage-limit.test.ts`](tests/detect-usage-limit.test.ts),
[`tests/structured-output.test.ts`](tests/structured-output.test.ts), etc.

## Capability flags that matter

Expand All @@ -62,6 +64,7 @@ See [`AgentHarness`](src/types.ts) for full semantics. Quick decision table:
| `name` / `displayName` / `defaultPath` | Always. `defaultPath` is the binary looked up on `PATH` (override-able via resolver env vars). |
| [`supportedModes`](src/modes.ts) | List only plan/readonly modes the CLI can **natively enforce** via flags. If you cannot enforce them, leave empty (or omit) — requests fail closed via `assertModeSupported` inside `buildArgs`. Never fake a mode by ignoring it. |
| `supportsMaxTurns` | Set `true` only if the CLI accepts a turn-limit flag *and* emits a recognizable diagnostic on exhaustion. Callers skip transcript scanning when this is false/unset, so tool output cannot be mistaken for a turn-limit error. |
| [`supportsStructuredOutput`](src/structured-output.ts) | Set `true` only if the CLI has a documented JSON output mode (verify upstream docs — don't assume). Supporting harnesses emit the flag in `buildArgs` when `options.structuredOutput` is set; runners fail closed with `UnsupportedStructuredOutputError` when it is false/unset. See the per-CLI flag table in [Structured (JSON) output](#structured-json-output). |
| `constrainedModeAllowsExternalTools` | Set `true` only if your constrained mode still permits network + MCP tools. No built-in harness sets it today (Codex's read-only sandbox disables network; Claude's plan mode denies non-annotated MCP tools, which aborts headless runs). Callers whose agents need web/MCP access skip constrained modes unless this is true. |
| `promptFlag` | Set when the prompt must arrive as a flag value (`kimi --prompt "..."`). Omit for positional prompts (`codex exec "..."`). Prefer argv over stdin — see below. |
| `imageInput` / `buildImageArgs` | `"path"` (default): images go into the prompt as markdown paths only. `"native"`: also emit CLI flags via `buildImageArgs(paths)` after the prompt (Codex `-i`). Runners call [`preparePromptWithAttachments`](src/attachments.ts); paths should also appear in `attachmentPaths`. |
Expand All @@ -71,6 +74,55 @@ first; use [`effectiveSkipPermissions`](src/modes.ts) instead of reading
`options.skipPermissions` directly — constrained modes always suppress YOLO /
bypass flags. When `mode` is plan/readonly, never emit write-capable flags.

## Structured (JSON) output

[`src/structured-output.ts`](src/structured-output.ts) lets callers request
machine-readable output per run: pass `structuredOutput: true` in
[`AgentRunOptions`](src/types.ts). This is **opt-in** — the plain-text default
keeps transcript scanning (usage-limit, max-turns, incomplete-implementation,
open-question detectors) working exactly as before.

- **Capability.** A harness sets `supportsStructuredOutput = true` only when
its CLI documents a JSON output mode; supporting harnesses emit the flag in
`buildArgs` when the option is requested. Runners call
`assertStructuredOutputSupported` before spawning, so requesting JSON from
an unsupported harness throws [`UnsupportedStructuredOutputError`](src/structured-output.ts)
instead of silently returning styled text (same fail-closed contract as
`UnsupportedAgentModeError`).
- **Result.** `AgentRunResult.structured` (`StructuredOutputResult`) carries
the parsed payload: `{ ok: true, value }` where `value` is the single JSON
document (Claude/Cursor/Grok/agy envelopes, Qwen message arrays) or an
array of NDJSON event objects (Codex/Opencode/Kilo/Cline/Kimi/Pi streams).
On failure it is `{ ok: false, error }` — empty output, no JSON found, or a
malformed/truncated payload. Fail closed: partial NDJSON is **not**
returned as a complete result; `stdout`/`stderr` always remain for
diagnostics. The field is absent when the option is unset, so existing
callers are unaffected.
- **Per-CLI flags wired into `buildArgs`** (verified against upstream docs):

| Harness | Flag | Shape |
| --- | --- | --- |
| claude-code | `--output-format json` | single result envelope |
| cline | `--json` | NDJSON messages |
| codex | `--json` | JSONL events |
| cursor | `--output-format json` | single result object |
| opencode | `--format json` | JSONL events |
| grok | `--output-format json` | single result object |
| goose | `--output-format json` | single result object |
| antigravity (agy, v1.1.8+) | `--output-format json` | single JSON envelope |
| qwen | `--output-format json` | JSON array of messages |
| kilo-code | `--format json` | JSONL events |
| kimi | `--output-format stream-json` | JSONL messages |
| pi | `--mode json` (pairs with `-p`) | JSONL events |
| deepseek (reasonix) | `--output-format json` | single result object |

- **Detectors & streaming.** Usage-limit and max-turns detectors keep
scanning the raw streams (JSON event lines can still match
provider-diagnostic patterns, e.g. `{"error":{...}}`). `onStdout` /
`onStderr` callbacks and Bun/Node runner behavior are identical in both
modes; parsing happens once after exit. Callers needing live deltas should
consume the streaming callbacks as today.

## Prompt & input conventions

- **Arg, not stdin.** The prompt goes on the command line via
Expand Down
17 changes: 14 additions & 3 deletions packages/agent-harness/src/harnesses/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ export class AntigravityHarness implements AgentHarness {
/** Executable on PATH after install (`curl -fsSL https://antigravity.google/cli/install.sh | bash`). */
readonly defaultPath = "agy";
readonly promptFlag = "-p";
/**
* `--output-format json` (print mode, v1.1.8+) emits one JSON envelope with
* status, response text, and usage metadata.
*/
readonly supportsStructuredOutput = true;

/**
* Build `agy` CLI flags for headless (`-p`) execution.
*
* @param options - Supports `skipPermissions` (`--dangerously-skip-permissions`)
* and `model` (`--model`, added upstream in v1.0.5; takes a model slug as
* listed by `agy models`). Max-turns has no `--max-turns` equivalent.
* @param options - Supports `skipPermissions`
* (`--dangerously-skip-permissions`), `model` (`--model`, added upstream
* in v1.0.5; takes a model slug as listed by `agy models`),
* `structuredOutput`, and `workingDir`. Max-turns has no `--max-turns`
* equivalent.
* @returns Args excluding the prompt (runner supplies `-p` via {@link promptFlag}).
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -57,6 +64,10 @@ export class AntigravityHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

// Antigravity does not currently support --max-turns.
// If it adds support in the future, uncomment:
// if (options.maxTurns !== undefined) {
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ export class ClaudeCodeHarness implements AgentHarness {
readonly promptFlag = "-p";
readonly supportedModes = ["plan", "readonly"] as const;
readonly supportsMaxTurns = true;
/** Print mode accepts `--output-format json` (single JSON envelope, result in `result`). */
readonly supportsStructuredOutput = true;

/**
* Build `claude` CLI flags for non-interactive (`-p`) execution.
*
* @param options - Supports `mode`, `skipPermissions`, `model`, and `maxTurns`.
* @param options - Supports `mode`, `skipPermissions`, `model`, `maxTurns`,
* and `structuredOutput`.
* @returns Args excluding the prompt (runner supplies `-p` via {@link promptFlag}).
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -63,6 +66,10 @@ export class ClaudeCodeHarness implements AgentHarness {
args.push("--max-turns", String(options.maxTurns));
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

return args;
}
}
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ export class ClineHarness implements AgentHarness {
readonly defaultPath = "cline";
/** No native plan/read-only enforcement documented for headless `cline task`. */
readonly supportedModes = [] as const;
/** `--json` emits newline-delimited JSON message objects instead of styled text. */
readonly supportsStructuredOutput = true;

/**
* Build `cline task` subcommand flags for non-interactive execution.
*
* @param options - Supports `skipPermissions` (`--yolo`) and `model`.
* @param options - Supports `skipPermissions` (`--yolo`), `model`, and
* `structuredOutput` (`--json`).
* @returns Args starting with `task`; prompt is appended as a positional argument.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -37,6 +40,10 @@ export class ClineHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--json");
}

// Cline does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export class CodexHarness implements AgentHarness {
readonly supportedModes = ["plan", "readonly"] as const;
/** Codex `exec` accepts images via `-i` after the prompt. */
readonly imageInput = "native" as const;
/** `--json` prints stdout as newline-delimited JSON events (thread/turn/item). */
readonly supportsStructuredOutput = true;

/**
* Build trailing `-i` flags for native image attachment.
Expand All @@ -115,7 +117,8 @@ export class CodexHarness implements AgentHarness {
/**
* Build `codex exec` flags for non-interactive execution.
*
* @param options - Supports `mode`, `skipPermissions` (sandbox + approval), and `model`.
* @param options - Supports `mode`, `skipPermissions` (sandbox + approval),
* `model`, and `structuredOutput` (`--json`).
* @returns Args starting with `exec`; prompt is appended as a positional argument.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand Down Expand Up @@ -143,6 +146,10 @@ export class CodexHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--json");
}

// Codex does not currently support --max-turns in exec mode.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-harness/src/harnesses/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ export class CursorHarness implements AgentHarness {
// unambiguous name — other CLIs (e.g. Grok) also install as `agent`.
readonly defaultPath = "cursor-agent";
readonly supportedModes = ["plan", "readonly"] as const;
/** `--output-format json` emits one result object on completion (requires `-p`). */
readonly supportsStructuredOutput = true;

/**
* Build Cursor `agent` CLI flags for non-interactive (`-p`) execution.
*
* @param options - Supports `mode`, `skipPermissions` (`--force`, `--trust`,
* `--approve-mcps`), and `model`.
* `--approve-mcps`), `model`, and `structuredOutput`.
* @returns Args excluding the prompt (runner supplies it positionally).
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -58,6 +60,10 @@ export class CursorHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

// Cursor does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-harness/src/harnesses/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ export class DeepSeekHarness implements AgentHarness {
readonly defaultPath = "reasonix";
/** No native plan/read-only enforcement documented for headless `reasonix run`. */
readonly supportedModes = [] as const;
/** `--output-format json` emits one final result object (verified in docs/CLI.md). */
readonly supportsStructuredOutput = true;

/**
* Build `reasonix run` flags for non-interactive execution.
*
* @param options - Supports `model`. `skipPermissions` and `maxTurns` are not
* exposed as CLI flags for `reasonix run`.
* @param options - Supports `model` and `structuredOutput`. `skipPermissions`
* and `maxTurns` are not exposed as CLI flags for `reasonix run`.
* @returns Args starting with `run`; prompt is appended as a positional argument.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -44,6 +46,10 @@ export class DeepSeekHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

// Reasonix turn limits are configured via max_steps in reasonix.toml /
// ~/.reasonix/config.toml, not a CLI --max-turns flag.
// If it adds support in the future, uncomment the following:
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/goose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ export class GooseHarness implements AgentHarness {
readonly promptFlag = "-t";
/** No native plan/read-only enforcement documented for headless `goose run`. */
readonly supportedModes = [] as const;
/** `--output-format json` emits structured session/results output after completion. */
readonly supportsStructuredOutput = true;

/**
* Build `goose run` flags for non-interactive (`-t`) execution.
*
* @param options - Supports `skipPermissions` (`--no-session`) and `model`.
* @param options - Supports `skipPermissions` (`--no-session`), `model`, and
* `structuredOutput`.
* @returns Args starting with `run`; prompt is supplied via {@link promptFlag}.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -44,6 +47,10 @@ export class GooseHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

// Goose does not currently support --max-turns via CLI flag.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-harness/src/harnesses/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ export class GrokHarness implements AgentHarness {
readonly defaultPath = "grok";
readonly promptFlag = "-p";
readonly supportedModes = ["plan", "readonly"] as const;
/** `--output-format json` emits one JSON object (text, stopReason, session, usage). */
readonly supportsStructuredOutput = true;

/**
* Build `grok` CLI flags for headless (`-p`) execution.
*
* @param options - Supports `mode`, `skipPermissions` (`--always-approve`), `model` (`-m`),
* and `workingDir` (`--cwd`).
* @param options - Supports `mode`, `skipPermissions` (`--always-approve`),
* `model` (`-m`), `structuredOutput`, and `workingDir` (`--cwd`).
* @returns Args excluding the prompt (runner supplies `-p` via {@link promptFlag}).
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -62,6 +64,10 @@ export class GrokHarness implements AgentHarness {
args.push("--cwd", options.workingDir);
}

if (options.structuredOutput) {
args.push("--output-format", "json");
}

// Grok Build does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/kilo-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ export class KiloCodeHarness implements AgentHarness {
readonly defaultPath = "kilo";
/** No native plan/read-only enforcement documented for headless `kilo run`. */
readonly supportedModes = [] as const;
/** `--format json` streams raw JSON events (one object per line) to stdout. */
readonly supportsStructuredOutput = true;

/**
* Build `kilo run` subcommand flags for non-interactive execution.
*
* @param options - Supports `skipPermissions` (`--auto`) and `model`.
* @param options - Supports `skipPermissions` (`--auto`), `model`, and
* `structuredOutput`.
* @returns Args starting with `run`; prompt is appended as a positional argument.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -37,6 +40,10 @@ export class KiloCodeHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--format", "json");
}

// Kilo Code does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-harness/src/harnesses/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@ export class KimiHarness implements AgentHarness {
readonly promptFlag = "--prompt";
/** No native plan/read-only enforcement documented for headless `kimi --print`. */
readonly supportedModes = [] as const;
/**
* Print mode accepts `--output-format stream-json` (JSONL messages); its
* only structured format.
*/
readonly supportsStructuredOutput = true;

/**
* Build `kimi --print` flags for non-interactive execution.
*
* @param options - Supports `skipPermissions` (`--yolo`) and `model`.
* @param options - Supports `skipPermissions` (`--yolo`), `model`, and
* `structuredOutput`.
* @returns Args including `--print`; prompt is supplied via {@link promptFlag}.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -38,6 +44,10 @@ export class KimiHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--output-format", "stream-json");
}

// Kimi CLI does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-harness/src/harnesses/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ export class OpencodeHarness implements AgentHarness {
readonly displayName = "Opencode";
readonly defaultPath = "opencode";
readonly supportedModes = ["plan", "readonly"] as const;
/** `--format json` streams raw JSON events (one object per line) to stdout. */
readonly supportsStructuredOutput = true;

/**
* Build `opencode run` flags for non-interactive execution.
*
* @param options - Supports `mode`, `skipPermissions`, `model`, and `workingDir`.
* @param options - Supports `mode`, `skipPermissions`, `model`,
* `structuredOutput`, and `workingDir`.
* @returns Args starting with `run`; prompt is appended as a positional argument.
*/
buildArgs(options: AgentRunOptions): string[] {
Expand All @@ -64,6 +67,10 @@ export class OpencodeHarness implements AgentHarness {
args.push("--model", options.model);
}

if (options.structuredOutput) {
args.push("--format", "json");
}

// Opencode does not currently support --max-turns.
// If it adds support in the future, uncomment the following:
// if (options.maxTurns !== undefined) {
Expand Down
Loading
Loading