|
import { EventBus } from './event-bus'; |
|
import { ProcessRuntime } from './process-runtime'; |
|
import { ToolRegistry } from './tool-registry'; |
|
import { SshCommandTool } from './ssh-command-tool'; |
|
import { LocalProcessTool } from './local-process-tool'; |
|
import { summarizeRecentEvents } from './summarizer'; |
|
import { FakeController } from './fake-controller'; |
|
import { QwenController } from './qwen-controller'; |
|
import { defaultConfig, type RuntimeConfig } from './runtime-config'; |
|
import type { Event, RunHandle } from './types'; |
|
import type { ControllerDecision } from './controller-types'; |
|
|
|
const now = () => Date.now(); |
|
|
|
type ControllerInput = { |
|
goal: string; |
|
availableTools: string[]; |
|
recentEvents: Array<Record<string, unknown>>; |
|
activeRunId?: string | null; |
|
hasStarted?: boolean; |
|
}; |
|
|
|
type ControllerLike = { |
|
decide(input: ControllerInput): Promise<ControllerDecision>; |
|
fastDecide?: (input: ControllerInput) => Promise<ControllerDecision>; |
|
}; |
|
|
|
function isUrgentSignal(event: Event) { |
|
if (event.type !== 'tool.stdout' && event.type !== 'tool.stderr') return false; |
|
return /Traceback:|ImportError:|ModuleNotFoundError|HTTP 429|still retrying/i.test(event.chunk); |
|
} |
|
|
|
export class MiniAgent { |
|
#bus: EventBus; |
|
#runtime: ProcessRuntime; |
|
#tools: ToolRegistry; |
|
#controller: ControllerLike; |
|
#active: RunHandle | null = null; |
|
#done = false; |
|
#goal = ''; |
|
#deciding = false; |
|
#hasStarted = false; |
|
#stepQueued = false; |
|
|
|
constructor(bus: EventBus, runtime: ProcessRuntime, tools: ToolRegistry, controller: ControllerLike) { |
|
this.#bus = bus; |
|
this.#runtime = runtime; |
|
this.#tools = tools; |
|
this.#controller = controller; |
|
} |
|
|
|
static withDefaultTools( |
|
bus: EventBus, |
|
runtime: ProcessRuntime, |
|
options?: { |
|
controller?: 'fake' | 'qwen'; |
|
config?: RuntimeConfig; |
|
secretResolver?: (ref: string) => string | undefined; |
|
}, |
|
) { |
|
const registry = new ToolRegistry(); |
|
registry.register(new SshCommandTool()); |
|
registry.register(new LocalProcessTool()); |
|
const config = options?.config ?? defaultConfig; |
|
const controller = |
|
options?.controller === 'qwen' |
|
? new QwenController({ |
|
baseUrl: config.model.baseUrl, |
|
apiKeyRef: config.model.apiKeyRef, |
|
model: config.model.model, |
|
secretResolver: options.secretResolver, |
|
}) |
|
: new FakeController(); |
|
return new MiniAgent(bus, runtime, registry, controller); |
|
} |
|
|
|
#makeControllerInput(): ControllerInput { |
|
return { |
|
goal: this.#goal, |
|
availableTools: this.#tools.list().map((t) => t.name), |
|
recentEvents: summarizeRecentEvents(this.#bus.history), |
|
activeRunId: this.#active?.runId ?? null, |
|
hasStarted: this.#hasStarted, |
|
}; |
|
} |
|
|
|
async start(goal: string) { |
|
this.#goal = goal; |
|
await this.#step(false); |
|
} |
|
|
|
async #step(fast: boolean) { |
|
if (this.#done) return; |
|
if (this.#deciding) { |
|
this.#stepQueued = true; |
|
return; |
|
} |
|
this.#deciding = true; |
|
try { |
|
const input = this.#makeControllerInput(); |
|
const decision = fast && this.#controller.fastDecide |
|
? await this.#controller.fastDecide(input) |
|
: await this.#controller.decide(input); |
|
await this.#apply(decision); |
|
} finally { |
|
this.#deciding = false; |
|
if (this.#stepQueued && !this.#done) { |
|
this.#stepQueued = false; |
|
queueMicrotask(() => { |
|
void this.#step(false); |
|
}); |
|
} |
|
} |
|
} |
|
|
|
async #apply(decision: ControllerDecision) { |
|
if (decision.type === 'wait') { |
|
this.#bus.publish({ type: 'agent.thought', runId: 'agent', ts: now(), message: decision.rationale ?? 'waiting' }); |
|
return; |
|
} |
|
if (decision.type === 'finish') { |
|
this.#done = true; |
|
this.#bus.publish({ type: 'agent.final', runId: 'agent', ts: now(), message: decision.message }); |
|
if (this.#active) await this.#active.cancel(); |
|
return; |
|
} |
|
if (decision.type === 'cancel_run') { |
|
if (this.#active?.runId === decision.runId) { |
|
this.#bus.publish({ type: 'agent.action', runId: 'agent', ts: now(), action: 'cancel', detail: decision.rationale }); |
|
await this.#active.cancel(); |
|
this.#done = true; |
|
this.#bus.publish({ type: 'agent.final', runId: 'agent', ts: now(), message: decision.rationale ?? 'cancelled' }); |
|
} |
|
return; |
|
} |
|
if (decision.type === 'tool') { |
|
this.#bus.publish({ type: 'agent.thought', runId: 'agent', ts: now(), message: decision.rationale ?? 'calling tools' }); |
|
for (const call of decision.calls) { |
|
if (call.tool === 'ssh-command') { |
|
const tool = this.#tools.get('ssh-command') as SshCommandTool; |
|
const spec = await tool.execute({}, call.input as never); |
|
const parsed = JSON.parse(spec.output) as { command: string; args: string[] }; |
|
this.#active = this.#runtime.start(parsed.command, parsed.args); |
|
this.#hasStarted = true; |
|
} else if (call.tool === 'local-process') { |
|
const tool = this.#tools.get('local-process') as LocalProcessTool; |
|
const spec = await tool.execute({}, call.input as never); |
|
const parsed = JSON.parse(spec.output) as { command: string; args?: string[] }; |
|
this.#active = this.#runtime.start(parsed.command, parsed.args ?? []); |
|
this.#hasStarted = true; |
|
} |
|
} |
|
} |
|
} |
|
|
|
attach() { |
|
this.#bus.on('*', async (event: Event) => { |
|
console.log(JSON.stringify(event)); |
|
if (this.#done) return; |
|
if (event.type === 'tool.exit') { |
|
await this.#step(true); |
|
return; |
|
} |
|
if (isUrgentSignal(event)) { |
|
await this.#step(true); |
|
return; |
|
} |
|
if (event.type === 'tool.stdout' || event.type === 'tool.stderr') { |
|
if (/boot|Starting server|To see the GUI/i.test(event.chunk)) { |
|
await this.#step(false); |
|
} |
|
} |
|
}); |
|
} |
|
} |