Files
AirCoding/packages/workers/src/WorkerRuntime.ts
AirCoding 8fd680cf84 feat(llm): add LLM call support to worker IPC chain
- ProviderManager: align API with contracts ProviderAdapter
- WorkerProtocol: add llm.request/llm.response message types
- WorkerRuntime: add call_llm() for worker→parent→LLM flow
- WorkerManager: support tool_registry and provider_manager injection

P1-1 complete, P1-2 protocol layer complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 18:42:06 +08:00

222 lines
5.9 KiB
TypeScript
Executable File

/**
* WorkerRuntime - In-worker side-effect surface
*
* Implements contracts §10; DD §8.3.
* INV-3: workers reach fs/shell/network/SQLite ONLY through parent-mediated tool IPC.
*
* @module packages/workers/src/WorkerRuntime
*/
export interface WorkerRuntimeConfig {
agent_id: string
session_id: string
}
export interface ToolCallRequest {
call_id: string
name: string
arguments: Record<string, unknown>
}
export interface ToolCallResult {
call_id: string
type: 'text' | 'error' | 'artifact'
content: Record<string, unknown>
}
export interface LLMRequest {
messages: Array<{ role: string; content: unknown }>
model?: string
max_tokens?: number
temperature?: number
tools?: unknown[]
}
export interface LLMResponse {
content: string
usage?: { input_tokens: number; output_tokens: number }
tool_calls?: Array<{ name: string; arguments: Record<string, unknown> }>
}
export class WorkerRuntime {
private agent_id: string
private session_id: string
private pending_calls: Map<string, { resolve: (r: ToolCallResult) => void; reject: (e: Error) => void }> = new Map()
private output: (line: string) => void
constructor(config: WorkerRuntimeConfig, output: (line: string) => void) {
this.agent_id = config.agent_id
this.session_id = config.session_id
this.output = output
}
/**
* Call a tool through the parent process via IPC.
* INV-3: This is the ONLY way workers interact with the outside world.
*/
async call_tool(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
const call_id = crypto.randomUUID()
const promise = new Promise<ToolCallResult>((resolve, reject) => {
this.pending_calls.set(call_id, { resolve, reject })
// Set timeout
setTimeout(() => {
this.pending_calls.delete(call_id)
reject(new Error(`Tool call timeout: ${name}`))
}, 300000) // 5 minutes
})
// Send tool.call via IPC
this.send_message('tool.call', {
call_id,
name,
arguments: args
})
return promise
}
/**
* Call LLM through parent process via IPC.
* INV-3: This is the ONLY way workers access LLM.
*/
async call_llm(request: LLMRequest): Promise<LLMResponse> {
const call_id = crypto.randomUUID()
const promise = new Promise<LLMResponse>((resolve, reject) => {
this.pending_calls.set(call_id, { resolve: resolve as any, reject })
// Set timeout (LLM calls can be long)
setTimeout(() => {
this.pending_calls.delete(call_id)
reject(new Error(`LLM call timeout: ${request.model || 'default'}`))
}, 300000) // 5 minutes
})
// Send llm.request via IPC
this.send_message('llm.request', {
call_id,
messages: request.messages,
model: request.model || 'claude-haiku-4-5-20251001',
max_tokens: request.max_tokens || 4096,
temperature: request.temperature,
tools: request.tools
})
return promise
}
/**
* Emit an event to the parent.
*/
emit(type: string, payload: Record<string, unknown>): void {
this.send_message('event', { type, ...payload })
}
/**
* Create a checkpoint.
*/
checkpoint(name: string, data?: Record<string, unknown>): void {
this.send_message('worker.checkpoint', { name, data })
}
/**
* Report worker result to parent.
*/
async report_result(result: Record<string, unknown>): Promise<void> {
this.send_message('worker.result', result)
}
/**
* Send heartbeat.
*/
heartbeat(): void {
this.send_message('worker.heartbeat', { timestamp: new Date().toISOString() })
}
/**
* Handle incoming message from parent (tool.result, agent.cancel, agent.ping).
*/
handle_message(type: string, payload: Record<string, unknown>): void {
switch (type) {
case 'tool.result': {
const call_id = payload.call_id as string
const pending = this.pending_calls.get(call_id)
if (pending) {
this.pending_calls.delete(call_id)
pending.resolve(payload as unknown as ToolCallResult)
}
break
}
case 'agent.cancel':
// Cancel all pending calls
for (const [id, pending] of this.pending_calls) {
pending.reject(new Error('Agent cancelled'))
this.pending_calls.delete(id)
}
break
case 'agent.ping':
// Respond to ping
this.send_message('worker.heartbeat', { timestamp: new Date().toISOString() })
break
case 'llm.response': {
const call_id = payload.call_id as string
const pending = this.pending_calls.get(call_id)
if (pending) {
this.pending_calls.delete(call_id)
// Resolve with LLM response
const response: LLMResponse = {
content: (payload.content as string) || '',
usage: payload.usage as LLMResponse['usage'],
tool_calls: payload.tool_calls as LLMResponse['tool_calls']
}
pending.resolve(response as any)
}
break
}
}
}
/**
* Send ready handshake.
*/
send_ready(protocol_version: number, worker_version: string): void {
this.send_message('worker.ready', {
protocol_version,
worker_version,
agent_id: this.agent_id,
session_id: this.session_id
})
}
/**
* Send error.
*/
send_error(message: string): void {
this.send_message('worker.error', { message })
}
// ============================================================================
// Private
// ============================================================================
private send_message(type: string, payload: Record<string, unknown>): void {
const msg = {
id: crypto.randomUUID(),
kind: type, // IpcKind per contracts §10
type,
direction: 'worker_to_parent',
session_id: this.session_id,
agent_id: this.agent_id,
protocol_version: 1,
timestamp: new Date().toISOString(),
payload
}
this.output(JSON.stringify(msg))
}
}