From 277cee8a7ea38a1653b3e7d7f2f92fceaeb42e5d Mon Sep 17 00:00:00 2001 From: Yahya Alhinai Date: Sun, 28 Jun 2026 08:33:52 +0000 Subject: [PATCH] feat(hermes): add async Gemini handoff jobs --- .prettierignore | 2 + agents/podman-live-conversation/agent.py | 106 +++++- .../tests/test_agent_helpers.py | 15 +- backend/src/hermes/jobs.ts | 349 ++++++++++++++++++ backend/src/memory/db.ts | 9 + backend/src/server.ts | 151 +++++++- frontend/src/components/PodView.tsx | 96 ++++- frontend/src/lib/api.ts | 29 ++ scripts/verify-backend.mjs | 61 ++- shared/src/hermes-job.ts | 79 ++++ shared/src/index.ts | 9 + shared/src/messages.ts | 2 + 12 files changed, 883 insertions(+), 25 deletions(-) create mode 100644 backend/src/hermes/jobs.ts create mode 100644 shared/src/hermes-job.ts diff --git a/.prettierignore b/.prettierignore index 80963ea..5fd1806 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ node_modules .venv **/.venv +.pytest_cache +**/.pytest_cache dist build pnpm-lock.yaml diff --git a/agents/podman-live-conversation/agent.py b/agents/podman-live-conversation/agent.py index 77af9db..fd17aab 100644 --- a/agents/podman-live-conversation/agent.py +++ b/agents/podman-live-conversation/agent.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import time from typing import Any from urllib import error, request @@ -28,6 +29,9 @@ You are in a private 1:1 voice conversation with one developer. Use PodMan tools before making claims about current work, git state, collisions, blockers, team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step. If a critical collision event arrives, stop the current turn and state the alert immediately. +For complex repository, terminal, GitHub, MongoDB, build, install, deploy, or multi-step tasks, +call delegate_to_hermes. Do not run those actions directly. If the user says stop, wait, cancel, +or change of plans while Hermes is running, call abort_active_hermes_job immediately. Do not reveal raw secrets, API keys, private tokens, or another teammate's private notes.""" @@ -62,11 +66,14 @@ def request_json(path: str, *, method: str = "GET", body: dict[str, Any] | None class PodManLiveAgent(Agent): - def __init__(self, pod_id: str, identity: str, session_id: str) -> None: + def __init__(self, pod_id: str, identity: str, session_id: str, conversation_room: str) -> None: super().__init__(instructions=INSTRUCTIONS) self.pod_id = pod_id self.identity = identity self.session_id = session_id + self.conversation_room = conversation_room + self.active_hermes_job_id: str | None = None + self.last_spoken_progress_at = 0.0 @function_tool() async def get_active_pod_context(self, context: RunContext) -> str: @@ -122,6 +129,77 @@ class PodManLiveAgent(Agent): snippets.append(haystack[max(0, idx - 400) : idx + 1200]) return "\n---\n".join(snippets)[:8000] or haystack[:6000] + @function_tool() + async def delegate_to_hermes( + self, + context: RunContext, + prompt: str, + context_scope: str = "current_repo", + target_repository: str = "", + risk_level: str = "read_only", + requires_confirmation: bool = False, + success_criteria: list[str] | None = None, + ) -> str: + """Hand off a complex engineering task to Hermes, PodMan's autonomous backend execution engine. + + Use this for filesystem, terminal, GitHub, MongoDB, build, install, deploy, test, + or multi-step repository tasks. Do not use this for simple conversational answers. + """ + body = { + "prompt": prompt, + "contextScope": context_scope, + "targetRepository": target_repository or "karti-ai/podman", + "riskLevel": risk_level, + "requiresConfirmation": requires_confirmation, + "successCriteria": success_criteria or ["Hermes completes the requested inspection."], + "podId": self.pod_id, + "identity": self.identity, + "sessionId": self.session_id, + "conversationRoom": self.conversation_room, + } + job = await asyncio.to_thread(request_json, "/api/internal/hermes/jobs", method="POST", body=body) + self.active_hermes_job_id = str(job["id"]) + return json.dumps( + { + "status": "accepted", + "job_id": self.active_hermes_job_id, + "spoken_ack": "Hermes is starting that now. I will keep you posted.", + }, + ensure_ascii=True, + ) + + @function_tool() + async def abort_active_hermes_job(self, context: RunContext, reason: str = "User changed plans") -> str: + """Abort the currently running Hermes job immediately.""" + if not self.active_hermes_job_id: + return "No active Hermes job is running." + job = await asyncio.to_thread( + request_json, + f"/api/internal/hermes/jobs/{self.active_hermes_job_id}/abort", + method="POST", + body={"reason": reason}, + ) + return json.dumps( + { + "status": job.get("status", "aborting"), + "job_id": self.active_hermes_job_id, + "spoken_ack": "Stopped. Hermes is aborting the job before making further changes.", + }, + ensure_ascii=True, + ) + + def should_speak_progress(self, event: dict[str, Any]) -> bool: + event_type = event.get("type") + if event_type in {"completed", "failed", "aborted", "needs_confirmation"}: + return True + if event_type not in {"heartbeat", "step_started", "step_completed"}: + return False + monotonic = time.monotonic() + if monotonic - self.last_spoken_progress_at < 8: + return False + self.last_spoken_progress_at = monotonic + return True + server = AgentServer() @@ -139,7 +217,12 @@ async def entrypoint(ctx: agents.JobContext): voice=VOICE, ), ) - agent = PodManLiveAgent(pod_id=pod_id, identity=identity, session_id=session_id) + agent = PodManLiveAgent( + pod_id=pod_id, + identity=identity, + session_id=session_id, + conversation_room=ctx.room.name, + ) def on_data_received(*args: Any): payload = args[0] if args else b"" @@ -151,19 +234,28 @@ async def entrypoint(ctx: agents.JobContext): msg = json.loads(raw) except json.JSONDecodeError: return - if msg.get("type") != "LIVE_CONVERSATION_EVENT": + msg_type = msg.get("type") + if msg_type == "HERMES_JOB_EVENT": + event = msg.get("event") or {} + summary = str(event.get("message") or "").strip() + if str(event.get("type")) in {"completed", "failed", "aborted"}: + agent.active_hermes_job_id = None + elif msg_type == "LIVE_CONVERSATION_EVENT": + event = msg.get("event") or {} + summary = str(event.get("summary") or "").strip() + else: return - event = msg.get("event") or {} - summary = str(event.get("summary") or "").strip() if not summary: return async def interrupt_and_say() -> None: try: - await session.interrupt(force=True) + if msg_type == "LIVE_CONVERSATION_EVENT": + await session.interrupt(force=True) except Exception as exc: logger.warning("interrupt failed: %s", exc) - await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True) + if msg_type == "LIVE_CONVERSATION_EVENT" or agent.should_speak_progress(event): + await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True) asyncio.create_task(interrupt_and_say()) diff --git a/agents/podman-live-conversation/tests/test_agent_helpers.py b/agents/podman-live-conversation/tests/test_agent_helpers.py index c895d5f..7bf38c7 100644 --- a/agents/podman-live-conversation/tests/test_agent_helpers.py +++ b/agents/podman-live-conversation/tests/test_agent_helpers.py @@ -1,4 +1,4 @@ -from agent import parse_metadata +from agent import PodManLiveAgent, parse_metadata def test_parse_metadata_accepts_valid_json(): @@ -11,3 +11,16 @@ def test_parse_metadata_accepts_valid_json(): def test_parse_metadata_handles_bad_json(): assert parse_metadata("not json") == {} + + +def test_hermes_terminal_events_always_speak(): + agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room") + assert agent.should_speak_progress({"type": "completed"}) is True + assert agent.should_speak_progress({"type": "failed"}) is True + assert agent.should_speak_progress({"type": "aborted"}) is True + + +def test_hermes_progress_is_throttled(): + agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room") + assert agent.should_speak_progress({"type": "heartbeat"}) is True + assert agent.should_speak_progress({"type": "step_started"}) is False diff --git a/backend/src/hermes/jobs.ts b/backend/src/hermes/jobs.ts new file mode 100644 index 0000000..f0a8f52 --- /dev/null +++ b/backend/src/hermes/jobs.ts @@ -0,0 +1,349 @@ +import { randomUUID } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { Room as LiveKitRoom } from '@livekit/rtc-node'; +import { AccessToken } from 'livekit-server-sdk'; +import { + DATA_TOPIC, + type DataMessage, + type HermesJob, + type HermesJobEvent, + type HermesJobEventType, + type HermesJobInput, + type HermesJobStatus, + type HermesRiskLevel, +} from '@podman/shared'; +import { env, repoParts } from '../env.js'; +import { getDb } from '../memory/db.js'; + +const execFileAsync = promisify(execFile); +const encoder = new TextEncoder(); +const MAX_OUTPUT = 3_000; +const COMMAND_TIMEOUT_MS = 45_000; +const runners = new Map(); + +function now(): string { + return new Date().toISOString(); +} + +function truncate(value: string): string { + return value.length > MAX_OUTPUT ? `${value.slice(0, MAX_OUTPUT)}\n...[truncated]` : value; +} + +function redact(value: string): string { + return value + .replace(/AIza[0-9A-Za-z_-]{20,}/g, '[redacted-google-key]') + .replace(/API[_-]?SECRET=[^\s]+/gi, 'API_SECRET=[redacted]') + .replace(/TOKEN=[^\s]+/gi, 'TOKEN=[redacted]') + .replace(/mongodb(\+srv)?:\/\/[^@\s]+@/gi, 'mongodb$1://[redacted]@'); +} + +function normalizeRisk(value: unknown): HermesRiskLevel { + return value === 'safe_write' || + value === 'commit_allowed' || + value === 'deploy_allowed' || + value === 'read_only' + ? value + : 'read_only'; +} + +async function hermesJobs() { + return (await getDb()).collection('hermes_jobs'); +} + +async function hermesJobEvents() { + return (await getDb()).collection('hermes_job_events'); +} + +export async function ensureHermesJobIndexes(): Promise { + const db = await getDb(); + await Promise.allSettled([ + db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true }), + db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }), + db.collection('hermes_jobs').createIndex({ podId: 1, updatedAt: -1 }), + db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }), + db.collection('hermes_job_events').createIndex({ sessionId: 1, createdAt: -1 }), + ]); +} + +export async function createHermesJob(input: Partial): Promise { + const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : ''; + if (!prompt) throw new Error('prompt is required'); + const createdAt = now(); + const job: HermesJob = { + id: `hermes_job_${randomUUID()}`, + podId: input.podId || 'demo-pod', + identity: input.identity || 'developer', + sessionId: input.sessionId || 'unknown', + conversationRoom: input.conversationRoom, + prompt, + contextScope: input.contextScope || 'current_repo', + targetRepository: input.targetRepository || env.GITHUB_REPO, + riskLevel: normalizeRisk(input.riskLevel), + requiresConfirmation: input.requiresConfirmation === true, + successCriteria: Array.isArray(input.successCriteria) + ? input.successCriteria.map(String).filter(Boolean).slice(0, 8) + : ['Hermes reports what it inspected and what changed.'], + parentJobId: input.parentJobId, + status: 'queued', + createdAt, + updatedAt: createdAt, + }; + await (await hermesJobs()).insertOne(job); + await appendHermesJobEvent(job.id, 'accepted', 'Hermes accepted the task.', { + riskLevel: job.riskLevel, + contextScope: job.contextScope, + }); + void runHermesJob(job.id); + return job; +} + +export async function getHermesJob(jobId: string): Promise { + return (await hermesJobs()).findOne({ id: jobId }, { projection: { _id: 0 } }); +} + +export async function getActiveHermesJobForSession(sessionId: string): Promise { + return (await hermesJobs()).findOne( + { sessionId, status: { $in: ['queued', 'running', 'waiting_for_confirmation', 'aborting'] } }, + { projection: { _id: 0 }, sort: { updatedAt: -1 } }, + ); +} + +export async function getLatestHermesJobForSession(sessionId: string): Promise { + return (await hermesJobs()).findOne( + { sessionId }, + { projection: { _id: 0 }, sort: { updatedAt: -1 } }, + ); +} + +export async function listHermesJobEvents(jobId: string, limit = 40): Promise { + return (await hermesJobEvents()) + .find({ jobId }, { projection: { _id: 0 } }) + .sort({ createdAt: 1 }) + .limit(Math.min(limit, 200)) + .toArray(); +} + +export async function appendHermesJobEvent( + jobId: string, + type: HermesJobEventType, + message: string, + data?: Record, +): Promise { + const job = await getHermesJob(jobId); + if (!job) throw new Error('job not found'); + const event: HermesJobEvent = { + id: `hermes_evt_${randomUUID()}`, + jobId, + podId: job.podId, + sessionId: job.sessionId, + type, + message: redact(truncate(message)), + data, + createdAt: now(), + }; + await (await hermesJobEvents()).insertOne(event); + await ( + await hermesJobs() + ).updateOne( + { id: jobId }, + { $set: { updatedAt: event.createdAt, lastHeartbeatAt: event.createdAt } }, + ); + if (job.conversationRoom) { + void publishHermesJobEvent(job.conversationRoom, event).catch((err) => + console.warn(`[hermes-job] data publish failed: ${(err as Error).message}`), + ); + } + return event; +} + +export async function abortHermesJob(jobId: string): Promise { + const job = await getHermesJob(jobId); + if (!job) return null; + const abortAt = now(); + await ( + await hermesJobs() + ).updateOne( + { id: jobId }, + { $set: { status: 'aborting', abortRequestedAt: abortAt, updatedAt: abortAt } }, + ); + runners.get(jobId)?.abort(); + await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is aborting the current job.'); + return getHermesJob(jobId); +} + +async function setStatus(jobId: string, status: HermesJobStatus, patch: Partial = {}) { + await ( + await hermesJobs() + ).updateOne({ id: jobId }, { $set: { status, updatedAt: now(), ...patch } }); +} + +async function publishHermesJobEvent(roomName: string, event: HermesJobEvent): Promise { + const room = new LiveKitRoom(); + try { + const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, { + identity: `podman-hermes-job-${Date.now()}`, + name: 'PodMan Hermes jobs', + ttl: '5m', + }); + at.addGrant({ + roomJoin: true, + room: roomName, + canPublish: true, + canSubscribe: false, + canPublishData: true, + }); + await room.connect(env.LIVEKIT_URL, await at.toJwt(), { + autoSubscribe: false, + dynacast: false, + }); + const data: DataMessage = { type: 'HERMES_JOB_EVENT', event }; + await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), { + reliable: true, + topic: DATA_TOPIC, + }); + } finally { + await room.disconnect().catch(() => {}); + } +} + +async function runCommand( + jobId: string, + label: string, + command: string, + args: string[], + signal: AbortSignal, +): Promise { + await appendHermesJobEvent(jobId, 'step_started', `${label} started.`); + const started = Date.now(); + const { stdout, stderr } = await execFileAsync(command, args, { + cwd: process.cwd(), + timeout: COMMAND_TIMEOUT_MS, + signal, + maxBuffer: 1024 * 1024, + }); + const output = redact(truncate([stdout, stderr].filter(Boolean).join('\n').trim())); + await appendHermesJobEvent(jobId, 'step_output', output || `${label} produced no output.`, { + label, + durationMs: Date.now() - started, + }); + await appendHermesJobEvent(jobId, 'step_completed', `${label} completed.`); + return output; +} + +function wantsBuild(prompt: string, criteria: string[]): boolean { + const haystack = `${prompt} ${criteria.join(' ')}`.toLowerCase(); + return /build|typecheck|test|lint|broken|failing|verify/.test(haystack); +} + +function wantsMongo(prompt: string, scope: string): boolean { + return scope === 'mongodb' || /mongo|database|telemetry|logs?|memory/.test(prompt.toLowerCase()); +} + +function wantsGithub(prompt: string, scope: string): boolean { + return ( + scope === 'github' || /github|branch|pr|pull request|commit|diff/.test(prompt.toLowerCase()) + ); +} + +async function inspectMongo(jobId: string) { + await appendHermesJobEvent(jobId, 'step_started', 'MongoDB inspection started.'); + const db = await getDb(); + const [observations, collisions, interventions, outcomes, jobs] = await Promise.all([ + db.collection('observations').estimatedDocumentCount(), + db.collection('collisions').estimatedDocumentCount(), + db.collection('interventions').estimatedDocumentCount(), + db.collection('outcomes').estimatedDocumentCount(), + db.collection('hermes_jobs').estimatedDocumentCount(), + ]); + await appendHermesJobEvent( + jobId, + 'step_output', + `MongoDB is reachable. Counts: observations=${observations}, collisions=${collisions}, interventions=${interventions}, outcomes=${outcomes}, hermes_jobs=${jobs}.`, + ); + await appendHermesJobEvent(jobId, 'step_completed', 'MongoDB inspection completed.'); +} + +async function inspectGithub(jobId: string) { + await appendHermesJobEvent(jobId, 'step_started', 'GitHub repository inspection started.'); + const { owner, repo } = repoParts(); + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${env.GITHUB_TOKEN}`, + 'x-github-api-version': '2022-11-28', + }, + }); + if (!res.ok) throw new Error(`GitHub repo check returned ${res.status}`); + const body = (await res.json()) as { + full_name?: string; + default_branch?: string; + open_issues_count?: number; + }; + await appendHermesJobEvent( + jobId, + 'step_output', + `GitHub ${body.full_name ?? `${owner}/${repo}`} is reachable. Default branch=${body.default_branch ?? 'unknown'}, open issue count=${body.open_issues_count ?? 0}.`, + ); + await appendHermesJobEvent(jobId, 'step_completed', 'GitHub repository inspection completed.'); +} + +async function runHermesJob(jobId: string): Promise { + const job = await getHermesJob(jobId); + if (!job) return; + const controller = new AbortController(); + runners.set(jobId, controller); + try { + await setStatus(jobId, 'running', { startedAt: now() }); + await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is gathering repository context.'); + const outputs: string[] = []; + outputs.push( + await runCommand( + jobId, + 'Git status', + 'git', + ['status', '--short', '--branch'], + controller.signal, + ), + ); + outputs.push( + await runCommand(jobId, 'Git diff summary', 'git', ['diff', '--stat'], controller.signal), + ); + + if (wantsGithub(job.prompt, job.contextScope)) await inspectGithub(jobId); + if (wantsMongo(job.prompt, job.contextScope)) await inspectMongo(jobId); + + if (wantsBuild(job.prompt, job.successCriteria)) { + outputs.push( + await runCommand(jobId, 'TypeScript typecheck', 'pnpm', ['typecheck'], controller.signal), + ); + } + + if (job.riskLevel === 'deploy_allowed' && job.requiresConfirmation) { + await setStatus(jobId, 'waiting_for_confirmation'); + await appendHermesJobEvent( + jobId, + 'needs_confirmation', + 'Hermes needs confirmation before deploy-level actions.', + ); + return; + } + + const finalSummary = `Hermes completed the task. It inspected repository state${wantsMongo(job.prompt, job.contextScope) ? ', MongoDB' : ''}${wantsGithub(job.prompt, job.contextScope) ? ', and GitHub' : ''}. ${outputs.some((o) => /error|failed/i.test(o)) ? 'Review the recorded output for warnings.' : 'No blocking error was reported by the completed checks.'}`; + await setStatus(jobId, 'completed', { completedAt: now(), finalSummary }); + await appendHermesJobEvent(jobId, 'completed', finalSummary); + } catch (err) { + const aborted = controller.signal.aborted; + const message = aborted + ? 'Hermes aborted the job before making further changes.' + : (err as Error).message; + await setStatus(jobId, aborted ? 'aborted' : 'failed', { + completedAt: now(), + finalSummary: message, + error: aborted ? undefined : message, + }); + await appendHermesJobEvent(jobId, aborted ? 'aborted' : 'failed', message); + } finally { + runners.delete(jobId); + } +} diff --git a/backend/src/memory/db.ts b/backend/src/memory/db.ts index 2a83cd0..9087859 100644 --- a/backend/src/memory/db.ts +++ b/backend/src/memory/db.ts @@ -114,6 +114,15 @@ export async function initMemory(): Promise { ['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })], ['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })], ['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })], + ['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })], + [ + 'hermes_jobs.session', + () => db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }), + ], + [ + 'hermes_job_events.job', + () => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }), + ], ]; for (const [name, make] of indexes) { try { diff --git a/backend/src/server.ts b/backend/src/server.ts index e869549..883705e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -39,7 +39,22 @@ import { getLiveConversationContext, recordLiveConversationNote, } from './live-conversation/context.js'; -import type { Collision, Intervention, InterventionOutcome, SuggestedActionKind } from '@podman/shared'; +import { + abortHermesJob, + appendHermesJobEvent, + createHermesJob, + getActiveHermesJobForSession, + getHermesJob, + getLatestHermesJobForSession, + listHermesJobEvents, +} from './hermes/jobs.js'; +import type { + Collision, + HermesJobEventType, + Intervention, + InterventionOutcome, + SuggestedActionKind, +} from '@podman/shared'; const app = express(); app.use(cors()); @@ -47,9 +62,7 @@ app.use(express.json()); app.get('/health', (_req, res) => res.json({ ok: true })); function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.map((item) => String(item).trim()).filter(Boolean) - : []; + return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; } function suggestedAction(value: unknown): SuggestedActionKind { @@ -58,6 +71,20 @@ function suggestedAction(value: unknown): SuggestedActionKind { : 'ping_teammate'; } +function hermesJobEventType(value: unknown): HermesJobEventType | null { + return value === 'accepted' || + value === 'heartbeat' || + value === 'step_started' || + value === 'step_output' || + value === 'needs_confirmation' || + value === 'step_completed' || + value === 'aborted' || + value === 'failed' || + value === 'completed' + ? value + : null; +} + // Mint a LiveKit token for an engineer joining a pod. app.post('/api/token', async (req, res) => { const { room, identity, name, githubLogin } = req.body ?? {}; @@ -225,6 +252,27 @@ app.get('/api/pods/:id/live-conversation/status', (req, res) => { res.json({ active: activeLiveConversation(req.params.id, identity) }); }); +app.get('/api/pods/:id/live-conversation/:sessionId/hermes-job', async (req, res) => { + try { + const job = await getLatestHermesJobForSession(req.params.sessionId); + if (!job || job.podId !== req.params.id) return res.json({ job: null, events: [] }); + res.json({ job, events: await listHermesJobEvents(job.id, 12) }); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } +}); + +app.post('/api/pods/:id/live-conversation/:sessionId/hermes-job/abort', async (req, res) => { + try { + const job = await getActiveHermesJobForSession(req.params.sessionId); + if (!job || job.podId !== req.params.id) + return res.status(404).json({ error: 'active job not found' }); + res.json({ job: await abortHermesJob(job.id) }); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } +}); + function requireInternalAgent(req: express.Request, res: express.Response): boolean { const expected = env.INTERNAL_AGENT_TOKEN; if (!expected) { @@ -271,6 +319,93 @@ app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req, } }); +app.post('/api/internal/hermes/jobs', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + try { + res.status(202).json(await createHermesJob(req.body ?? {})); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +app.get('/api/internal/hermes/jobs/:jobId', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + const job = await getHermesJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'job not found' }); + res.json(job); +}); + +app.post('/api/internal/hermes/jobs/:jobId/abort', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + const job = await abortHermesJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'job not found' }); + res.json(job); +}); + +app.get('/api/internal/hermes/jobs/:jobId/events', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + const job = await getHermesJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'job not found' }); + res.json(await listHermesJobEvents(req.params.jobId, 100)); +}); + +app.post('/api/internal/hermes/jobs/:jobId/events', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + try { + const { type, message, data } = req.body ?? {}; + const eventType = hermesJobEventType(type); + if (!eventType || typeof message !== 'string') { + return res.status(400).json({ error: 'type and message are required' }); + } + res.status(201).json(await appendHermesJobEvent(req.params.jobId, eventType, message, data)); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +app.get('/api/internal/hermes/jobs/:jobId/events/stream', async (req, res) => { + if (!requireInternalAgent(req, res)) return; + const job = await getHermesJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'job not found' }); + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders?.(); + + let closed = false; + let lastIds = new Set(); + const send = async () => { + if (closed) return; + try { + const events = await listHermesJobEvents(req.params.jobId, 100); + const fresh = events.filter((event) => !lastIds.has(event.id)); + lastIds = new Set(events.map((event) => event.id)); + for (const event of fresh) { + res.write(`event: job-event\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + const current = await getHermesJob(req.params.jobId); + if (current && ['completed', 'failed', 'aborted'].includes(current.status)) { + res.write(`event: done\n`); + res.write(`data: ${JSON.stringify(current)}\n\n`); + closed = true; + res.end(); + } else { + res.write(`: keepalive ${Date.now()}\n\n`); + } + } catch (e) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`); + } + }; + await send(); + const interval = setInterval(() => void send(), 1500); + req.on('close', () => { + closed = true; + clearInterval(interval); + }); +}); + app.post('/api/pods/:id/hermes/notify', async (req, res) => { const podId = req.params.id; const pod = await getPod(podId); @@ -283,10 +418,14 @@ app.post('/api/pods/:id/hermes/notify', async (req, res) => { const now = new Date().toISOString(); const engineers = stringArray(body.engineers); const recipients = engineers.length ? engineers : pod.members.slice(0, 2); - const file = typeof body.file === 'string' && body.file.trim() ? body.file.trim() : 'Hermes signal'; + const file = + typeof body.file === 'string' && body.file.trim() ? body.file.trim() : 'Hermes signal'; const urgent = body.urgency === 'urgent' || body.severity === 'critical'; const collision: Collision = { - id: typeof body.collisionId === 'string' && body.collisionId ? body.collisionId : `col_${Date.now()}`, + id: + typeof body.collisionId === 'string' && body.collisionId + ? body.collisionId + : `col_${Date.now()}`, podId, file, symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined, diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index 45ba12f..2e405cc 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -28,6 +28,8 @@ import { } from 'lucide-react'; import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client'; import type { + HermesJob, + HermesJobEvent, MemberWorkHistory, MemberWorkHistoryEvent, MemberWorkHistoryFile, @@ -38,6 +40,8 @@ import type { } from '@podman/shared'; import { useBeat } from '../livekit/useBeat.js'; import { + abortLiveConversationHermesJob, + getLiveConversationHermesJob, getMemberWorkHistory, startLiveConversation, stopLiveConversation, @@ -147,12 +151,15 @@ export function PodView({ const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); const [conversationRoom, setConversationRoom] = useState(null); - const [conversationSession, setConversationSession] = - useState(null); + const [conversationSession, setConversationSession] = useState( + null, + ); const [conversationState, setConversationState] = useState< 'idle' | 'connecting' | 'listening' | 'speaking' | 'interrupted' | 'error' >('idle'); const [conversationNote, setConversationNote] = useState(null); + const [hermesJob, setHermesJob] = useState(null); + const [hermesJobEvents, setHermesJobEvents] = useState([]); const [leftStreamOpen, setLeftStreamOpen] = useState(() => readStoredBool('podman.myStreamOpen', true), ); @@ -259,7 +266,9 @@ export function PodView({ element.autoplay = true; conversationAudioElementsRef.current.set(key, element); audioRef.current.appendChild(element); - setRemoteAudioTracks(audioElementsRef.current.size + conversationAudioElementsRef.current.size); + setRemoteAudioTracks( + audioElementsRef.current.size + conversationAudioElementsRef.current.size, + ); }; const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => { const key = `conversation:${pub?.trackSid || track.sid || track.mediaStreamTrack.id}`; @@ -295,6 +304,13 @@ export function PodView({ setConversationState(msg.event?.interrupt ? 'interrupted' : 'listening'); if (msg.event?.summary) setConversationNote(msg.event.summary); } + if (msg.type === 'HERMES_JOB_EVENT') { + const event = msg.event as HermesJobEvent; + setHermesJobEvents((events) => + [...events.filter((item) => item.id !== event.id), event].slice(-12), + ); + setConversationNote(event.message); + } } catch { // Ignore non-PodMan private-room data. } @@ -320,6 +336,31 @@ export function PodView({ }; }, [conversationRoom]); + useEffect(() => { + if (!conversationSession) { + setHermesJob(null); + setHermesJobEvents([]); + return; + } + let alive = true; + const refresh = async () => { + try { + const next = await getLiveConversationHermesJob(team.id, conversationSession.sessionId); + if (!alive) return; + setHermesJob(next.job); + setHermesJobEvents(next.events); + } catch { + // Keep voice conversation usable even if the status panel cannot refresh. + } + }; + void refresh(); + const interval = setInterval(() => void refresh(), 2500); + return () => { + alive = false; + clearInterval(interval); + }; + }, [team.id, conversationSession]); + useEffect(() => { if (!historyMember) { setHistory(null); @@ -426,6 +467,8 @@ export function PodView({ setConversationRoom(null); setConversationSession(null); setConversationState('idle'); + setHermesJob(null); + setHermesJobEvents([]); try { await endingRoom.localParticipant.setMicrophoneEnabled(false).catch(() => {}); await endingRoom.disconnect(); @@ -457,10 +500,24 @@ export function PodView({ setConversationState('error'); setConversationRoom(null); setConversationSession(null); + setHermesJob(null); + setHermesJobEvents([]); setNote(`Live conversation failed: ${(e as Error).message}`); } } + async function stopHermesJob() { + if (!conversationSession || !hermesJob) return; + setNote(null); + try { + const next = await abortLiveConversationHermesJob(team.id, conversationSession.sessionId); + setHermesJob(next.job); + setConversationNote('Hermes is aborting the current job.'); + } catch (e) { + setNote(`Could not stop Hermes job: ${(e as Error).message}`); + } + } + async function toggleScreen() { primeSpeech(); // unlock browser voice from this gesture too if (!room) return; @@ -813,7 +870,40 @@ export function PodView({ label="Context" value={conversationRoom ? 'synced on demand' : 'waiting'} /> + + {hermesJob && + ['queued', 'running', 'waiting_for_confirmation', 'aborting'].includes( + hermesJob.status, + ) && ( + + )} + {hermesJobEvents.length > 0 && ( +
+
+ + Hermes progress +
+
+ {hermesJobEvents.slice(-3).map((event) => ( +
+ {event.type.replaceAll('_', ' ')} + - {event.message} +
+ ))} +
+
+ )} {conversationNote && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index de57fda..168cc67 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,7 @@ import type { InterventionOutcome, + HermesJob, + HermesJobEvent, MemberWorkHistory, Pod, PodActivityEvent, @@ -188,6 +190,33 @@ export async function stopLiveConversation(podId: string, sessionId: string): Pr if (!res.ok) throw new Error(`live conversation stop failed: ${res.status}`); } +export async function getLiveConversationHermesJob( + podId: string, + sessionId: string, +): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> { + return json( + await fetch( + `${BACKEND_URL}/api/pods/${encodeURIComponent( + podId, + )}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`, + ), + ); +} + +export async function abortLiveConversationHermesJob( + podId: string, + sessionId: string, +): Promise<{ job: HermesJob | null }> { + return json( + await fetch( + `${BACKEND_URL}/api/pods/${encodeURIComponent( + podId, + )}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`, + { method: 'POST' }, + ), + ); +} + export async function removeMember(id: string, name: string): Promise { return json( await fetch( diff --git a/scripts/verify-backend.mjs b/scripts/verify-backend.mjs index 35dcb45..22ebd9e 100644 --- a/scripts/verify-backend.mjs +++ b/scripts/verify-backend.mjs @@ -22,6 +22,7 @@ const env = { GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github', GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman', MONGODB_URI: mongoUri, + INTERNAL_AGENT_TOKEN: process.env.INTERNAL_AGENT_TOKEN ?? 'verify-internal-agent-token', }; function fail(message) { @@ -115,14 +116,11 @@ async function verifyApi() { } const liveConversation = await json( - await doFetch( - `${baseUrl}/api/pods/${encodeURIComponent(created.id)}/live-conversation/start`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ identity: 'Alice', displayName: 'Alice' }), - }, - ), + await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/live-conversation/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ identity: 'Alice', displayName: 'Alice' }), + }), ); if ( typeof liveConversation.token !== 'string' || @@ -151,6 +149,52 @@ async function verifyApi() { ), ); + const hermesJob = await json( + await doFetch(`${baseUrl}/api/internal/hermes/jobs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}`, + }, + body: JSON.stringify({ + prompt: 'Check repository state for backend verification.', + contextScope: 'current_repo', + riskLevel: 'read_only', + successCriteria: ['Git status is inspected.'], + podId: created.id, + identity: 'Alice', + sessionId: liveConversation.sessionId, + }), + }), + ); + if (!hermesJob.id || hermesJob.status !== 'queued') { + fail('Hermes job create returned unexpected payload'); + } + let finalJob = hermesJob; + for (let i = 0; i < 30; i++) { + finalJob = await json( + await doFetch(`${baseUrl}/api/internal/hermes/jobs/${encodeURIComponent(hermesJob.id)}`, { + headers: { authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}` }, + }), + ); + if (['completed', 'failed', 'aborted'].includes(finalJob.status)) break; + await delay(500); + } + if (finalJob.status !== 'completed') { + fail(`Hermes job did not complete: ${JSON.stringify(finalJob)}`); + } + const hermesEvents = await json( + await doFetch( + `${baseUrl}/api/internal/hermes/jobs/${encodeURIComponent(hermesJob.id)}/events`, + { + headers: { authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}` }, + }, + ), + ); + if (!Array.isArray(hermesEvents) || hermesEvents.length < 1) { + fail('Hermes job events were not persisted'); + } + await json( await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }), ); @@ -320,6 +364,7 @@ try { 'pod-crud', 'hermes-notify', 'live-conversation-session', + 'hermes-job-lifecycle', 'collision', 'memory-recall', 'graph', diff --git a/shared/src/hermes-job.ts b/shared/src/hermes-job.ts new file mode 100644 index 0000000..8c77dc4 --- /dev/null +++ b/shared/src/hermes-job.ts @@ -0,0 +1,79 @@ +export type HermesJobStatus = + | 'queued' + | 'running' + | 'waiting_for_confirmation' + | 'aborting' + | 'aborted' + | 'failed' + | 'completed'; + +export type HermesJobEventType = + | 'accepted' + | 'heartbeat' + | 'step_started' + | 'step_output' + | 'needs_confirmation' + | 'step_completed' + | 'aborted' + | 'failed' + | 'completed'; + +export type HermesContextScope = + | 'current_pod' + | 'current_repo' + | 'current_file' + | 'github' + | 'mongodb' + | 'terminal' + | 'full_workspace'; + +export type HermesRiskLevel = 'read_only' | 'safe_write' | 'commit_allowed' | 'deploy_allowed'; + +export interface HermesJobInput { + prompt: string; + contextScope: HermesContextScope; + targetRepository?: string; + riskLevel: HermesRiskLevel; + requiresConfirmation?: boolean; + successCriteria: string[]; + podId: string; + identity: string; + sessionId: string; + conversationRoom?: string; + parentJobId?: string; +} + +export interface HermesJob { + id: string; + podId: string; + identity: string; + sessionId: string; + conversationRoom?: string; + prompt: string; + contextScope: HermesContextScope; + targetRepository: string; + riskLevel: HermesRiskLevel; + requiresConfirmation: boolean; + successCriteria: string[]; + parentJobId?: string; + status: HermesJobStatus; + finalSummary?: string; + error?: string; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + lastHeartbeatAt?: string; + abortRequestedAt?: string; +} + +export interface HermesJobEvent { + id: string; + jobId: string; + podId: string; + sessionId: string; + type: HermesJobEventType; + message: string; + data?: Record; + createdAt: string; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index b85d448..37a0b97 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -16,6 +16,15 @@ export type { } from './intervention.js'; export * from './messages.js'; export type { HermesMessage, LiveConversationEvent } from './messages.js'; +export type { + HermesContextScope, + HermesJob, + HermesJobEvent, + HermesJobEventType, + HermesJobInput, + HermesJobStatus, + HermesRiskLevel, +} from './hermes-job.js'; export type { PodGraph, PodGraphNode, diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 6840d11..0c49583 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -1,4 +1,5 @@ import type { Collision } from './collision.js'; +import type { HermesJobEvent } from './hermes-job.js'; import type { Intervention, InterventionStatus } from './intervention.js'; /** Topics multiplexed over the LiveKit data channel. */ @@ -10,6 +11,7 @@ export type DataMessage = | { type: 'HERMES_MESSAGE'; message: HermesMessage } | { type: 'VOICE_CUE'; text: string } | { type: 'LIVE_CONVERSATION_EVENT'; event: LiveConversationEvent } + | { type: 'HERMES_JOB_EVENT'; event: HermesJobEvent } | { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string } | { type: 'GIT_REPORT'; report: LocalGitReport } /** Any participant → the current test-audio owner: stop publishing the shared beat. */