feat(cli): add --output-schema flag for structured agent output

This commit is contained in:
Mohamed Boudra
2026-02-12 18:04:39 +07:00
parent 018edef3bb
commit cb2e4aacfe
10 changed files with 344 additions and 21 deletions
+1
View File
@@ -67,6 +67,7 @@ export function createCli(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
+1
View File
@@ -43,6 +43,7 @@ export function createAgentCommand(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
+235 -3
View File
@@ -9,7 +9,7 @@ import { lookup } from 'mime-types'
/** Result type for agent run command */
export interface AgentRunResult {
agentId: string
status: 'created' | 'running'
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
provider: string
cwd: string
title: string | null
@@ -39,24 +39,178 @@ export interface AgentRunOptions extends CommandOptions {
cwd?: string
label?: string[]
ui?: boolean
outputSchema?: string
}
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult['status']
): AgentRunResult {
return {
agentId: agent.id,
status: agent.status === 'running' ? 'running' : 'created',
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
provider: agent.provider,
cwd: agent.cwd,
title: agent.title,
}
}
function loadOutputSchema(value: string): Record<string, unknown> {
const trimmed = value.trim()
if (!trimmed) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: '--output-schema cannot be empty',
details: 'Provide a JSON schema file path or inline JSON object',
}
throw error
}
let source = trimmed
if (!trimmed.startsWith('{')) {
try {
source = readFileSync(resolve(trimmed), 'utf8')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: `Failed to read output schema file: ${trimmed}`,
details: message,
}
throw error
}
}
let parsed: unknown
try {
parsed = JSON.parse(source)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Failed to parse output schema JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Output schema must be a JSON object',
}
throw error
}
return parsed as Record<string, unknown>
}
function extractFirstJsonObject(text: string): string | null {
const source = text.trim()
if (!source) {
return null
}
const startIndexes: number[] = []
for (let i = 0; i < source.length; i += 1) {
if (source[i] === '{') {
startIndexes.push(i)
}
}
for (const start of startIndexes) {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < source.length; i += 1) {
const ch = source[i]!
if (inString) {
if (escaped) {
escaped = false
continue
}
if (ch === '\\') {
escaped = true
continue
}
if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '{') {
depth += 1
continue
}
if (ch === '}') {
depth -= 1
if (depth === 0) {
const candidate = source.slice(start, i + 1).trim()
try {
JSON.parse(candidate)
return candidate
} catch {
// Keep scanning.
}
}
}
}
}
return null
}
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
const trimmed = lastMessage.trim()
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response is not valid JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response JSON must be an object',
}
throw error
}
return parsed as Record<string, unknown>
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
serialize: () => output,
}
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
// Validate prompt is provided
if (!prompt || prompt.trim().length === 0) {
@@ -78,6 +232,16 @@ export async function runRunCommand(
throw error
}
// --output-schema always runs in attached/wait mode
if (outputSchema && options.detach) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--output-schema cannot be used with --detach',
details: 'Structured output requires waiting for the agent to finish',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
@@ -162,12 +326,75 @@ export async function runRunCommand(
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
outputSchema,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
if (outputSchema) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
if (state.status === 'timeout') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Timed out waiting for structured output',
}
throw error
}
if (state.status === 'permission') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent is waiting for permission before producing structured output',
}
throw error
}
if (state.status === 'error') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: state.error ?? 'Agent failed before producing structured output',
}
throw error
}
const lastMessage = state.lastMessage?.trim()
if (!lastMessage) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
}
const output = parseStructuredOutput(lastMessage)
await client.close()
return {
type: 'single',
data: toRunResult(agent, 'completed'),
schema: structuredRunSchema(output),
}
}
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
await client.close()
const finalAgent = state.final ?? agent
const status: AgentRunResult['status'] =
state.status === 'idle' ? 'completed' : state.status
return {
type: 'single',
data: toRunResult(finalAgent, status),
schema: agentRunSchema,
}
}
await client.close()
return {
@@ -177,6 +404,11 @@ export async function runRunCommand(
}
} catch (err) {
await client.close().catch(() => {})
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',
+1
View File
@@ -483,6 +483,7 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
'2. Web app: https://app.paseo.sh',
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
'4. Docs: https://paseo.sh/docs',
'5. Example: paseo run --output-schema schema.json "extract fields"',
]
const quickReferenceLines = [
'1. paseo --help',
+12
View File
@@ -31,6 +31,18 @@ function normalizeFormat(raw: unknown): OutputOptions['format'] {
/** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions {
const hasStructuredOutputSchema =
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
if (hasStructuredOutputSchema) {
return {
format: 'json',
quiet: false,
noHeaders: options.headers === false,
noColor: options.color === false,
}
}
return {
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
quiet: options.quiet ?? defaultOutputOptions.quiet,
+45 -7
View File
@@ -22,7 +22,7 @@
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
@@ -33,6 +33,18 @@ console.log('=== Run Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const schemaPath = join(paseoHome, 'run-output-schema.json')
await writeFile(
schemaPath,
JSON.stringify({
type: 'object',
properties: {
status: { type: 'string' },
},
required: ['status'],
additionalProperties: false,
})
)
try {
// Test 1: run --help shows options
@@ -46,6 +58,7 @@ try {
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
console.log('✓ run --help shows options\n')
@@ -138,9 +151,34 @@ try {
console.log('✓ run --cwd flag is accepted\n')
}
// Test 9: -q (quiet) flag is accepted with run
// Test 9: run --output-schema flag is accepted
{
console.log('Test 9: -q (quiet) flag is accepted with run')
console.log('Test 9: run --output-schema flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --output-schema flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --output-schema flag is accepted\n')
}
// Test 10: run --output-schema cannot be used with --detach
{
console.log('Test 10: run --output-schema cannot be used with --detach')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail with --detach and --output-schema')
const output = result.stdout + result.stderr
assert(
output.includes('--output-schema cannot be used with --detach'),
'error should explain detach incompatibility'
)
console.log('✓ run --output-schema cannot be used with --detach\n')
}
// Test 11: -q (quiet) flag is accepted with run
{
console.log('Test 11: -q (quiet) flag is accepted with run')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
@@ -149,9 +187,9 @@ try {
console.log('✓ -q (quiet) flag is accepted with run\n')
}
// Test 10: Combined flags work together
// Test 12: Combined flags work together
{
console.log('Test 10: Combined flags work together')
console.log('Test 12: Combined flags work together')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
const output = result.stdout + result.stderr
@@ -160,9 +198,9 @@ try {
console.log('✓ Combined flags work together\n')
}
// Test 11: paseo --help shows run command
// Test 13: paseo --help shows run command
{
console.log('Test 11: paseo --help shows run command')
console.log('Test 13: paseo --help shows run command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('run'), 'help should mention run command')
@@ -169,6 +169,7 @@ export type CreateAgentRequestOptions = {
provider?: AgentProvider;
cwd?: string;
initialPrompt?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
git?: GitSetupOptions;
worktreeName?: string;
@@ -224,6 +225,7 @@ export type WaitForFinishResult = {
status: "idle" | "error" | "permission" | "timeout";
final: AgentSnapshotPayload | null;
error: string | null;
lastMessage: string | null;
};
type Waiter<T> = {
@@ -984,6 +986,7 @@ export class DaemonClient {
requestId,
config,
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
...(options.images && options.images.length > 0
? { images: options.images }
: {}),
@@ -2260,6 +2263,7 @@ export class DaemonClient {
status: payload.status,
final: payload.final,
error: payload.error,
lastMessage: payload.lastMessage,
};
}
+16 -10
View File
@@ -63,6 +63,7 @@ import type {
AgentPermissionResponse,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
McpServerConfig,
AgentSessionConfig,
AgentStreamEvent,
@@ -683,7 +684,8 @@ export class Session {
*/
private startAgentStream(
agentId: string,
prompt: AgentPromptInput
prompt: AgentPromptInput,
runOptions?: AgentRunOptions
): { ok: true } | { ok: false; error: string } {
this.sessionLogger.info(
{ agentId },
@@ -692,7 +694,7 @@ export class Session {
let iterator: AsyncGenerator<AgentStreamEvent>;
try {
iterator = this.agentManager.streamAgent(agentId, prompt);
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to start agent run");
const message =
@@ -2036,7 +2038,8 @@ export class Session {
agentId: string,
text: string,
messageId?: string,
images?: Array<{ data: string; mimeType: string }>
images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions
): Promise<void> {
this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
@@ -2076,7 +2079,7 @@ export class Session {
);
}
this.startAgentStream(agentId, prompt);
this.startAgentStream(agentId, prompt, runOptions);
}
/**
@@ -2124,7 +2127,7 @@ export class Session {
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>
): Promise<void> {
const { config, worktreeName, requestId, initialPrompt, git, images, labels } = msg;
const { config, worktreeName, requestId, initialPrompt, outputSchema, git, images, labels } = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
`Creating agent in ${config.cwd} (${config.provider})${
@@ -2163,7 +2166,8 @@ export class Session {
snapshot.id,
trimmedPrompt,
uuidv4(),
images
images,
outputSchema ? { outputSchema } : undefined
);
} catch (promptError) {
this.sessionLogger.error(
@@ -4832,7 +4836,7 @@ export class Session {
if (!resolved.ok) {
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status: "error", final: null, error: resolved.error },
payload: { requestId, status: "error", final: null, error: resolved.error, lastMessage: null },
});
return;
}
@@ -4849,6 +4853,7 @@ export class Session {
status: "error",
final: null,
error: `Agent not found: ${agentId}`,
lastMessage: null,
},
});
return;
@@ -4862,7 +4867,7 @@ export class Session {
: "idle";
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null },
payload: { requestId, status, final, error: null, lastMessage: null },
});
return;
}
@@ -4892,7 +4897,7 @@ export class Session {
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null },
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
});
} catch (error) {
const isAbort =
@@ -4910,6 +4915,7 @@ export class Session {
status: "error",
final,
error: message,
lastMessage: null,
},
});
return;
@@ -4921,7 +4927,7 @@ export class Session {
}
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status: "timeout", final, error: null },
payload: { requestId, status: "timeout", final, error: null, lastMessage: null },
});
} finally {
clearTimeout(timeoutHandle);
+2
View File
@@ -548,6 +548,7 @@ export const CreateAgentRequestMessageSchema = z.object({
config: AgentSessionConfigSchema,
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
outputSchema: z.record(z.unknown()).optional(),
images: z.array(z.object({
data: z.string(), // base64 encoded image
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
@@ -1360,6 +1361,7 @@ export const WaitForFinishResponseMessageSchema = z.object({
status: z.enum(["idle", "error", "permission", "timeout"]),
final: AgentSnapshotPayloadSchema.nullable(),
error: z.string().nullable(),
lastMessage: z.string().nullable(),
}),
});
+27 -1
View File
@@ -64,12 +64,23 @@ paseo stop <id> # Stop an agent`}</pre>
<pre className="text-white/80">{`paseo run "implement user authentication"
paseo run --provider codex "refactor the API layer"
paseo run --detach "run the full test suite" # background
paseo run --worktree feature-x "implement feature X"`}</pre>
paseo run --worktree feature-x "implement feature X"
paseo run --output-schema schema.json "extract release notes"
paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes"`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
The <code className="font-mono">--worktree</code> flag creates the agent in an isolated git
worktree, useful for parallel feature development.
</p>
<p className="text-white/60 leading-relaxed">
Use <code className="font-mono">--output-schema</code> to return only matching JSON output.
You can pass a schema file path or an inline JSON schema object.
This mode cannot be used with <code className="font-mono">--detach</code>.
</p>
<p className="text-white/60 leading-relaxed">
By default, <code className="font-mono">paseo run</code> waits for completion. Use{' '}
<code className="font-mono">--detach</code> to run in the background.
</p>
</section>
{/* Listing agents */}
@@ -187,6 +198,21 @@ paseo daemon stop # Stop the daemon`}</pre>
paseo run --detach "implement the API" --name api-agent
paseo wait api-agent
paseo logs api-agent --tail 5`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
Simple implement + verify loop:
</p>
<Code>
<pre className="text-white/80">{`# Requires jq
while true; do
paseo run --provider codex "make the tests pass" >/dev/null
verdict=$(paseo run --provider claude --output-schema '{"type":"object","properties":{"criteria_met":{"type":"boolean"}},"required":["criteria_met"],"additionalProperties":false}' "ensure tests all pass")
if echo "$verdict" | jq -e '.criteria_met == true' >/dev/null; then
echo "criteria met"
break
fi
done`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
This pattern enables hierarchical task decomposition — a lead agent can break down work,