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')