fix(plugins): re-export probe symbols + declare plugin deps in dashboard
- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` / `probeOpenClawBinary` and their status types so the dashboard's `runtime-provider-probes.ts` façade can import them via the public package entry instead of deep paths. - Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`, `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so pnpm symlinks them into `packages/dashboard/node_modules/`. Without these, the new probe imports failed with "Cannot find module" during `pnpm typecheck`. This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are in the in-flight Hermes plugin rewrite (runtime-adapter still imports from a deleted `./pi-module.js`; the new `index.ts` calls a factory with the wrong arg type) and should be resolved by the same change set that landed the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// ── Mock node:child_process before imports that use it ─────────────────────
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
|
||||
import {
|
||||
buildHermesArgs,
|
||||
invokeHermesCli,
|
||||
listHermesProfiles,
|
||||
parseHermesOutput,
|
||||
resolveCliSettings,
|
||||
} from "../cli-spawn.js";
|
||||
import type { HermesCliSettings } from "../cli-spawn.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultSettings(overrides: Partial<HermesCliSettings> = {}): HermesCliSettings {
|
||||
return {
|
||||
binaryPath: "hermes",
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 5_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fake ChildProcess-like EventEmitter with controllable
|
||||
* stdout/stderr streams and a kill spy.
|
||||
*/
|
||||
function makeFakeChild(): {
|
||||
child: ChildProcess;
|
||||
emitStdout: (data: string) => void;
|
||||
emitStderr: (data: string) => void;
|
||||
emitError: (err: Error) => void;
|
||||
emitClose: (code: number | null) => void;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const main = new EventEmitter() as ChildProcess;
|
||||
const stdoutEmitter = new EventEmitter();
|
||||
const stderrEmitter = new EventEmitter();
|
||||
(main as any).stdout = stdoutEmitter;
|
||||
(main as any).stderr = stderrEmitter;
|
||||
const kill = vi.fn().mockReturnValue(true);
|
||||
(main as any).kill = kill;
|
||||
|
||||
return {
|
||||
child: main,
|
||||
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
|
||||
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
|
||||
emitError: (err) => main.emit("error", err),
|
||||
emitClose: (code) => main.emit("close", code),
|
||||
kill,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a stdout string that hermes would produce for a given body + session id. */
|
||||
function fakeHermesOutput(body: string, sessionId = "20260427_120000_abcd12"): string {
|
||||
return `${body}\nsession_id: ${sessionId}\n`;
|
||||
}
|
||||
|
||||
// ── resolveCliSettings ──────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveCliSettings", () => {
|
||||
const origEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...origEnv };
|
||||
});
|
||||
|
||||
it("returns defaults when settings and env are empty", () => {
|
||||
delete process.env.HERMES_BIN;
|
||||
delete process.env.HERMES_MODEL_ID;
|
||||
delete process.env.HERMES_PROVIDER;
|
||||
delete process.env.HERMES_MAX_TURNS;
|
||||
delete process.env.HERMES_YOLO;
|
||||
delete process.env.HERMES_CLI_TIMEOUT_MS;
|
||||
|
||||
expect(resolveCliSettings()).toEqual({
|
||||
binaryPath: "hermes",
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers settings over env vars", () => {
|
||||
process.env.HERMES_BIN = "/usr/bin/hermes";
|
||||
process.env.HERMES_MODEL_ID = "env-model";
|
||||
process.env.HERMES_PROVIDER = "openrouter";
|
||||
|
||||
expect(
|
||||
resolveCliSettings({
|
||||
binaryPath: "/custom/hermes",
|
||||
model: "gpt-4o",
|
||||
provider: "openai-codex",
|
||||
maxTurns: 5,
|
||||
yolo: true,
|
||||
cliTimeoutMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
binaryPath: "/custom/hermes",
|
||||
model: "gpt-4o",
|
||||
provider: "openai-codex",
|
||||
maxTurns: 5,
|
||||
yolo: true,
|
||||
cliTimeoutMs: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to env vars when settings omit values", () => {
|
||||
process.env.HERMES_BIN = "/env/hermes";
|
||||
process.env.HERMES_MODEL_ID = "env-model";
|
||||
process.env.HERMES_PROVIDER = "gemini";
|
||||
process.env.HERMES_MAX_TURNS = "7";
|
||||
process.env.HERMES_YOLO = "true";
|
||||
process.env.HERMES_CLI_TIMEOUT_MS = "60000";
|
||||
|
||||
expect(resolveCliSettings({})).toEqual({
|
||||
binaryPath: "/env/hermes",
|
||||
model: "env-model",
|
||||
provider: "gemini",
|
||||
maxTurns: 7,
|
||||
yolo: true,
|
||||
cliTimeoutMs: 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildHermesArgs ────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildHermesArgs", () => {
|
||||
it("builds minimal args without resume or optional flags", () => {
|
||||
const args = buildHermesArgs("hello world", defaultSettings());
|
||||
expect(args).toEqual(["chat", "-q", "hello world", "-Q", "--source", "tool", "--max-turns", "12"]);
|
||||
});
|
||||
|
||||
it("adds --resume when sessionId provided", () => {
|
||||
const args = buildHermesArgs("hello", defaultSettings(), "20260427_120000_abcd12");
|
||||
expect(args).toContain("--resume");
|
||||
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
|
||||
});
|
||||
|
||||
it("adds -m and --provider when configured", () => {
|
||||
const args = buildHermesArgs(
|
||||
"hello",
|
||||
defaultSettings({ model: "claude-sonnet-4-5", provider: "anthropic" }),
|
||||
);
|
||||
expect(args).toContain("-m");
|
||||
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
|
||||
expect(args).toContain("--provider");
|
||||
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("adds --yolo when yolo is true", () => {
|
||||
const args = buildHermesArgs("hello", defaultSettings({ yolo: true }));
|
||||
expect(args).toContain("--yolo");
|
||||
});
|
||||
|
||||
it("does not add --yolo when yolo is false", () => {
|
||||
const args = buildHermesArgs("hello", defaultSettings({ yolo: false }));
|
||||
expect(args).not.toContain("--yolo");
|
||||
});
|
||||
|
||||
it("uses configured maxTurns", () => {
|
||||
const args = buildHermesArgs("hello", defaultSettings({ maxTurns: 5 }));
|
||||
expect(args[args.indexOf("--max-turns") + 1]).toBe("5");
|
||||
});
|
||||
|
||||
it("first call has no --resume arg", () => {
|
||||
const args = buildHermesArgs("hi", defaultSettings());
|
||||
expect(args).not.toContain("--resume");
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseHermesOutput ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parseHermesOutput", () => {
|
||||
it("extracts session_id and body from clean output", () => {
|
||||
const stdout = "This is the response\nsession_id: 20260427_120000_abcd12\n";
|
||||
const result = parseHermesOutput(stdout, "");
|
||||
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
||||
expect(result.body).toBe("This is the response");
|
||||
});
|
||||
|
||||
it("strips ANSI escape codes from body", () => {
|
||||
const stdout = "\x1b[32mGreen text\x1b[0m\nsession_id: 20260427_120000_abcd12\n";
|
||||
const result = parseHermesOutput(stdout, "");
|
||||
expect(result.body).toBe("Green text");
|
||||
expect(result.body).not.toContain("\x1b");
|
||||
});
|
||||
|
||||
it("strips preamble chrome lines", () => {
|
||||
const stdout = [
|
||||
"╭─ Hermes ─╮",
|
||||
"↻ Resumed session foo",
|
||||
" ┊ preparing memory…",
|
||||
"Query: what is 2+2?",
|
||||
"4",
|
||||
"╰─────────╯",
|
||||
"session_id: 20260427_120000_abcd12",
|
||||
].join("\n") + "\n";
|
||||
|
||||
const result = parseHermesOutput(stdout, "");
|
||||
expect(result.body).toBe("4");
|
||||
expect(result.body).not.toContain("Hermes");
|
||||
expect(result.body).not.toContain("Resumed");
|
||||
expect(result.body).not.toContain("preparing");
|
||||
expect(result.body).not.toContain("Query:");
|
||||
expect(result.body).not.toContain("╭");
|
||||
expect(result.body).not.toContain("╰");
|
||||
});
|
||||
|
||||
it("normalizes CRLF to LF", () => {
|
||||
const stdout = "line1\r\nline2\r\nsession_id: 20260427_120000_abcd12\r\n";
|
||||
const result = parseHermesOutput(stdout, "");
|
||||
expect(result.body).toBe("line1\nline2");
|
||||
});
|
||||
|
||||
it("throws when session_id line is missing", () => {
|
||||
expect(() => parseHermesOutput("some output without id", "stderr text")).toThrow(
|
||||
/missing session_id/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── invokeHermesCli ────────────────────────────────────────────────────────
|
||||
|
||||
describe("invokeHermesCli", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("first call passes correct args and no --resume", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const PROMPT = "what is typescript?";
|
||||
const promise = invokeHermesCli(PROMPT, defaultSettings());
|
||||
|
||||
emitStdout(fakeHermesOutput("TypeScript is a language."));
|
||||
emitClose(0);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledOnce();
|
||||
const [bin, args] = mockSpawn.mock.calls[0]!;
|
||||
expect(bin).toBe("hermes");
|
||||
expect(args).toEqual(["chat", "-q", PROMPT, "-Q", "--source", "tool", "--max-turns", "12"]);
|
||||
expect(result.body).toBe("TypeScript is a language.");
|
||||
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
||||
});
|
||||
|
||||
it("subsequent call with sessionId passes --resume", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = invokeHermesCli("hello again", defaultSettings(), "20260427_120000_abcd12");
|
||||
|
||||
emitStdout(fakeHermesOutput("Hi there!"));
|
||||
emitClose(0);
|
||||
|
||||
await promise;
|
||||
|
||||
const args: string[] = mockSpawn.mock.calls[0]![1];
|
||||
expect(args).toContain("--resume");
|
||||
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
|
||||
});
|
||||
|
||||
it("captures session id from stdout", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = invokeHermesCli("hi", defaultSettings());
|
||||
emitStdout("Hello!\nsession_id: 20260427_120000_abcd12\n");
|
||||
emitClose(0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
||||
});
|
||||
|
||||
it("rejects on non-zero exit code and surfaces stderr", async () => {
|
||||
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = invokeHermesCli("hi", defaultSettings());
|
||||
emitStdout("partial output");
|
||||
emitStderr("fatal error from hermes");
|
||||
emitClose(1);
|
||||
|
||||
await expect(promise).rejects.toThrow(/exited with code 1/);
|
||||
});
|
||||
|
||||
it("rejects when session_id is missing from stdout on exit 0", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = invokeHermesCli("hi", defaultSettings());
|
||||
emitStdout("Some output without session id line");
|
||||
emitClose(0);
|
||||
|
||||
await expect(promise).rejects.toThrow(/missing session_id/);
|
||||
});
|
||||
|
||||
it("includes -m, --provider, --max-turns, --yolo when settings configured", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const settings = defaultSettings({
|
||||
model: "claude-sonnet-4-5",
|
||||
provider: "anthropic",
|
||||
maxTurns: 20,
|
||||
yolo: true,
|
||||
});
|
||||
|
||||
const promise = invokeHermesCli("test", settings);
|
||||
emitStdout(fakeHermesOutput("ok"));
|
||||
emitClose(0);
|
||||
|
||||
await promise;
|
||||
|
||||
const args: string[] = mockSpawn.mock.calls[0]![1];
|
||||
expect(args).toContain("-m");
|
||||
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
|
||||
expect(args).toContain("--provider");
|
||||
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
|
||||
expect(args[args.indexOf("--max-turns") + 1]).toBe("20");
|
||||
expect(args).toContain("--yolo");
|
||||
});
|
||||
|
||||
it("rejects on ENOENT with not-found message", async () => {
|
||||
const { child, emitError } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = invokeHermesCli("hi", defaultSettings());
|
||||
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
|
||||
emitError(err);
|
||||
|
||||
await expect(promise).rejects.toThrow(/binary not found/);
|
||||
});
|
||||
|
||||
it("AbortSignal triggers child.kill and rejects", async () => {
|
||||
const { child, kill, emitStdout } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const ac = new AbortController();
|
||||
const promise = invokeHermesCli("hi", defaultSettings(), undefined, ac.signal);
|
||||
|
||||
// Abort before any output arrives.
|
||||
ac.abort();
|
||||
emitStdout("irrelevant");
|
||||
|
||||
await expect(promise).rejects.toThrow(/aborted/);
|
||||
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
});
|
||||
|
||||
// ── listHermesProfiles ─────────────────────────────────────────────────────
|
||||
|
||||
describe("listHermesProfiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const SAMPLE_OUTPUT = [
|
||||
" Profile Model Gateway Alias",
|
||||
" ─────────────── ───────────────────────── ─────────── ────────────",
|
||||
" ◆default MiniMax-M2.7 stopped —",
|
||||
].join("\n") + "\n";
|
||||
|
||||
it("happy path: parses single default profile", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = listHermesProfiles();
|
||||
emitStdout(SAMPLE_OUTPUT);
|
||||
emitClose(0);
|
||||
|
||||
const profiles = await promise;
|
||||
expect(profiles).toHaveLength(1);
|
||||
expect(profiles[0]).toMatchObject({
|
||||
name: "default",
|
||||
model: "MiniMax-M2.7",
|
||||
gateway: "stopped",
|
||||
alias: undefined,
|
||||
isDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("happy path: multi-profile ordered correctly", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const multiOutput = [
|
||||
" Profile Model Gateway Alias",
|
||||
" ─────────────── ───────────────────────── ─────────── ────────────",
|
||||
" ◆default MiniMax-M2.7 stopped —",
|
||||
" work claude-sonnet-4-5 running work-hermes",
|
||||
].join("\n") + "\n";
|
||||
|
||||
const promise = listHermesProfiles();
|
||||
emitStdout(multiOutput);
|
||||
emitClose(0);
|
||||
|
||||
const profiles = await promise;
|
||||
expect(profiles).toHaveLength(2);
|
||||
expect(profiles[0]!.name).toBe("default");
|
||||
expect(profiles[0]!.isDefault).toBe(true);
|
||||
expect(profiles[1]!.name).toBe("work");
|
||||
expect(profiles[1]!.isDefault).toBe(false);
|
||||
expect(profiles[1]!.model).toBe("claude-sonnet-4-5");
|
||||
expect(profiles[1]!.alias).toBe("work-hermes");
|
||||
});
|
||||
|
||||
it("ENOENT rejects with binary-not-found error", async () => {
|
||||
const { child, emitError } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = listHermesProfiles({ binaryPath: "/no/such/hermes" });
|
||||
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
|
||||
emitError(err);
|
||||
|
||||
await expect(promise).rejects.toThrow(/hermes profile list failed.*binary not found/);
|
||||
});
|
||||
|
||||
it("non-zero exit rejects with exit code error", async () => {
|
||||
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = listHermesProfiles();
|
||||
emitStdout("");
|
||||
emitStderr("unknown subcommand");
|
||||
emitClose(2);
|
||||
|
||||
await expect(promise).rejects.toThrow(/hermes profile list failed.*exited with code 2/);
|
||||
});
|
||||
|
||||
it("spawns with 'profile list' args", async () => {
|
||||
const { child, emitStdout, emitClose } = makeFakeChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const promise = listHermesProfiles({ binaryPath: "/custom/hermes" });
|
||||
emitStdout(SAMPLE_OUTPUT);
|
||||
emitClose(0);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledOnce();
|
||||
const [bin, args] = mockSpawn.mock.calls[0]!;
|
||||
expect(bin).toBe("/custom/hermes");
|
||||
expect(args).toEqual(["profile", "list"]);
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createStreamSession,
|
||||
describeStreamModel,
|
||||
resolveModelConfig,
|
||||
streamPrompt,
|
||||
} from "../pi-module.js";
|
||||
|
||||
const { mockGetModel, mockStreamSimple } = vi.hoisted(() => ({
|
||||
mockGetModel: vi.fn(),
|
||||
mockStreamSimple: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
getModel: mockGetModel,
|
||||
streamSimple: mockStreamSimple,
|
||||
}));
|
||||
|
||||
function createFakeStream(events: unknown[], finalMessage: unknown) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
},
|
||||
result: vi.fn().mockResolvedValue(finalMessage),
|
||||
};
|
||||
}
|
||||
|
||||
describe("hermes pi-ai stream client", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.HERMES_PROVIDER;
|
||||
delete process.env.HERMES_MODEL_ID;
|
||||
delete process.env.HERMES_API_KEY;
|
||||
delete process.env.HERMES_THINKING_LEVEL;
|
||||
|
||||
mockGetModel.mockReturnValue({ provider: "anthropic", id: "claude-sonnet-4-5" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("resolveModelConfig prefers settings over env and env over defaults", () => {
|
||||
process.env.HERMES_PROVIDER = "openai";
|
||||
process.env.HERMES_MODEL_ID = "gpt-5";
|
||||
process.env.HERMES_API_KEY = "env-key";
|
||||
process.env.HERMES_THINKING_LEVEL = "medium";
|
||||
|
||||
expect(resolveModelConfig()).toEqual({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "env-key",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveModelConfig({ provider: "anthropic", modelId: "claude", apiKey: "plugin-key", thinkingLevel: "high" }),
|
||||
).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
apiKey: "plugin-key",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
|
||||
delete process.env.HERMES_PROVIDER;
|
||||
delete process.env.HERMES_MODEL_ID;
|
||||
delete process.env.HERMES_API_KEY;
|
||||
delete process.env.HERMES_THINKING_LEVEL;
|
||||
|
||||
expect(resolveModelConfig()).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
apiKey: undefined,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("createStreamSession resolves model and initializes session state", () => {
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const session = createStreamSession({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
apiKey: "key",
|
||||
thinkingLevel: "high",
|
||||
systemPrompt: "You are Hermes",
|
||||
callbacks: { onText, onThinking, onToolStart, onToolEnd },
|
||||
});
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
|
||||
expect(session.model).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5" });
|
||||
expect(session.systemPrompt).toBe("You are Hermes");
|
||||
expect(session.messages).toEqual([]);
|
||||
expect(session.apiKey).toBe("key");
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
expect(session.callbacks).toEqual({ onText, onThinking, onToolStart, onToolEnd });
|
||||
expect(session.lastModelDescription).toBe("anthropic/claude-sonnet-4-5");
|
||||
expect(session.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
|
||||
const second = createStreamSession({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
systemPrompt: "You are Hermes",
|
||||
});
|
||||
expect(second.sessionId).not.toBe(session.sessionId);
|
||||
});
|
||||
|
||||
it("streamPrompt streams deltas, handles tool calls, stores usage, and appends assistant text only", async () => {
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const session = createStreamSession({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
apiKey: "api-key",
|
||||
thinkingLevel: "medium",
|
||||
systemPrompt: "system",
|
||||
callbacks: { onText, onThinking, onToolStart, onToolEnd },
|
||||
});
|
||||
session.messages.push({ role: "user", content: "hello" });
|
||||
|
||||
const doneMessage = {
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "thinking", thinking: "internal" },
|
||||
{ type: "toolCall", id: "t1", name: "bash", arguments: { cmd: "ls" } },
|
||||
{ type: "text", text: " world" },
|
||||
],
|
||||
usage: { input: 1, output: 2 },
|
||||
};
|
||||
|
||||
mockStreamSimple.mockReturnValue(
|
||||
createFakeStream(
|
||||
[
|
||||
{ type: "text_delta", delta: "Hello" },
|
||||
{ type: "thinking_delta", delta: "thinking" },
|
||||
{ type: "toolcall_end", toolCall: { name: "bash", arguments: { cmd: "ls" } } },
|
||||
{ type: "text_delta", delta: " world" },
|
||||
{ type: "done", message: doneMessage },
|
||||
],
|
||||
doneMessage,
|
||||
),
|
||||
);
|
||||
|
||||
await streamPrompt(session, { role: "user", content: "ignored" } as any);
|
||||
|
||||
expect(mockStreamSimple).toHaveBeenCalledWith(
|
||||
session.model,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
apiKey: "api-key",
|
||||
reasoning: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(onText).toHaveBeenNthCalledWith(1, "Hello");
|
||||
expect(onText).toHaveBeenNthCalledWith(2, " world");
|
||||
expect(onThinking).toHaveBeenCalledWith("thinking");
|
||||
expect(onToolStart).toHaveBeenCalledWith("bash", { cmd: "ls" });
|
||||
expect(onToolEnd).toHaveBeenCalledWith("bash", false, { cmd: "ls" });
|
||||
expect(session.usage).toEqual({ input: 1, output: 2 });
|
||||
expect(session.messages).toEqual([
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "Hello world" },
|
||||
]);
|
||||
expect(describeStreamModel(session)).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("streamPrompt omits optional apiKey/reasoning when unset", async () => {
|
||||
const session = createStreamSession({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
session.messages.push({ role: "user", content: "hello" });
|
||||
|
||||
const doneMessage = { content: [{ type: "text", text: "ok" }], usage: { input: 1, output: 1 } };
|
||||
mockStreamSimple.mockReturnValue(createFakeStream([{ type: "done", message: doneMessage }], doneMessage));
|
||||
|
||||
await streamPrompt(session, { role: "user", content: "ignored" } as any);
|
||||
|
||||
expect(mockStreamSimple).toHaveBeenCalledWith(
|
||||
session.model,
|
||||
{ systemPrompt: "system", messages: [{ role: "user", content: "hello" }] },
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
});
|
||||
|
||||
it("streamPrompt throws on error event", async () => {
|
||||
const session = createStreamSession({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
|
||||
const errorMessage = {
|
||||
type: "error",
|
||||
error: {
|
||||
errorMessage: "boom",
|
||||
},
|
||||
};
|
||||
mockStreamSimple.mockReturnValue(createFakeStream([errorMessage], { content: [], usage: {} }));
|
||||
|
||||
await expect(streamPrompt(session, { role: "user", content: "ignored" } as any)).rejects.toThrow("boom");
|
||||
expect(session.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveModelConfig } = vi.hoisted(() => ({
|
||||
mockResolveModelConfig: vi.fn().mockReturnValue({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
apiKey: undefined,
|
||||
thinkingLevel: undefined,
|
||||
const { mockResolveCli } = vi.hoisted(() => ({
|
||||
mockResolveCli: vi.fn().mockReturnValue({
|
||||
binaryPath: "hermes",
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
resolveModelConfig: mockResolveModelConfig,
|
||||
}));
|
||||
vi.mock("../cli-spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
|
||||
"../cli-spawn.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveCliSettings: mockResolveCli,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin, { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID } from "../index.js";
|
||||
import plugin, {
|
||||
hermesRuntimeMetadata,
|
||||
hermesRuntimeFactory,
|
||||
HERMES_RUNTIME_ID,
|
||||
} from "../index.js";
|
||||
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
function createMockContext(settings: Record<string, unknown> = {}) {
|
||||
return {
|
||||
pluginId: "fusion-plugin-hermes-runtime",
|
||||
settings,
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
taskStore: { getTask: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("hermes-runtime plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveCli.mockReturnValue({
|
||||
binaryPath: "hermes",
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("has expected manifest identity", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
|
||||
expect(plugin.manifest.name).toBe("Hermes Runtime Plugin");
|
||||
expect(plugin.manifest.version).toBe("0.1.0");
|
||||
expect(plugin.state).toBe("installed");
|
||||
});
|
||||
|
||||
@@ -49,50 +61,35 @@ describe("hermes-runtime plugin", () => {
|
||||
expect(HERMES_RUNTIME_ID).toBe("hermes");
|
||||
expect(plugin.runtime?.metadata.runtimeId).toBe("hermes");
|
||||
expect(plugin.runtime?.metadata.name).toBe("Hermes Runtime");
|
||||
expect(plugin.runtime?.metadata.description).toContain("pi-ai direct streaming");
|
||||
expect(plugin.runtime?.metadata.description).toContain("hermes");
|
||||
expect(plugin.manifest.runtime).toEqual(hermesRuntimeMetadata);
|
||||
});
|
||||
|
||||
it("onLoad resolves model config and logs selected provider/model without api key", async () => {
|
||||
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5", apiKey: "secret" });
|
||||
mockResolveModelConfig.mockReturnValue({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "secret",
|
||||
thinkingLevel: "medium",
|
||||
it("onLoad logs selected binary path & model and emits loaded event", async () => {
|
||||
mockResolveCli.mockReturnValue({
|
||||
binaryPath: "/opt/homebrew/bin/hermes",
|
||||
model: "claude-sonnet-4-5",
|
||||
provider: "anthropic",
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
});
|
||||
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
|
||||
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("Hermes Runtime Plugin loaded — using openai/gpt-5");
|
||||
expect(ctx.logger.info.mock.calls[0][0]).not.toContain("secret");
|
||||
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/opt/homebrew/bin/hermes"),
|
||||
);
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
|
||||
runtimeId: "hermes",
|
||||
version: "0.1.0",
|
||||
version: plugin.manifest.version,
|
||||
});
|
||||
});
|
||||
|
||||
it("runtime factory resolves settings and returns HermesRuntimeAdapter", async () => {
|
||||
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5" });
|
||||
mockResolveModelConfig.mockReturnValue({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "api-key",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
|
||||
it("factory returns a HermesRuntimeAdapter", async () => {
|
||||
const ctx = createMockContext({ binaryPath: "hermes" });
|
||||
const runtime = (await hermesRuntimeFactory(ctx as any)) as HermesRuntimeAdapter;
|
||||
|
||||
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(runtime).toBeInstanceOf(HermesRuntimeAdapter);
|
||||
expect(runtime.id).toBe("hermes");
|
||||
expect(runtime.name).toBe("Hermes Runtime");
|
||||
expect((runtime as any).config).toEqual({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "api-key",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FusionPlugin } from "@fusion/plugin-sdk";
|
||||
import plugin from "../index.js";
|
||||
import type { AgentRuntime } from "../types.js";
|
||||
|
||||
const {
|
||||
mockResolveModelConfig,
|
||||
mockCreateStreamSession,
|
||||
mockStreamPrompt,
|
||||
mockDescribeStreamModel,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveModelConfig: vi.fn().mockReturnValue({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
apiKey: undefined,
|
||||
thinkingLevel: undefined,
|
||||
}),
|
||||
mockCreateStreamSession: vi.fn().mockReturnValue({ messages: [], dispose: vi.fn() }),
|
||||
mockStreamPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
mockDescribeStreamModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
resolveModelConfig: mockResolveModelConfig,
|
||||
createStreamSession: mockCreateStreamSession,
|
||||
streamPrompt: mockStreamPrompt,
|
||||
describeStreamModel: mockDescribeStreamModel,
|
||||
}));
|
||||
|
||||
function isAgentRuntime(value: unknown): value is AgentRuntime {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
"name" in value &&
|
||||
typeof (value as AgentRuntime).createSession === "function" &&
|
||||
typeof (value as AgentRuntime).promptWithFallback === "function" &&
|
||||
typeof (value as AgentRuntime).describeModel === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function createMockContext() {
|
||||
return {
|
||||
pluginId: "fusion-plugin-hermes-runtime",
|
||||
settings: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: { getTask: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hermes runtime plugin integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exports a valid Fusion plugin manifest", () => {
|
||||
const fusionPlugin = plugin as FusionPlugin;
|
||||
|
||||
expect(fusionPlugin).toBeDefined();
|
||||
expect(fusionPlugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
|
||||
});
|
||||
|
||||
it("runtime factory returns an AgentRuntime-compatible Hermes adapter", async () => {
|
||||
const runtime = (await plugin.runtime!.factory(createMockContext() as any)) as AgentRuntime;
|
||||
|
||||
expect(runtime.id).toBe("hermes");
|
||||
expect(runtime.name).toBe("Hermes Runtime");
|
||||
expect(isAgentRuntime(runtime)).toBe(true);
|
||||
|
||||
const created = await runtime.createSession({ cwd: "/tmp", systemPrompt: "helpful" });
|
||||
expect(created.sessionFile).toBeUndefined();
|
||||
|
||||
await runtime.promptWithFallback(created.session, "Hello integration");
|
||||
expect(mockStreamPrompt).toHaveBeenCalled();
|
||||
|
||||
expect(runtime.describeModel(created.session)).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("onLoad emits hermes-runtime:loaded with runtime metadata", async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
|
||||
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
|
||||
runtimeId: "hermes",
|
||||
version: "0.1.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
156
plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts
Normal file
156
plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
// ── Mock node:child_process ────────────────────────────────────────────────
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
|
||||
import { probeHermesBinary } from "../probe.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Yields to the microtask and I/O queue so awaited code can continue. */
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function makeFakeChild(): {
|
||||
child: ChildProcess;
|
||||
emitStdout: (data: string) => void;
|
||||
emitStderr: (data: string) => void;
|
||||
emitError: (err: NodeJS.ErrnoException) => void;
|
||||
emitClose: (code: number | null) => void;
|
||||
} {
|
||||
const main = new EventEmitter() as ChildProcess;
|
||||
const stdoutEmitter = new EventEmitter();
|
||||
const stderrEmitter = new EventEmitter();
|
||||
(main as any).stdout = stdoutEmitter;
|
||||
(main as any).stderr = stderrEmitter;
|
||||
(main as any).kill = vi.fn();
|
||||
|
||||
return {
|
||||
child: main,
|
||||
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
|
||||
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
|
||||
emitError: (err) => main.emit("error", err),
|
||||
emitClose: (code) => main.emit("close", code),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("probeHermesBinary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns available: false with not-found reason on ENOENT", async () => {
|
||||
// probeHermesBinary first awaits tryResolveBinaryPath (which/where spawn),
|
||||
// then spawns the --version process. We must drive them sequentially.
|
||||
const whichChild = makeFakeChild();
|
||||
const versionChild = makeFakeChild();
|
||||
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(whichChild.child) // which hermes
|
||||
.mockReturnValueOnce(versionChild.child); // hermes --version
|
||||
|
||||
const promise = probeHermesBinary({ timeoutMs: 500 });
|
||||
|
||||
// Settle the `which` call — which causes tryResolveBinaryPath to resolve.
|
||||
whichChild.emitClose(1);
|
||||
|
||||
// Yield so the awaited tryResolveBinaryPath continuation runs and spawns
|
||||
// the version child before we emit its error.
|
||||
await flushAsync();
|
||||
|
||||
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
||||
versionChild.emitError(enoent);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toMatch(/not found on PATH/);
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns available: true with parsed version on success", async () => {
|
||||
const whichChild = makeFakeChild();
|
||||
const versionChild = makeFakeChild();
|
||||
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(whichChild.child)
|
||||
.mockReturnValueOnce(versionChild.child);
|
||||
|
||||
const promise = probeHermesBinary({ binaryPath: "hermes", timeoutMs: 500 });
|
||||
|
||||
whichChild.emitStdout("/usr/local/bin/hermes\n");
|
||||
whichChild.emitClose(0);
|
||||
|
||||
await flushAsync();
|
||||
|
||||
versionChild.emitStdout("Hermes Agent v1.2.3\n");
|
||||
versionChild.emitClose(0);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe("Hermes Agent v1.2.3");
|
||||
expect(result.binaryPath).toBe("/usr/local/bin/hermes");
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns available: false when version exits non-zero", async () => {
|
||||
const whichChild = makeFakeChild();
|
||||
const versionChild = makeFakeChild();
|
||||
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(whichChild.child)
|
||||
.mockReturnValueOnce(versionChild.child);
|
||||
|
||||
const promise = probeHermesBinary({ timeoutMs: 500 });
|
||||
|
||||
whichChild.emitClose(1);
|
||||
|
||||
await flushAsync();
|
||||
|
||||
versionChild.emitStderr("error: something went wrong");
|
||||
versionChild.emitClose(2);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("error: something went wrong");
|
||||
});
|
||||
|
||||
it("uses custom binaryPath when provided", async () => {
|
||||
const whichChild = makeFakeChild();
|
||||
const versionChild = makeFakeChild();
|
||||
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(whichChild.child)
|
||||
.mockReturnValueOnce(versionChild.child);
|
||||
|
||||
const promise = probeHermesBinary({ binaryPath: "/opt/bin/hermes", timeoutMs: 500 });
|
||||
|
||||
whichChild.emitClose(1);
|
||||
|
||||
await flushAsync();
|
||||
|
||||
versionChild.emitStdout("Hermes Agent v2.0.0\n");
|
||||
versionChild.emitClose(0);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe("Hermes Agent v2.0.0");
|
||||
|
||||
// Verify the version spawn used our custom path.
|
||||
const versionSpawnArgs = mockSpawn.mock.calls[1]!;
|
||||
expect(versionSpawnArgs[0]).toBe("/opt/bin/hermes");
|
||||
expect(versionSpawnArgs[1]).toEqual(["--version"]);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
const {
|
||||
mockCreateStreamSession,
|
||||
mockStreamPrompt,
|
||||
mockDescribeStreamModel,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateStreamSession: vi.fn(),
|
||||
mockStreamPrompt: vi.fn(),
|
||||
mockDescribeStreamModel: vi.fn(),
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
createStreamSession: mockCreateStreamSession,
|
||||
streamPrompt: mockStreamPrompt,
|
||||
describeStreamModel: mockDescribeStreamModel,
|
||||
}));
|
||||
vi.mock("../cli-spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
|
||||
"../cli-spawn.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
invokeHermesCli: mockInvoke,
|
||||
};
|
||||
});
|
||||
|
||||
describe("HermesRuntimeAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInvoke.mockResolvedValue({
|
||||
body: "hello from hermes",
|
||||
sessionId: "20260427_120000_abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("has stable runtime identity", () => {
|
||||
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
describe("HermesRuntimeAdapter — identity", () => {
|
||||
it("has stable id/name", () => {
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
expect(adapter.id).toBe("hermes");
|
||||
expect(adapter.name).toBe("Hermes Runtime");
|
||||
});
|
||||
});
|
||||
|
||||
it("createSession passes model config/systemPrompt/callbacks and returns undefined sessionFile", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "secret",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
const session = { messages: [], dispose: vi.fn() };
|
||||
mockCreateStreamSession.mockReturnValue(session);
|
||||
|
||||
describe("HermesRuntimeAdapter — createSession", () => {
|
||||
it("returns a session with empty sessionId and undefined sessionFile", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const result = await adapter.createSession({
|
||||
cwd: "/tmp/project",
|
||||
systemPrompt: "You are Hermes",
|
||||
tools: "coding",
|
||||
customTools: [{ name: "ignored" }],
|
||||
sessionManager: { foo: "bar" },
|
||||
skillSelection: { all: true },
|
||||
skills: ["bash"],
|
||||
cwd: "/repo",
|
||||
systemPrompt: "be helpful",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(mockCreateStreamSession).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
apiKey: "secret",
|
||||
thinkingLevel: "high",
|
||||
systemPrompt: "You are Hermes",
|
||||
callbacks: {
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ session, sessionFile: undefined });
|
||||
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("/tmp/project");
|
||||
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("coding");
|
||||
});
|
||||
|
||||
it("promptWithFallback appends user message then delegates to streamPrompt", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
const session = { messages: [], dispose: vi.fn() } as any;
|
||||
|
||||
await adapter.promptWithFallback(session, "Hello from Hermes");
|
||||
|
||||
expect(session.messages).toEqual([{ role: "user", content: "Hello from Hermes" }]);
|
||||
expect(mockStreamPrompt).toHaveBeenCalledWith(session, {
|
||||
role: "user",
|
||||
content: "Hello from Hermes",
|
||||
});
|
||||
});
|
||||
|
||||
it("describeModel delegates to describeStreamModel", () => {
|
||||
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
const session = { messages: [], dispose: vi.fn() } as any;
|
||||
mockDescribeStreamModel.mockReturnValue("anthropic/claude-sonnet-4-5");
|
||||
|
||||
expect(adapter.describeModel(session)).toBe("anthropic/claude-sonnet-4-5");
|
||||
expect(mockDescribeStreamModel).toHaveBeenCalledWith(session);
|
||||
});
|
||||
|
||||
it("dispose is a no-op when missing and calls dispose when present", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
const dispose = vi.fn();
|
||||
|
||||
await expect(adapter.dispose({ messages: [], dispose } as any)).resolves.toBeUndefined();
|
||||
await expect(adapter.dispose({ messages: [] } as any)).resolves.toBeUndefined();
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect(result.sessionFile).toBeUndefined();
|
||||
expect(result.session.sessionId).toBe("");
|
||||
expect(result.session.systemPrompt).toBe("be helpful");
|
||||
expect(result.session.callbacks.onText).toBe(onText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HermesRuntimeAdapter — promptWithFallback", () => {
|
||||
it("invokes hermes CLI with no resume on first call", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({ model: "claude-sonnet-4-5" });
|
||||
const onText = vi.fn();
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
onText,
|
||||
});
|
||||
|
||||
await adapter.promptWithFallback(session, "first prompt");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
|
||||
expect(prompt).toBe("first prompt");
|
||||
expect(settings.model).toBe("claude-sonnet-4-5");
|
||||
expect(resumeId).toBeUndefined();
|
||||
expect(onText).toHaveBeenCalledWith("hello from hermes");
|
||||
// Session id is captured for next call
|
||||
expect(session.sessionId).toBe("20260427_120000_abc123");
|
||||
});
|
||||
|
||||
it("passes captured session id as --resume on subsequent calls", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
await adapter.promptWithFallback(session, "p1");
|
||||
await adapter.promptWithFallback(session, "p2");
|
||||
|
||||
const [, , resume2] = mockInvoke.mock.calls[1];
|
||||
expect(resume2).toBe("20260427_120000_abc123");
|
||||
});
|
||||
|
||||
it("propagates CLI errors", async () => {
|
||||
mockInvoke.mockRejectedValueOnce(new Error("hermes: missing session_id"));
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
await expect(adapter.promptWithFallback(session, "p")).rejects.toThrow(
|
||||
/missing session_id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT call onText when body is empty", async () => {
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
body: "",
|
||||
sessionId: "20260427_120000_def456",
|
||||
});
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const onText = vi.fn();
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
onText,
|
||||
});
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HermesRuntimeAdapter — describeModel", () => {
|
||||
it("returns hermes/<provider>/<model> when both set", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
expect(adapter.describeModel(session)).toBe(
|
||||
"hermes/anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hermes/<model> when only model set", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({ model: "MiniMax-M2.7" });
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
expect(adapter.describeModel(session)).toBe("hermes/MiniMax-M2.7");
|
||||
});
|
||||
|
||||
it("returns plain 'hermes' when neither set", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
expect(adapter.describeModel(session)).toBe("hermes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HermesRuntimeAdapter — dispose", () => {
|
||||
it("is a no-op", async () => {
|
||||
const adapter = new HermesRuntimeAdapter({});
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
await expect(adapter.dispose!(session)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
467
plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts
Normal file
467
plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Hermes CLI spawn module.
|
||||
*
|
||||
* Drives the local `hermes` binary as a subprocess instead of using the
|
||||
* @mariozechner/pi-ai SDK. Session continuity is maintained by capturing
|
||||
* the `session_id:` line from stdout and passing `--resume <id>` on
|
||||
* subsequent invocations.
|
||||
*
|
||||
* There is NO per-token streaming on this surface — `hermes chat -q`
|
||||
* buffers output in prompt_toolkit. The full response is delivered in
|
||||
* one chunk once the process exits.
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { sep as PATH_SEP } from "node:path";
|
||||
|
||||
/**
|
||||
* On Windows, `spawn("hermes", ...)` won't find `hermes.cmd`/`.bat` shims —
|
||||
* Node doesn't honor PATHEXT. We resolve via `where` (which does) and spawn
|
||||
* the absolute path instead. No-op on POSIX. Cached per process.
|
||||
*/
|
||||
const resolvedBinaryCache = new Map<string, string>();
|
||||
|
||||
function resolveBinaryForSpawn(binary: string): string {
|
||||
if (process.platform !== "win32") return binary;
|
||||
if (binary.includes(PATH_SEP) || binary.includes("/") || /\.[a-z]{2,4}$/i.test(binary)) {
|
||||
return binary;
|
||||
}
|
||||
const cached = resolvedBinaryCache.get(binary);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const result = spawnSync("where", [binary], { encoding: "utf-8" });
|
||||
if (result.status === 0) {
|
||||
const first = (result.stdout ?? "").trim().split(/\r?\n/)[0];
|
||||
if (first?.length) {
|
||||
resolvedBinaryCache.set(binary, first);
|
||||
return first;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
/** ANSI escape code stripping regex. */
|
||||
// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition
|
||||
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
|
||||
/** Pattern to locate the session id appended by hermes near end of stdout. */
|
||||
const SESSION_ID_RE = /^session_id:\s+([0-9]{8}_[0-9]{6}_[0-9a-f]{6})\s*$/m;
|
||||
|
||||
/** Lines that are part of the hermes TUI chrome and should be stripped from the body. */
|
||||
const CHROME_LINE_RES = [
|
||||
/^\s*┊\s/, // memory/status sidebar lines
|
||||
/^↻ Resumed session /, // resume banner
|
||||
/^╭─.*╮\s*$/, // top box border
|
||||
/^╰─.*╯\s*$/, // bottom box border
|
||||
/^Query:\s*/, // query echo line
|
||||
];
|
||||
|
||||
// ── Profile listing ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Summary of a single Hermes profile as returned by `hermes profile list`.
|
||||
*/
|
||||
export interface HermesProfileSummary {
|
||||
/** Profile name, e.g. "default". */
|
||||
name: string;
|
||||
/** Model configured for this profile, if any. */
|
||||
model?: string;
|
||||
/** Gateway status string, e.g. "stopped". */
|
||||
gateway?: string;
|
||||
/** Alias wrapper script name, if set. */
|
||||
alias?: string;
|
||||
/** True when this profile has the `◆` sticky-default marker. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** Regex matching lines made entirely of `─` box-drawing chars (the rule row). */
|
||||
const PROFILE_RULE_RE = /^[\s─]+$/;
|
||||
|
||||
/** Em-dash placeholder used by `hermes profile list` for empty values. */
|
||||
const EM_DASH = "—";
|
||||
|
||||
/**
|
||||
* Parse the stdout of `hermes profile list` into an array of profile summaries.
|
||||
*
|
||||
* The output looks like:
|
||||
*
|
||||
* ```
|
||||
* Profile Model Gateway Alias
|
||||
* ─────────────── ───────────────────────── ─────────── ────────────
|
||||
* ◆default MiniMax-M2.7 stopped —
|
||||
* ```
|
||||
*
|
||||
* Columns are whitespace-aligned (variable-width). The function splits on runs
|
||||
* of two-or-more spaces to handle variable column widths robustly.
|
||||
*/
|
||||
function parseProfileListOutput(raw: string): HermesProfileSummary[] {
|
||||
const lines = raw.replace(ANSI_RE, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const profiles: HermesProfileSummary[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip blank lines.
|
||||
if (line.trim() === "") continue;
|
||||
// Skip the header line (starts with " Profile").
|
||||
if (/^\s*Profile\b/.test(line)) continue;
|
||||
// Skip rule lines (all box-drawing dashes / spaces).
|
||||
if (PROFILE_RULE_RE.test(line)) continue;
|
||||
|
||||
// Split on 2+ spaces to separate columns. Trim leading space first.
|
||||
const stripped = line.replace(/^\s+/, "");
|
||||
const columns = stripped.split(/\s{2,}/);
|
||||
|
||||
const rawName = columns[0] ?? "";
|
||||
const isDefault = rawName.startsWith("◆");
|
||||
const name = rawName.replace(/^◆/, "").trim();
|
||||
if (!name) continue;
|
||||
|
||||
const toUndef = (v: string | undefined): string | undefined =>
|
||||
v === undefined || v.trim() === "" || v.trim() === EM_DASH ? undefined : v.trim();
|
||||
|
||||
profiles.push({
|
||||
name,
|
||||
model: toUndef(columns[1]),
|
||||
gateway: toUndef(columns[2]),
|
||||
alias: toUndef(columns[3]),
|
||||
isDefault,
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the HERMES_HOME directory for a named profile.
|
||||
*
|
||||
* Mirrors hermes's own logic:
|
||||
* - "default" → ~/.hermes
|
||||
* - other name → ~/.hermes/profiles/<name>
|
||||
*
|
||||
* This is used to set HERMES_HOME when spawning hermes with a specific profile.
|
||||
*/
|
||||
function hermesProfileHome(profileName: string): string {
|
||||
const base = process.env.HERMES_HOME ?? `${process.env.HOME ?? "~"}/.hermes`;
|
||||
if (profileName === "default" || profileName === "") return base;
|
||||
return `${base}/profiles/${profileName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Hermes profiles by running `hermes profile list`.
|
||||
*
|
||||
* @param opts.binaryPath - Path to the hermes binary (default: "hermes").
|
||||
* @param opts.timeoutMs - Maximum wait time in ms (default: 5000).
|
||||
* @returns Array of profile summaries, ordered as hermes returns them.
|
||||
* @throws When hermes is not found (ENOENT) or exits non-zero.
|
||||
*/
|
||||
export async function listHermesProfiles(opts?: {
|
||||
binaryPath?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<HermesProfileSummary[]> {
|
||||
const binary = resolveBinaryForSpawn(opts?.binaryPath ?? "hermes");
|
||||
const timeoutMs = opts?.timeoutMs ?? 5_000;
|
||||
|
||||
return new Promise<HermesProfileSummary[]>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const child = spawn(binary, ["profile", "list"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
||||
reject(new Error(`hermes profile list failed: timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); });
|
||||
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); });
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const isNotFound = err.code === "ENOENT";
|
||||
reject(
|
||||
new Error(
|
||||
isNotFound
|
||||
? `hermes profile list failed: binary not found at "${opts?.binaryPath ?? "hermes"}"`
|
||||
: `hermes profile list failed: ${err.message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (code !== 0) {
|
||||
const combined = [stdout, stderr].filter(Boolean).join("\n");
|
||||
reject(new Error(`hermes profile list failed: process exited with code ${String(code)}.\n${combined}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(parseProfileListOutput(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── CLI settings + invocation ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Settings resolved from plugin ctx.settings + env-var fallbacks.
|
||||
*/
|
||||
export interface HermesCliSettings {
|
||||
/** Path to the hermes binary. Default: "hermes" (rely on PATH). */
|
||||
binaryPath: string;
|
||||
/** Model identifier, e.g. "claude-sonnet-4-5". */
|
||||
model?: string;
|
||||
/** Provider identifier, e.g. "anthropic". */
|
||||
provider?: string;
|
||||
/** Maximum agent turns per invocation. Default: 12. */
|
||||
maxTurns: number;
|
||||
/** Pass --yolo to hermes (skip confirmations). Default: false. */
|
||||
yolo: boolean;
|
||||
/** Hard kill timeout in milliseconds. Default: 300000 (5 min). */
|
||||
cliTimeoutMs: number;
|
||||
/**
|
||||
* Hermes profile name to activate when spawning the CLI.
|
||||
* Implemented by setting HERMES_HOME to the profile directory in the
|
||||
* subprocess environment — hermes has no `--profile` CLI flag on `chat`.
|
||||
* Empty string / undefined = use the current sticky-default profile.
|
||||
*/
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
/** Result of a single hermes CLI invocation. */
|
||||
export interface HermesCliResult {
|
||||
/** Parsed assistant response text. */
|
||||
body: string;
|
||||
/** The session id captured from stdout (used for --resume on next call). */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve HermesCliSettings from a plugin settings record and environment
|
||||
* variable fallbacks.
|
||||
*/
|
||||
export function resolveCliSettings(settings?: Record<string, unknown>): HermesCliSettings {
|
||||
const str = (v: unknown): string | undefined =>
|
||||
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
||||
|
||||
const num = (v: unknown, envKey: string, fallback: number): number => {
|
||||
// Accept numeric values directly.
|
||||
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
||||
const raw = str(v) ?? str(process.env[envKey]);
|
||||
if (raw !== undefined) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const bool = (v: unknown, envKey: string, fallback: boolean): boolean => {
|
||||
if (typeof v === "boolean") return v;
|
||||
const raw = str(v) ?? str(process.env[envKey]);
|
||||
if (raw !== undefined) return raw === "1" || raw.toLowerCase() === "true";
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
binaryPath: str(settings?.binaryPath) ?? str(process.env.HERMES_BIN) ?? "hermes",
|
||||
model: str(settings?.model) ?? str(process.env.HERMES_MODEL_ID),
|
||||
provider: str(settings?.provider) ?? str(process.env.HERMES_PROVIDER),
|
||||
maxTurns: num(settings?.maxTurns, "HERMES_MAX_TURNS", 12),
|
||||
yolo: bool(settings?.yolo, "HERMES_YOLO", false),
|
||||
cliTimeoutMs: num(settings?.cliTimeoutMs, "HERMES_CLI_TIMEOUT_MS", 300_000),
|
||||
profile: str(settings?.profile) ?? str(process.env.HERMES_PROFILE),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes and normalize CRLF to LF.
|
||||
*/
|
||||
function cleanText(raw: string): string {
|
||||
return raw.replace(ANSI_RE, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hermes TUI chrome lines from the captured body.
|
||||
*/
|
||||
function stripChrome(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const filtered = lines.filter((line) => !CHROME_LINE_RES.some((re) => re.test(line)));
|
||||
return filtered.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw stdout from `hermes chat -q ... -Q`.
|
||||
*
|
||||
* Returns `{ body, sessionId }` on success or throws with a descriptive error.
|
||||
*/
|
||||
export function parseHermesOutput(rawStdout: string, rawStderr: string): HermesCliResult {
|
||||
const cleaned = cleanText(rawStdout);
|
||||
const match = SESSION_ID_RE.exec(cleaned);
|
||||
|
||||
if (!match) {
|
||||
const combined = [rawStdout, rawStderr].filter(Boolean).join("\n");
|
||||
throw new Error(`hermes: missing session_id in output.\n${combined}`);
|
||||
}
|
||||
|
||||
const sessionId = match[1]!;
|
||||
// Body is everything before the session_id line.
|
||||
const sessionIdLineStart = cleaned.lastIndexOf("\nsession_id:");
|
||||
const bodyRaw = sessionIdLineStart >= 0 ? cleaned.slice(0, sessionIdLineStart) : cleaned;
|
||||
const body = stripChrome(bodyRaw);
|
||||
|
||||
return { body, sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv array for a `hermes chat` invocation.
|
||||
*/
|
||||
export function buildHermesArgs(
|
||||
prompt: string,
|
||||
settings: HermesCliSettings,
|
||||
resumeSessionId?: string,
|
||||
): string[] {
|
||||
const args: string[] = ["chat", "-q", prompt, "-Q", "--source", "tool"];
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push("--resume", resumeSessionId);
|
||||
}
|
||||
|
||||
if (settings.model) {
|
||||
args.push("-m", settings.model);
|
||||
}
|
||||
|
||||
if (settings.provider) {
|
||||
args.push("--provider", settings.provider);
|
||||
}
|
||||
|
||||
args.push("--max-turns", String(settings.maxTurns));
|
||||
|
||||
if (settings.yolo) {
|
||||
args.push("--yolo");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the hermes CLI for a single prompt/response turn.
|
||||
*
|
||||
* @param prompt - The user prompt to send.
|
||||
* @param settings - Resolved CLI settings.
|
||||
* @param resumeSessionId - Hermes session id from a prior call, if continuing.
|
||||
* @param signal - Optional AbortSignal; will SIGTERM the subprocess on abort.
|
||||
* @returns Parsed response body and the new/existing session id.
|
||||
*/
|
||||
export async function invokeHermesCli(
|
||||
prompt: string,
|
||||
settings: HermesCliSettings,
|
||||
resumeSessionId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HermesCliResult> {
|
||||
const args = buildHermesArgs(prompt, settings, resumeSessionId);
|
||||
const binary = resolveBinaryForSpawn(settings.binaryPath);
|
||||
|
||||
return new Promise<HermesCliResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const spawnEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONUNBUFFERED: "1" };
|
||||
if (settings.profile) {
|
||||
spawnEnv.HERMES_HOME = hermesProfileHome(settings.profile);
|
||||
}
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: spawnEnv,
|
||||
});
|
||||
|
||||
const hardKillTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
reject(new Error(`hermes: process timed out after ${settings.cliTimeoutMs}ms`));
|
||||
}, settings.cliTimeoutMs);
|
||||
|
||||
// Forward AbortSignal.
|
||||
const onAbort = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardKillTimer);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
reject(new Error("hermes: invocation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardKillTimer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
const isNotFound = err.code === "ENOENT";
|
||||
reject(
|
||||
new Error(
|
||||
isNotFound
|
||||
? `hermes: binary not found at "${settings.binaryPath}". Install hermes or set binaryPath/HERMES_BIN.`
|
||||
: `hermes: spawn error — ${err.message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardKillTimer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (code !== 0) {
|
||||
const combined = [stdout, stderr].filter(Boolean).join("\n");
|
||||
reject(new Error(`hermes: process exited with code ${String(code)}.\n${combined}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseHermesOutput(stdout, stderr));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Hermes Runtime Plugin
|
||||
*
|
||||
* Provides an executable Hermes runtime adapter for Fusion's plugin runtime
|
||||
* discovery and session execution pipeline.
|
||||
* Provides an executable Hermes runtime adapter that drives the local `hermes`
|
||||
* CLI as a subprocess. Discovered by Fusion's plugin runtime registry; the
|
||||
* settings configured in the dashboard's "Runtimes → Hermes" page flow through
|
||||
* `ctx.settings` into the CLI invocation.
|
||||
*/
|
||||
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { resolveModelConfig } from "./pi-module.js";
|
||||
import { resolveCliSettings } from "./cli-spawn.js";
|
||||
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
@@ -17,26 +19,19 @@ import type {
|
||||
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
|
||||
|
||||
const HERMES_RUNTIME_ID = "hermes";
|
||||
const HERMES_RUNTIME_VERSION = "0.1.0";
|
||||
const HERMES_RUNTIME_VERSION = "0.2.0";
|
||||
|
||||
const hermesRuntimeMetadata: PluginRuntimeManifestMetadata = {
|
||||
runtimeId: HERMES_RUNTIME_ID,
|
||||
name: "Hermes Runtime",
|
||||
description: "Hermes raw-model runtime using pi-ai direct streaming",
|
||||
description: "Drives the local `hermes` CLI (NousResearch/hermes-agent)",
|
||||
version: HERMES_RUNTIME_VERSION,
|
||||
};
|
||||
|
||||
// ── Hermes Runtime Factory ────────────────────────────────────────────────────
|
||||
|
||||
const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
|
||||
const config = resolveModelConfig(ctx.settings);
|
||||
|
||||
return new HermesRuntimeAdapter({
|
||||
provider: config.provider,
|
||||
modelId: config.modelId,
|
||||
apiKey: config.apiKey,
|
||||
thinkingLevel: config.thinkingLevel,
|
||||
});
|
||||
return new HermesRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
|
||||
};
|
||||
|
||||
// ── Plugin Definition ─────────────────────────────────────────────────────────
|
||||
@@ -45,24 +40,27 @@ const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-hermes-runtime",
|
||||
name: "Hermes Runtime Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Hermes AI runtime plugin for Fusion - provides AI agent execution runtime capabilities",
|
||||
version: HERMES_RUNTIME_VERSION,
|
||||
description:
|
||||
"Drives the local `hermes` CLI for Fusion agents — captures session ids and resumes via --resume.",
|
||||
author: "Fusion Team",
|
||||
homepage: "https://github.com/gsxdsm/fusion",
|
||||
homepage: "https://github.com/NousResearch/hermes-agent",
|
||||
runtime: hermesRuntimeMetadata,
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
const config = resolveModelConfig(ctx.settings);
|
||||
ctx.logger.info(`Hermes Runtime Plugin loaded — using ${config.provider}/${config.modelId}`);
|
||||
const settings = resolveCliSettings(ctx.settings);
|
||||
ctx.logger.info(
|
||||
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`,
|
||||
);
|
||||
ctx.emitEvent("hermes-runtime:loaded", {
|
||||
runtimeId: HERMES_RUNTIME_ID,
|
||||
version: HERMES_RUNTIME_VERSION,
|
||||
});
|
||||
},
|
||||
onUnload: () => {
|
||||
// No context available during unload
|
||||
// No persistent state to clean up — each prompt spawns a fresh subprocess.
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
@@ -73,6 +71,19 @@ const plugin: FusionPlugin = definePlugin({
|
||||
|
||||
export default plugin;
|
||||
|
||||
// ── Exports for Testing ───────────────────────────────────────────────────────
|
||||
// ── Public exports ────────────────────────────────────────────────────────────
|
||||
|
||||
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
|
||||
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
|
||||
export {
|
||||
resolveCliSettings,
|
||||
invokeHermesCli,
|
||||
buildHermesArgs,
|
||||
parseHermesOutput,
|
||||
listHermesProfiles,
|
||||
} from "./cli-spawn.js";
|
||||
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
|
||||
|
||||
// Probe re-export for the dashboard's runtime-provider-probes façade.
|
||||
export { probeHermesBinary } from "./probe.js";
|
||||
export type { HermesBinaryStatus } from "./probe.js";
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
getModel,
|
||||
streamSimple,
|
||||
type Api,
|
||||
type AssistantMessageEvent,
|
||||
type Context,
|
||||
type Message,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type ThinkingLevel,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
HermesCallbacks,
|
||||
HermesStreamSession,
|
||||
ResolvedModelConfig,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_PROVIDER = "anthropic";
|
||||
const DEFAULT_MODEL_ID = "claude-sonnet-4-5";
|
||||
|
||||
function resolveStringSetting(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function resolveModelConfig(settings?: Record<string, unknown>): ResolvedModelConfig {
|
||||
const provider =
|
||||
resolveStringSetting(settings?.provider) ?? resolveStringSetting(process.env.HERMES_PROVIDER) ?? DEFAULT_PROVIDER;
|
||||
const modelId =
|
||||
resolveStringSetting(settings?.modelId) ?? resolveStringSetting(process.env.HERMES_MODEL_ID) ?? DEFAULT_MODEL_ID;
|
||||
const apiKey = resolveStringSetting(settings?.apiKey) ?? resolveStringSetting(process.env.HERMES_API_KEY);
|
||||
const thinkingLevel =
|
||||
resolveStringSetting(settings?.thinkingLevel) ?? resolveStringSetting(process.env.HERMES_THINKING_LEVEL) ?? undefined;
|
||||
|
||||
return { provider, modelId, apiKey, thinkingLevel };
|
||||
}
|
||||
|
||||
export function createStreamSession(options: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
apiKey?: string;
|
||||
thinkingLevel?: string;
|
||||
systemPrompt: string;
|
||||
callbacks?: HermesCallbacks;
|
||||
}): HermesStreamSession {
|
||||
const model = getModel(options.provider as never, options.modelId as never) as Model<Api>;
|
||||
|
||||
return {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
messages: [],
|
||||
apiKey: options.apiKey,
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
sessionId: randomUUID(),
|
||||
lastModelDescription: `${model.provider}/${model.id}`,
|
||||
callbacks: options.callbacks ?? {},
|
||||
usage: undefined,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function streamPrompt(session: HermesStreamSession, _userMessage: Message): Promise<void> {
|
||||
const context: Context = {
|
||||
systemPrompt: session.systemPrompt,
|
||||
messages: [...(session.messages as Message[])],
|
||||
};
|
||||
|
||||
const options: SimpleStreamOptions = {
|
||||
sessionId: session.sessionId,
|
||||
};
|
||||
|
||||
if (session.apiKey) {
|
||||
options.apiKey = session.apiKey;
|
||||
}
|
||||
|
||||
if (session.thinkingLevel) {
|
||||
options.reasoning = session.thinkingLevel as ThinkingLevel;
|
||||
}
|
||||
|
||||
const stream = streamSimple(session.model as Model<Api>, context, options);
|
||||
|
||||
let fullText = "";
|
||||
for await (const event of stream) {
|
||||
handleStreamEvent(event, session, (delta) => {
|
||||
fullText += delta;
|
||||
});
|
||||
}
|
||||
|
||||
const finalMessage = await stream.result();
|
||||
const responseText =
|
||||
finalMessage.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("") || fullText;
|
||||
|
||||
session.messages.push({ role: "assistant", content: responseText });
|
||||
session.lastModelDescription = `${(session.model as Model<Api>).provider}/${(session.model as Model<Api>).id}`;
|
||||
}
|
||||
|
||||
function handleStreamEvent(
|
||||
event: AssistantMessageEvent,
|
||||
session: HermesStreamSession,
|
||||
onTextDelta: (delta: string) => void,
|
||||
): void {
|
||||
if (event.type === "text_delta") {
|
||||
session.callbacks.onText?.(event.delta);
|
||||
onTextDelta(event.delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "thinking_delta") {
|
||||
session.callbacks.onThinking?.(event.delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "toolcall_end") {
|
||||
session.callbacks.onToolStart?.(event.toolCall.name, event.toolCall.arguments);
|
||||
session.callbacks.onToolEnd?.(event.toolCall.name, false, event.toolCall.arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const errorMessage = event.error.errorMessage ?? "Hermes stream failed";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (event.type === "done") {
|
||||
session.usage = event.message.usage;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeStreamModel(session: HermesStreamSession): string {
|
||||
return session.lastModelDescription;
|
||||
}
|
||||
146
plugins/fusion-plugin-hermes-runtime/src/probe.ts
Normal file
146
plugins/fusion-plugin-hermes-runtime/src/probe.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Hermes binary probe helper.
|
||||
*
|
||||
* Mirrors the probeClaudeCli pattern from packages/dashboard/src/claude-cli-probe.ts.
|
||||
* Never throws — all failures are captured as `available: false` with a reason.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/** Default probe timeout in milliseconds. */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Result of probing for the hermes binary.
|
||||
*/
|
||||
export interface HermesBinaryStatus {
|
||||
/** True if the binary was found and ran to completion successfully. */
|
||||
available: boolean;
|
||||
/** Absolute path resolved via `which`/`where`, if found. */
|
||||
binaryPath?: string;
|
||||
/** Version string from `hermes --version` stdout, if available. */
|
||||
version?: string;
|
||||
/** Human-readable failure reason when `available === false`. */
|
||||
reason?: string;
|
||||
/** Wall-clock duration of the probe in milliseconds. */
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for the hermes binary.
|
||||
*
|
||||
* Runs `<binaryPath> --version` with a short timeout. Use this from
|
||||
* the dashboard status endpoint to check binary presence without crashing.
|
||||
*
|
||||
* @param opts.binaryPath - Override the binary path (default: "hermes").
|
||||
* @param opts.timeoutMs - Override probe timeout in ms (default: 2000).
|
||||
*/
|
||||
export async function probeHermesBinary(opts?: {
|
||||
binaryPath?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<HermesBinaryStatus> {
|
||||
const startedAt = Date.now();
|
||||
const binary =
|
||||
typeof opts?.binaryPath === "string" && opts.binaryPath.trim().length > 0
|
||||
? opts.binaryPath.trim()
|
||||
: "hermes";
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
|
||||
const resolvedPath = await tryResolveBinaryPath(binary);
|
||||
|
||||
return new Promise<HermesBinaryStatus>((resolvePromise) => {
|
||||
const finish = (result: Omit<HermesBinaryStatus, "probeDurationMs">): void => {
|
||||
resolvePromise({ ...result, probeDurationMs: Date.now() - startedAt });
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
|
||||
const child = spawn(resolvedPath ?? binary, ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Process already gone.
|
||||
}
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason: `Probe timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const isNotFound = err.code === "ENOENT";
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason: isNotFound
|
||||
? `\`${binary}\` not found on PATH`
|
||||
: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
finish({
|
||||
available: true,
|
||||
version: stdout.trim() || undefined,
|
||||
binaryPath: resolvedPath,
|
||||
});
|
||||
} else {
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason:
|
||||
stderr.trim() || `hermes --version exited with code ${String(code)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort path resolution via `which` (POSIX) or `where` (Windows).
|
||||
* Returns undefined on failure — the spawn above is the actual authority.
|
||||
*/
|
||||
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
out += chunk.toString("utf-8");
|
||||
});
|
||||
child.on("error", () => resolvePromise(undefined));
|
||||
child.on("close", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
const first = out.trim().split(/\r?\n/)[0];
|
||||
resolvePromise(first?.length ? first : undefined);
|
||||
} else {
|
||||
resolvePromise(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,57 +1,84 @@
|
||||
/**
|
||||
* Hermes Runtime Adapter — drives the local `hermes` CLI as a subprocess.
|
||||
*
|
||||
* Each call to `promptWithFallback` invokes `hermes chat -q ... -Q --source tool`
|
||||
* and captures the resulting `session_id:` line. Subsequent calls on the same
|
||||
* session pass `--resume <id>` to continue the conversation.
|
||||
*/
|
||||
|
||||
import { invokeHermesCli, resolveCliSettings } from "./cli-spawn.js";
|
||||
import type { HermesCliSettings } from "./cli-spawn.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSession,
|
||||
AgentSessionResult,
|
||||
HermesModelConfig,
|
||||
HermesStreamSession,
|
||||
} from "./types.js";
|
||||
import { createStreamSession, describeStreamModel, streamPrompt } from "./pi-module.js";
|
||||
|
||||
export class HermesRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "hermes";
|
||||
readonly name = "Hermes Runtime";
|
||||
|
||||
constructor(
|
||||
private readonly config: HermesModelConfig = {
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
) {}
|
||||
private readonly settings: HermesCliSettings;
|
||||
|
||||
constructor(settings?: Record<string, unknown> | HermesCliSettings) {
|
||||
this.settings = resolveCliSettings(
|
||||
settings as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const session = createStreamSession({
|
||||
provider: this.config.provider,
|
||||
modelId: this.config.modelId,
|
||||
apiKey: this.config.apiKey,
|
||||
thinkingLevel: this.config.thinkingLevel,
|
||||
const session: HermesStreamSession = {
|
||||
model: undefined,
|
||||
systemPrompt: options.systemPrompt,
|
||||
messages: [],
|
||||
apiKey: undefined,
|
||||
thinkingLevel: undefined,
|
||||
sessionId: "",
|
||||
lastModelDescription: this.describeFromSettings(),
|
||||
callbacks: {
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
onToolEnd: options.onToolEnd,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionFile: undefined,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
return { session, sessionFile: undefined };
|
||||
}
|
||||
|
||||
async promptWithFallback(session: AgentSession, prompt: string, _options?: unknown): Promise<void> {
|
||||
const userMessage = { role: "user", content: prompt };
|
||||
session.messages.push(userMessage);
|
||||
await streamPrompt(session, userMessage as any);
|
||||
async promptWithFallback(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
_options?: unknown,
|
||||
): Promise<void> {
|
||||
const resumeId = session.sessionId || undefined;
|
||||
const result = await invokeHermesCli(prompt, this.settings, resumeId);
|
||||
|
||||
session.sessionId = result.sessionId;
|
||||
session.lastModelDescription = this.describeFromSettings();
|
||||
|
||||
if (result.body) {
|
||||
session.callbacks.onText?.(result.body);
|
||||
}
|
||||
}
|
||||
|
||||
describeModel(session: AgentSession): string {
|
||||
return describeStreamModel(session);
|
||||
return session.lastModelDescription || this.describeFromSettings();
|
||||
}
|
||||
|
||||
async dispose(session: AgentSession): Promise<void> {
|
||||
if (typeof session.dispose === "function") {
|
||||
session.dispose();
|
||||
}
|
||||
async dispose(_session: AgentSession): Promise<void> {
|
||||
// No persistent resources to release — the hermes CLI process exits per turn.
|
||||
}
|
||||
|
||||
private describeFromSettings(): string {
|
||||
const provider = this.settings.provider;
|
||||
const model = this.settings.model;
|
||||
if (provider && model) return `hermes/${provider}/${model}`;
|
||||
if (model) return `hermes/${model}`;
|
||||
if (provider) return `hermes/${provider}`;
|
||||
return "hermes";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user