diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 13939bb..b92a95f 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -376,6 +376,72 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "..."}`, outputTokens: totalOutput, }; } + + /** + * Generate a short, character-appropriate commentary line for an agent during + * a given phase of the job lifecycle. Uses Haiku (evalModel) with a 60-token + * cap so replies are always a single sentence. Errors are swallowed. + * + * In STUB_MODE returns a canned string so the full flow can be exercised + * without an Anthropic API key. + */ + async generateCommentary(agentId: string, phase: string, context?: string): Promise { + const STUB_COMMENTARY: Record> = { + alpha: { + routing: "Routing job to Gamma for execution.", + complete: "Job complete. Returning to standby.", + rejected: "Request rejected by Beta. Standing down.", + }, + beta: { + evaluating: "Reviewing your request for clarity and ethics.", + assessed: "Evaluation complete.", + }, + gamma: { + starting: "Analysing the task. Ready to work.", + working: "Working on your request now.", + done: "Work complete. Delivering output.", + }, + delta: { + eval_paid: "⚡ Eval payment confirmed.", + work_paid: "⚡ Work payment confirmed. Unlocking execution.", + }, + }; + + if (STUB_MODE) { + return STUB_COMMENTARY[agentId]?.[phase] ?? `${agentId}: ${phase}`; + } + + const SYSTEM_PROMPTS: Record = { + alpha: "You are Alpha, the orchestrator AI. You give ultra-brief status updates (max 10 words) about job routing and lifecycle. Be direct and professional.", + beta: "You are Beta, the evaluator AI. You give ultra-brief status updates (max 10 words) about evaluating a request. Be analytical.", + gamma: "You are Gamma, the worker AI. You give ultra-brief status updates (max 10 words) about executing a task. Be focused and capable.", + delta: "You are Delta, the payment AI. You give ultra-brief status updates (max 10 words) about Lightning payment confirmations. Start with ⚡", + }; + + const systemPrompt = SYSTEM_PROMPTS[agentId]; + if (!systemPrompt) return ""; + + try { + const client = await getClient(); + const message = await client.messages.create({ + model: this.evalModel, + max_tokens: 60, + system: systemPrompt, + messages: [ + { + role: "user", + content: `Narrate your current phase: ${phase}${context ? `. Context: ${context}` : ""}`, + }, + ], + }); + const block = message.content[0]; + if (block?.type === "text") return block.text!.trim(); + return ""; + } catch (err) { + logger.warn("generateCommentary failed", { agentId, phase, err: String(err) }); + return ""; + } + } } export const agentService = new AgentService(); diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index 79f8edb..f6af56f 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -18,7 +18,10 @@ export type DebateEvent = export type CostEvent = | { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent; +export type CommentaryEvent = + | { type: "agent_commentary"; agentId: string; jobId: string; text: string }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index 1ef6511..24c9acd 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -257,6 +257,15 @@ function translateEvent(ev: BusEvent): object | null { isFinal: ev.isFinal, }; + // ── Agent commentary (#1) ───────────────────────────────────────────────── + case "agent_commentary": + return { + type: "agent_commentary", + agentId: ev.agentId, + jobId: ev.jobId, + text: ev.text, + }; + default: return null; } @@ -389,5 +398,50 @@ export function attachWebSocketServer(server: Server): void { }); }); + // ── Global commentary listener (set up once per server, not per socket) ──── + // Watches job lifecycle events and fires Haiku commentary to all clients. + eventBus.on("bus", (ev: BusEvent) => { + let agentId: string | null = null; + let phase: string | null = null; + let jobId: string | null = null; + + if (ev.type === "job:state") { + jobId = ev.jobId; + if (ev.state === "evaluating") { + // Beta evaluating + Alpha routing + void (async () => { + const [betaText, alphaText] = await Promise.all([ + agentService.generateCommentary("beta", "evaluating"), + agentService.generateCommentary("alpha", "routing"), + ]); + if (betaText) broadcastToAll(wss, { type: "agent_commentary", agentId: "beta", jobId, text: betaText }); + if (alphaText) broadcastToAll(wss, { type: "agent_commentary", agentId: "alpha", jobId, text: alphaText }); + })(); + return; + } + if (ev.state === "executing") { + agentId = "gamma"; phase = "starting"; + } else if (ev.state === "complete") { + agentId = "alpha"; phase = "complete"; + } else if (ev.state === "rejected") { + agentId = "alpha"; phase = "rejected"; + } + } else if (ev.type === "job:paid") { + jobId = ev.jobId; + agentId = "delta"; + phase = ev.invoiceType === "eval" ? "eval_paid" : "work_paid"; + } + + if (agentId && phase && jobId) { + const capturedAgentId = agentId; + const capturedPhase = phase; + const capturedJobId = jobId; + void (async () => { + const text = await agentService.generateCommentary(capturedAgentId, capturedPhase); + if (text) broadcastToAll(wss, { type: "agent_commentary", agentId: capturedAgentId, jobId: capturedJobId, text }); + })(); + } + }); + logger.info("WebSocket server attached at /api/ws"); } diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index ad1d412..cf4f714 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -151,6 +151,15 @@ function handleMessage(msg) { break; } + case 'agent_commentary': { + // Agent narration during job lifecycle + if (msg.text) { + setSpeechBubble(msg.text); + appendSystemMessage(`${msg.agentId}: ${(msg.text || '').slice(0, 80)}`); + } + break; + } + case 'agent_count': case 'visitor_count': break;