feat(hermes): add async Gemini handoff jobs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user