- Unify ToolResultEnvelope (output vs content) for built-in tools - Fix shell.run AsyncGenerator consumption in ToolRegistry.call/streaming - Scheduler: consume WorkerResult.status instead of marking all running tasks completed - WorkerProcess/WorkerManager: surface exit events and generate failed/cancelled result - MainAgent: integrate ContextAssembler, Chinese destructive regex, ArchitectureDesigner impact gate - run.ts: pendingConfirmation flow, dispatch extracted, .air files filtered from /results - CapabilityRegistry wired into RuntimeApp and ServiceRegistry; DoctorService uses it - release.ts: findRepoRoot/findBun, run air e2e + depcruise + runtime regression - New gates: release-critical-gates, CLI run command regression - 14/14 e2e gates pass; 3/3 release dry-run pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
4.6 KiB
TypeScript
Executable File
146 lines
4.6 KiB
TypeScript
Executable File
/**
|
|
* Shell Tool - Command execution
|
|
*
|
|
* Implements T-207: shell.run
|
|
* Emits command.started/completed events; streaming stdout/stderr.
|
|
*
|
|
* @module packages/runtime/src/tools/shell
|
|
*/
|
|
|
|
import { spawn } from 'child_process'
|
|
import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts'
|
|
|
|
export const shell_run: ToolDefinition = {
|
|
name: 'shell.run',
|
|
category: 'shell',
|
|
description: 'Run a shell command',
|
|
version: 1,
|
|
output_schema: { type: 'object', properties: {}, required: [] },
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
command: { type: 'string', description: 'Command to execute' },
|
|
workdir: { type: 'string', description: 'Working directory' },
|
|
timeout: { type: 'number', default: 300000, description: 'Timeout in milliseconds' },
|
|
env: { type: 'object', description: 'Environment variables to add' }
|
|
},
|
|
required: ['command']
|
|
},
|
|
permissions: { network: true },
|
|
streaming: true
|
|
}
|
|
|
|
export function createShellExecutor(project_root: string) {
|
|
return {
|
|
'shell.run': async function* (call: ToolCall, context: ToolExecutionContext): AsyncGenerator<ToolResultEnvelope> {
|
|
const { command, workdir, timeout = 300000, env = {} } = call.arguments as {
|
|
command: string
|
|
workdir?: string
|
|
timeout?: number
|
|
env?: Record<string, string>
|
|
}
|
|
|
|
const cwd = workdir || project_root
|
|
const timestamp = new Date().toISOString() as ISOTimeString
|
|
|
|
yield {
|
|
status: 'ok',
|
|
output: { event: 'command.started', command, cwd },
|
|
metadata: { timestamp, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
}
|
|
|
|
const proc = spawn(command, [], {
|
|
cwd,
|
|
shell: true,
|
|
env: { ...process.env, ...env }
|
|
})
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let timed_out = false
|
|
const chunks: ToolResultEnvelope[] = []
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
const text = data.toString()
|
|
stdout += text
|
|
chunks.push({
|
|
status: 'ok',
|
|
output: { event: 'command.stdout', text },
|
|
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
})
|
|
})
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
const text = data.toString()
|
|
stderr += text
|
|
chunks.push({
|
|
status: 'ok',
|
|
output: { event: 'command.stderr', text },
|
|
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
})
|
|
})
|
|
|
|
const timeout_id = setTimeout(() => {
|
|
timed_out = true
|
|
proc.kill('SIGKILL')
|
|
}, timeout)
|
|
|
|
const exit_code = await new Promise<number>((resolve) => {
|
|
proc.on('exit', (code) => resolve(code ?? 0))
|
|
proc.on('error', () => resolve(1))
|
|
})
|
|
clearTimeout(timeout_id)
|
|
|
|
if (stdout) {
|
|
yield {
|
|
status: 'ok',
|
|
output: { event: 'command.stdout', text: stdout.slice(-50000) },
|
|
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
}
|
|
}
|
|
if (stderr) {
|
|
yield {
|
|
status: 'ok',
|
|
output: { event: 'command.stderr', text: stderr.slice(-10000) },
|
|
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
}
|
|
}
|
|
|
|
while (chunks.length > 0) {
|
|
yield chunks.shift()!
|
|
}
|
|
|
|
if (timed_out) {
|
|
stderr += `\n[Command timed out after ${timeout}ms]`
|
|
}
|
|
|
|
yield {
|
|
status: exit_code === 0 ? 'ok' : 'error',
|
|
output: {
|
|
event: 'command.completed',
|
|
exit_code,
|
|
stdout: stdout.slice(-50000),
|
|
stderr: stderr.slice(-10000),
|
|
timed_out
|
|
},
|
|
error: exit_code === 0 ? undefined : {
|
|
error_id: call.call_id,
|
|
kind: 'tool_error',
|
|
severity: 'error',
|
|
message: timed_out ? `Command timed out after ${timeout}ms` : `Command exited with code ${exit_code}`,
|
|
retryability: timed_out ? 'retryable' : 'not_retryable',
|
|
semantic_signature: 'shell.run'
|
|
},
|
|
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: false, is_final: true, call_id: call.call_id, tool_name: 'shell.run' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
interface ToolExecutionContext {
|
|
session_id: string
|
|
project_id: string
|
|
project_root: string
|
|
agent_id: string
|
|
agent_type: string
|
|
} |