d1c523c442
CI / check (pull_request) Successful in 1m41s
- Add inputTokens/outputTokens to HermesSessionJson type - Query input_tokens, output_tokens from sessions table in loadHermesSessionFromDb - Update test fixture schema with token columns - runPrompt now reports real token counts from Hermes state.db Refs #76, #68
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import type { Store } from "@ocas/core";
|
|
import type { Usage } from "@united-workforce/protocol";
|
|
import { createLogger } from "@united-workforce/util";
|
|
import {
|
|
type AgentContext,
|
|
type AgentRunResult,
|
|
buildContinuationPrompt,
|
|
buildRolePrompt,
|
|
createAgent,
|
|
} from "@united-workforce/util-agent";
|
|
|
|
import { HermesAcpClient } from "./acp-client.js";
|
|
import { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
|
import { loadHermesSession, storeHermesSessionDetail } from "./session-detail.js";
|
|
|
|
const log = createLogger({ sink: { kind: "stderr" } });
|
|
|
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
const parts: string[] = [];
|
|
|
|
if (ctx.outputFormatInstruction !== "") {
|
|
parts.push(ctx.outputFormatInstruction, "");
|
|
}
|
|
|
|
if (!ctx.isFirstVisit) {
|
|
// Re-entry: show only steps since last visit, meta only
|
|
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
|
return parts.join("\n");
|
|
}
|
|
|
|
// First visit: show initial context with content for recent steps
|
|
const roleDef = ctx.workflow.roles[ctx.role];
|
|
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
|
|
|
// Add history with content (last 2-3 steps within quota)
|
|
if (ctx.steps.length > 0) {
|
|
parts.push(
|
|
"",
|
|
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
|
includeContent: true,
|
|
quota: 32000, // Use THREAD_READ_DEFAULT_QUOTA equivalent
|
|
}),
|
|
);
|
|
} else {
|
|
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
|
}
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
async function storePromptResult(store: Store, sessionId: string): Promise<{ detailHash: string }> {
|
|
const session = await loadHermesSession(sessionId);
|
|
if (session === null) {
|
|
throw new Error(`Hermes session file not found: ${sessionId}`);
|
|
}
|
|
return storeHermesSessionDetail(store, session);
|
|
}
|
|
|
|
type PromptAttempt = {
|
|
useContinuation: boolean;
|
|
resumed: boolean;
|
|
};
|
|
|
|
async function prepareSession(
|
|
client: HermesAcpClient,
|
|
ctx: AgentContext,
|
|
cwd: string,
|
|
resumeDisabled: boolean,
|
|
): Promise<PromptAttempt> {
|
|
if (ctx.isFirstVisit || resumeDisabled) {
|
|
await client.connect(cwd);
|
|
return { useContinuation: false, resumed: false };
|
|
}
|
|
|
|
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role, ctx.storageRoot);
|
|
if (cachedSessionId === null) {
|
|
log("6RWK3N8Q", `no cached session for ${ctx.threadId}:${ctx.role}, starting new session`);
|
|
await client.connect(cwd);
|
|
return { useContinuation: false, resumed: false };
|
|
}
|
|
|
|
try {
|
|
await client.resume(cachedSessionId, cwd);
|
|
log("9MHT4V2P", `resumed hermes session ${cachedSessionId} for ${ctx.threadId}:${ctx.role}`);
|
|
return { useContinuation: true, resumed: true };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log("3XPN7K4W", `session resume failed, falling back to new session: ${message}`);
|
|
await client.close();
|
|
await client.connect(cwd);
|
|
return { useContinuation: false, resumed: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode.
|
|
*
|
|
* A single ACP client is shared across run() and continue() calls so that
|
|
* frontmatter retry loops keep the same Hermes session context. The client
|
|
* is closed once the agent process exits (via process.on("exit")).
|
|
*/
|
|
export function createHermesAgent(resumeDisabled: boolean): () => Promise<void> {
|
|
const client = new HermesAcpClient();
|
|
|
|
// Ensure cleanup regardless of how the process exits.
|
|
process.on("exit", () => {
|
|
void client.close();
|
|
});
|
|
|
|
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
|
|
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
|
|
const fullPrompt = buildHermesPrompt(effectiveCtx);
|
|
const startMs = Date.now();
|
|
const { text, sessionId } = await client.prompt(fullPrompt);
|
|
const durationSec = (Date.now() - startMs) / 1000;
|
|
const { detailHash } = await storePromptResult(ctx.store, sessionId);
|
|
|
|
if (!resumeDisabled) {
|
|
await setCachedSessionId(ctx.threadId, ctx.role, sessionId, ctx.storageRoot);
|
|
}
|
|
|
|
const session = await loadHermesSession(sessionId);
|
|
const turns =
|
|
session !== null ? session.messages.filter((m) => m.role === "assistant").length : 1;
|
|
|
|
const usage: Usage = {
|
|
turns,
|
|
inputTokens: session !== null ? session.inputTokens : 0,
|
|
outputTokens: session !== null ? session.outputTokens : 0,
|
|
duration: Math.round(durationSec),
|
|
};
|
|
|
|
return { output: text, detailHash, sessionId, assembledPrompt: fullPrompt, usage };
|
|
}
|
|
|
|
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
|
const cwd = process.cwd();
|
|
const attempt = await prepareSession(client, ctx, cwd, resumeDisabled);
|
|
|
|
try {
|
|
return await runPrompt(ctx, attempt.useContinuation);
|
|
} catch (error) {
|
|
if (!attempt.resumed) {
|
|
throw error;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log("8FQW2R6N", `continuation prompt failed, retrying with initial prompt: ${message}`);
|
|
await client.close();
|
|
await client.connect(cwd);
|
|
return runPrompt(ctx, false);
|
|
}
|
|
}
|
|
|
|
async function continueHermes(
|
|
_sessionId: string,
|
|
message: string,
|
|
store: Store,
|
|
): Promise<AgentRunResult> {
|
|
// Client is already connected from runHermes — same ACP session,
|
|
// so the agent sees the full conversation history (crucial for retries).
|
|
const startMs = Date.now();
|
|
const { text, sessionId } = await client.prompt(message);
|
|
const durationSec = (Date.now() - startMs) / 1000;
|
|
const { detailHash } = await storePromptResult(store, sessionId);
|
|
|
|
const usage: Usage = {
|
|
turns: 1,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
duration: Math.round(durationSec),
|
|
};
|
|
|
|
return { output: text, detailHash, sessionId, assembledPrompt: "", usage };
|
|
}
|
|
|
|
const agentMain = createAgent({
|
|
name: "hermes",
|
|
run: runHermes,
|
|
continue: continueHermes,
|
|
});
|
|
|
|
// Wrap to ensure ACP client is closed after agent completes,
|
|
// so the hermes subprocess exits and bun can terminate.
|
|
return async () => {
|
|
try {
|
|
await agentMain();
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
};
|
|
}
|