feat(hermes): add async Gemini handoff jobs

This commit is contained in:
Yahya Alhinai
2026-06-28 08:33:52 +00:00
parent 5ac66c0a02
commit 277cee8a7e
12 changed files with 883 additions and 25 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules node_modules
.venv .venv
**/.venv **/.venv
.pytest_cache
**/.pytest_cache
dist dist
build build
pnpm-lock.yaml pnpm-lock.yaml
+96 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import time
from typing import Any from typing import Any
from urllib import error, request 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, 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. 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. 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.""" 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): 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) super().__init__(instructions=INSTRUCTIONS)
self.pod_id = pod_id self.pod_id = pod_id
self.identity = identity self.identity = identity
self.session_id = session_id 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() @function_tool()
async def get_active_pod_context(self, context: RunContext) -> str: 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]) snippets.append(haystack[max(0, idx - 400) : idx + 1200])
return "\n---\n".join(snippets)[:8000] or haystack[:6000] 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() server = AgentServer()
@@ -139,7 +217,12 @@ async def entrypoint(ctx: agents.JobContext):
voice=VOICE, 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): def on_data_received(*args: Any):
payload = args[0] if args else b"" payload = args[0] if args else b""
@@ -151,18 +234,27 @@ async def entrypoint(ctx: agents.JobContext):
msg = json.loads(raw) msg = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
return return
if msg.get("type") != "LIVE_CONVERSATION_EVENT": msg_type = msg.get("type")
return 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 {} event = msg.get("event") or {}
summary = str(event.get("summary") or "").strip() summary = str(event.get("summary") or "").strip()
else:
return
if not summary: if not summary:
return return
async def interrupt_and_say() -> None: async def interrupt_and_say() -> None:
try: try:
if msg_type == "LIVE_CONVERSATION_EVENT":
await session.interrupt(force=True) await session.interrupt(force=True)
except Exception as exc: except Exception as exc:
logger.warning("interrupt failed: %s", exc) logger.warning("interrupt failed: %s", exc)
if msg_type == "LIVE_CONVERSATION_EVENT" or agent.should_speak_progress(event):
await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True) await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True)
asyncio.create_task(interrupt_and_say()) asyncio.create_task(interrupt_and_say())
@@ -1,4 +1,4 @@
from agent import parse_metadata from agent import PodManLiveAgent, parse_metadata
def test_parse_metadata_accepts_valid_json(): 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(): def test_parse_metadata_handles_bad_json():
assert parse_metadata("not 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
+349
View File
@@ -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<string, AbortController>();
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<HermesJob>('hermes_jobs');
}
async function hermesJobEvents() {
return (await getDb()).collection<HermesJobEvent>('hermes_job_events');
}
export async function ensureHermesJobIndexes(): Promise<void> {
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<HermesJobInput>): Promise<HermesJob> {
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<HermesJob | null> {
return (await hermesJobs()).findOne({ id: jobId }, { projection: { _id: 0 } });
}
export async function getActiveHermesJobForSession(sessionId: string): Promise<HermesJob | null> {
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<HermesJob | null> {
return (await hermesJobs()).findOne(
{ sessionId },
{ projection: { _id: 0 }, sort: { updatedAt: -1 } },
);
}
export async function listHermesJobEvents(jobId: string, limit = 40): Promise<HermesJobEvent[]> {
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<string, unknown>,
): Promise<HermesJobEvent> {
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<HermesJob | null> {
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<HermesJob> = {}) {
await (
await hermesJobs()
).updateOne({ id: jobId }, { $set: { status, updatedAt: now(), ...patch } });
}
async function publishHermesJobEvent(roomName: string, event: HermesJobEvent): Promise<void> {
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<string> {
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<void> {
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);
}
}
+9
View File
@@ -114,6 +114,15 @@ export async function initMemory(): Promise<void> {
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })], ['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })], ['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 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) { for (const [name, make] of indexes) {
try { try {
+145 -6
View File
@@ -39,7 +39,22 @@ import {
getLiveConversationContext, getLiveConversationContext,
recordLiveConversationNote, recordLiveConversationNote,
} from './live-conversation/context.js'; } 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(); const app = express();
app.use(cors()); app.use(cors());
@@ -47,9 +62,7 @@ app.use(express.json());
app.get('/health', (_req, res) => res.json({ ok: true })); app.get('/health', (_req, res) => res.json({ ok: true }));
function stringArray(value: unknown): string[] { function stringArray(value: unknown): string[] {
return Array.isArray(value) return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
? value.map((item) => String(item).trim()).filter(Boolean)
: [];
} }
function suggestedAction(value: unknown): SuggestedActionKind { function suggestedAction(value: unknown): SuggestedActionKind {
@@ -58,6 +71,20 @@ function suggestedAction(value: unknown): SuggestedActionKind {
: 'ping_teammate'; : '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. // Mint a LiveKit token for an engineer joining a pod.
app.post('/api/token', async (req, res) => { app.post('/api/token', async (req, res) => {
const { room, identity, name, githubLogin } = req.body ?? {}; 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) }); 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 { function requireInternalAgent(req: express.Request, res: express.Response): boolean {
const expected = env.INTERNAL_AGENT_TOKEN; const expected = env.INTERNAL_AGENT_TOKEN;
if (!expected) { 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<string>();
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) => { app.post('/api/pods/:id/hermes/notify', async (req, res) => {
const podId = req.params.id; const podId = req.params.id;
const pod = await getPod(podId); 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 now = new Date().toISOString();
const engineers = stringArray(body.engineers); const engineers = stringArray(body.engineers);
const recipients = engineers.length ? engineers : pod.members.slice(0, 2); 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 urgent = body.urgency === 'urgent' || body.severity === 'critical';
const collision: Collision = { 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, podId,
file, file,
symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined, symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined,
+93 -3
View File
@@ -28,6 +28,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client'; import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client';
import type { import type {
HermesJob,
HermesJobEvent,
MemberWorkHistory, MemberWorkHistory,
MemberWorkHistoryEvent, MemberWorkHistoryEvent,
MemberWorkHistoryFile, MemberWorkHistoryFile,
@@ -38,6 +40,8 @@ import type {
} from '@podman/shared'; } from '@podman/shared';
import { useBeat } from '../livekit/useBeat.js'; import { useBeat } from '../livekit/useBeat.js';
import { import {
abortLiveConversationHermesJob,
getLiveConversationHermesJob,
getMemberWorkHistory, getMemberWorkHistory,
startLiveConversation, startLiveConversation,
stopLiveConversation, stopLiveConversation,
@@ -147,12 +151,15 @@ export function PodView({
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null); const [historyError, setHistoryError] = useState<string | null>(null);
const [conversationRoom, setConversationRoom] = useState<Room | null>(null); const [conversationRoom, setConversationRoom] = useState<Room | null>(null);
const [conversationSession, setConversationSession] = const [conversationSession, setConversationSession] = useState<LiveConversationSession | null>(
useState<LiveConversationSession | null>(null); null,
);
const [conversationState, setConversationState] = useState< const [conversationState, setConversationState] = useState<
'idle' | 'connecting' | 'listening' | 'speaking' | 'interrupted' | 'error' 'idle' | 'connecting' | 'listening' | 'speaking' | 'interrupted' | 'error'
>('idle'); >('idle');
const [conversationNote, setConversationNote] = useState<string | null>(null); const [conversationNote, setConversationNote] = useState<string | null>(null);
const [hermesJob, setHermesJob] = useState<HermesJob | null>(null);
const [hermesJobEvents, setHermesJobEvents] = useState<HermesJobEvent[]>([]);
const [leftStreamOpen, setLeftStreamOpen] = useState(() => const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
readStoredBool('podman.myStreamOpen', true), readStoredBool('podman.myStreamOpen', true),
); );
@@ -259,7 +266,9 @@ export function PodView({
element.autoplay = true; element.autoplay = true;
conversationAudioElementsRef.current.set(key, element); conversationAudioElementsRef.current.set(key, element);
audioRef.current.appendChild(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 removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => {
const key = `conversation:${pub?.trackSid || track.sid || track.mediaStreamTrack.id}`; const key = `conversation:${pub?.trackSid || track.sid || track.mediaStreamTrack.id}`;
@@ -295,6 +304,13 @@ export function PodView({
setConversationState(msg.event?.interrupt ? 'interrupted' : 'listening'); setConversationState(msg.event?.interrupt ? 'interrupted' : 'listening');
if (msg.event?.summary) setConversationNote(msg.event.summary); 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 { } catch {
// Ignore non-PodMan private-room data. // Ignore non-PodMan private-room data.
} }
@@ -320,6 +336,31 @@ export function PodView({
}; };
}, [conversationRoom]); }, [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(() => { useEffect(() => {
if (!historyMember) { if (!historyMember) {
setHistory(null); setHistory(null);
@@ -426,6 +467,8 @@ export function PodView({
setConversationRoom(null); setConversationRoom(null);
setConversationSession(null); setConversationSession(null);
setConversationState('idle'); setConversationState('idle');
setHermesJob(null);
setHermesJobEvents([]);
try { try {
await endingRoom.localParticipant.setMicrophoneEnabled(false).catch(() => {}); await endingRoom.localParticipant.setMicrophoneEnabled(false).catch(() => {});
await endingRoom.disconnect(); await endingRoom.disconnect();
@@ -457,10 +500,24 @@ export function PodView({
setConversationState('error'); setConversationState('error');
setConversationRoom(null); setConversationRoom(null);
setConversationSession(null); setConversationSession(null);
setHermesJob(null);
setHermesJobEvents([]);
setNote(`Live conversation failed: ${(e as Error).message}`); 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() { async function toggleScreen() {
primeSpeech(); // unlock browser voice from this gesture too primeSpeech(); // unlock browser voice from this gesture too
if (!room) return; if (!room) return;
@@ -813,7 +870,40 @@ export function PodView({
label="Context" label="Context"
value={conversationRoom ? 'synced on demand' : 'waiting'} value={conversationRoom ? 'synced on demand' : 'waiting'}
/> />
<StatusLine
label="Hermes"
value={hermesJob ? hermesJob.status.replaceAll('_', ' ') : 'idle'}
/>
</div> </div>
{hermesJob &&
['queued', 'running', 'waiting_for_confirmation', 'aborting'].includes(
hermesJob.status,
) && (
<Button
variant="outline"
onClick={() => void stopHermesJob()}
data-testid="hermes-job-stop"
>
<XIcon data-icon="inline-start" />
Stop Hermes Job
</Button>
)}
{hermesJobEvents.length > 0 && (
<div className="rounded-lg border border-dashed p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<WorkflowIcon className="size-3.5" />
Hermes progress
</div>
<div className="flex flex-col gap-2">
{hermesJobEvents.slice(-3).map((event) => (
<div key={event.id} className="text-sm leading-5">
<span className="font-medium">{event.type.replaceAll('_', ' ')}</span>
<span className="text-muted-foreground"> - {event.message}</span>
</div>
))}
</div>
</div>
)}
{conversationNote && ( {conversationNote && (
<div className="rounded-lg border border-dashed p-3"> <div className="rounded-lg border border-dashed p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground"> <div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
+29
View File
@@ -1,5 +1,7 @@
import type { import type {
InterventionOutcome, InterventionOutcome,
HermesJob,
HermesJobEvent,
MemberWorkHistory, MemberWorkHistory,
Pod, Pod,
PodActivityEvent, 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}`); 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<Pod> { export async function removeMember(id: string, name: string): Promise<Pod> {
return json( return json(
await fetch( await fetch(
+50 -5
View File
@@ -22,6 +22,7 @@ const env = {
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github', GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman', GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
MONGODB_URI: mongoUri, MONGODB_URI: mongoUri,
INTERNAL_AGENT_TOKEN: process.env.INTERNAL_AGENT_TOKEN ?? 'verify-internal-agent-token',
}; };
function fail(message) { function fail(message) {
@@ -115,14 +116,11 @@ async function verifyApi() {
} }
const liveConversation = await json( const liveConversation = await json(
await doFetch( await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/live-conversation/start`, {
`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/live-conversation/start`,
{
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identity: 'Alice', displayName: 'Alice' }), body: JSON.stringify({ identity: 'Alice', displayName: 'Alice' }),
}, }),
),
); );
if ( if (
typeof liveConversation.token !== 'string' || 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 json(
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }), await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
); );
@@ -320,6 +364,7 @@ try {
'pod-crud', 'pod-crud',
'hermes-notify', 'hermes-notify',
'live-conversation-session', 'live-conversation-session',
'hermes-job-lifecycle',
'collision', 'collision',
'memory-recall', 'memory-recall',
'graph', 'graph',
+79
View File
@@ -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<string, unknown>;
createdAt: string;
}
+9
View File
@@ -16,6 +16,15 @@ export type {
} from './intervention.js'; } from './intervention.js';
export * from './messages.js'; export * from './messages.js';
export type { HermesMessage, LiveConversationEvent } 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 { export type {
PodGraph, PodGraph,
PodGraphNode, PodGraphNode,
+2
View File
@@ -1,4 +1,5 @@
import type { Collision } from './collision.js'; import type { Collision } from './collision.js';
import type { HermesJobEvent } from './hermes-job.js';
import type { Intervention, InterventionStatus } from './intervention.js'; import type { Intervention, InterventionStatus } from './intervention.js';
/** Topics multiplexed over the LiveKit data channel. */ /** Topics multiplexed over the LiveKit data channel. */
@@ -10,6 +11,7 @@ export type DataMessage =
| { type: 'HERMES_MESSAGE'; message: HermesMessage } | { type: 'HERMES_MESSAGE'; message: HermesMessage }
| { type: 'VOICE_CUE'; text: string } | { type: 'VOICE_CUE'; text: string }
| { type: 'LIVE_CONVERSATION_EVENT'; event: LiveConversationEvent } | { type: 'LIVE_CONVERSATION_EVENT'; event: LiveConversationEvent }
| { type: 'HERMES_JOB_EVENT'; event: HermesJobEvent }
| { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string } | { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string }
| { type: 'GIT_REPORT'; report: LocalGitReport } | { type: 'GIT_REPORT'; report: LocalGitReport }
/** Any participant → the current test-audio owner: stop publishing the shared beat. */ /** Any participant → the current test-audio owner: stop publishing the shared beat. */