feat: agent commentary during job execution
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s

Add character-appropriate narration for each agent (Alpha, Beta, Gamma,
Delta) at each job lifecycle phase. Generated by Claude Haiku per agent
per phase and broadcast as agent_commentary WebSocket events.

- Add CommentaryEvent type to event-bus.ts
- Add AgentService.generateCommentary() with per-agent personas and
  stub mode canned responses
- Dispatch commentary in events.ts after job:state and job:paid
  transitions via a global event bus listener
- Handle agent_commentary on frontend: show speech bubble + event log

Fixes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 16:40:03 -04:00
parent e41d30d308
commit 29d4a53f8a
4 changed files with 133 additions and 1 deletions

View File

@@ -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<string> {
const STUB_COMMENTARY: Record<string, Record<string, string>> = {
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<string, string> = {
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();

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;