Add Hermes gateway adapter plugin

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-26 13:53:20 +00:00
1 parent f565280dff
commit ed86920ffa
17 files changed
+1603

No files matched your search

@@ -0,0 +1,16 @@
# Hermes Gateway Adapter
External Paperclip adapter for Hermes Agent's authenticated HTTP/SSE API server.
Install it through Paperclip's Adapter manager as a local path or npm package. The
adapter type is `hermes_gateway`; it is intentionally not registered as a built-in
core adapter.
Required config:
- `apiBaseUrl`: Hermes API server base URL, for example `http://127.0.0.1:8642`
- `apiKey`: Hermes `API_SERVER_KEY`
The adapter creates Hermes runs with `POST /v1/runs`, streams
`/v1/runs/{run_id}/events`, polls `/v1/runs/{run_id}` as a fallback, and calls
`/v1/runs/{run_id}/stop` on timeout.
@@ -0,0 +1,81 @@
{
"name": "@paperclipai/adapter-hermes-gateway",
"version": "0.1.0",
"description": "External Paperclip adapter for Hermes Agent Gateway HTTP/SSE runs",
"type": "module",
"license": "MIT",
"author": "Paperclip",
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/hermes-gateway"
},
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"homepage": "https://github.com/paperclipai/paperclip/tree/master/packages/adapters/hermes-gateway#readme",
"keywords": [
"paperclip",
"hermes",
"gateway",
"adapter",
"orchestration"
],
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts",
"./ui-parser": "./ui-parser.cjs"
},
"paperclip": {
"adapterUiParser": "1.0.0"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
},
"./ui-parser": "./ui-parser.cjs"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"provenance": true
},
"files": [
"dist",
"ui-parser.cjs",
"README.md"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.19.21",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,13 @@
export function printHermesGatewayStreamEvent(line: string, debug: boolean): void {
const trimmed = line.trim();
if (!trimmed) return;
if (trimmed.startsWith("[hermes-gateway:event]")) {
console.log(trimmed);
return;
}
if (trimmed.startsWith("[hermes-gateway]")) {
console.log(trimmed);
return;
}
if (debug) console.log(line);
}
@@ -0,0 +1,2 @@
export { printHermesGatewayStreamEvent as formatStdoutEvent } from "./format-event.js";
@@ -0,0 +1,73 @@
import type { AdapterSessionManagement, ServerAdapterModule } from "@paperclipai/adapter-utils";
import { ADAPTER_LABEL, ADAPTER_TYPE } from "./shared/constants.js";
import { execute, getConfigSchema, sessionCodec, testEnvironment } from "./server/index.js";
export const type = ADAPTER_TYPE;
export const label = ADAPTER_LABEL;
export const models: { id: string; label: string }[] = [];
const sessionManagement: AdapterSessionManagement = {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
},
};
export const agentConfigurationDoc = `# hermes_gateway agent configuration
Adapter: hermes_gateway
Use when:
- Hermes Agent runs on another host or process that exposes the Hermes API server.
- Paperclip should create Hermes runs through POST /v1/runs and observe them through SSE events.
- You need remote Hermes session continuity with X-Hermes-Session-Key.
Don't use when:
- Hermes should run as a local child process on the Paperclip host; use hermes_local instead.
- The Hermes API server is not enabled or is only reachable over an unsafe public HTTP endpoint.
- You need Hermes-originated Paperclip task creation only; that is a Paperclip skill/API bridge, not this wake adapter.
Required fields:
- apiBaseUrl (string): Hermes API server base URL, for example http://127.0.0.1:8642.
- apiKey (string): Hermes API_SERVER_KEY. Sent as Authorization: Bearer <apiKey>.
Optional fields:
- headers (object or JSON string): extra noncritical headers. Authorization, Content-Type, Accept, Idempotency-Key, and X-Hermes-Session-Key are generated by the adapter.
- paperclipApiUrl (string): Paperclip API URL reachable from the Hermes host. This is not a credential.
- sessionKeyStrategy (issue | agent | run | none): defaults to issue.
- timeoutSec (number): defaults to 120.
- eventReconnectMs (number): defaults to 2000.
- instructions (string): stable Hermes instructions sent separately from wake input.
Runtime mapping:
- Creates runs with POST /v1/runs.
- Sends Idempotency-Key equal to the Paperclip run id for correlation only; Hermes v0.16.0 did not dedupe duplicate creates.
- Streams GET /v1/runs/{run_id}/events and polls GET /v1/runs/{run_id} as fallback.
- Calls POST /v1/runs/{run_id}/stop on timeout.
Security guidance:
- Prefer HTTPS or a private overlay network for non-loopback hosts.
- Do not put Hermes apiKey or Paperclip bearer tokens in prompts, comments, logs, or result JSON.
- Keep the default issue-scoped session strategy unless shared agent memory is intentional.
`;
export function createServerAdapter(): ServerAdapterModule {
return {
type,
execute,
testEnvironment,
sessionCodec,
sessionManagement,
models,
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc,
getConfigSchema,
};
}
@@ -0,0 +1,68 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import { DEFAULT_EVENT_RECONNECT_MS, DEFAULT_TIMEOUT_SEC } from "../shared/constants.js";
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "apiBaseUrl",
label: "API base URL",
type: "text",
required: true,
hint: "Hermes API server base URL, such as http://127.0.0.1:8642 or a private HTTPS URL.",
},
{
key: "apiKey",
label: "API key",
type: "text",
required: true,
hint: "Hermes API_SERVER_KEY. Stored in adapter config; avoid sharing exported configs.",
meta: { secret: true },
},
{
key: "sessionKeyStrategy",
label: "Session key strategy",
type: "select",
default: "issue",
options: [
{ value: "issue", label: "Issue scoped" },
{ value: "agent", label: "Agent scoped" },
{ value: "run", label: "Run scoped" },
{ value: "none", label: "None" },
],
hint: "Controls X-Hermes-Session-Key. Issue scoped prevents cross-task memory bleed by default.",
},
{
key: "timeoutSec",
label: "Timeout seconds",
type: "number",
default: DEFAULT_TIMEOUT_SEC,
},
{
key: "eventReconnectMs",
label: "Event reconnect ms",
type: "number",
default: DEFAULT_EVENT_RECONNECT_MS,
hint: "Delay before reconnecting the Hermes SSE events stream after a nonterminal disconnect.",
},
{
key: "paperclipApiUrl",
label: "Paperclip API URL",
type: "text",
hint: "Optional Paperclip API URL reachable by the remote Hermes host. This is not a credential.",
},
{
key: "headers",
label: "Extra headers",
type: "textarea",
hint: "Optional JSON object of extra nonsecret headers. Security-critical headers are generated by the adapter.",
},
{
key: "instructions",
label: "Instructions",
type: "textarea",
hint: "Optional stable Hermes instructions sent separately from the wake input.",
},
],
};
}
@@ -0,0 +1,218 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { execute, mapFinalResultForTest, parseSseFramesForTest, resolveSessionKey } from "./execute.js";
function makeCtx(config: Record<string, unknown>): AdapterExecutionContext {
return {
runId: "pc-run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Hermes",
adapterType: "hermes_gateway",
adapterConfig: config,
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config,
context: {
issueId: "issue-1",
wakeReason: "manual",
paperclipWake: {
issue: { identifier: "PAP-1", title: "Do the thing" },
},
},
onLog: vi.fn(async () => undefined),
onMeta: vi.fn(async () => undefined),
};
}
function sseStream(text: string): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("resolveSessionKey", () => {
it("derives issue-scoped session keys by default", () => {
expect(
resolveSessionKey({
strategy: "issue",
companyId: "company-1",
agentId: "agent-1",
runId: "run-1",
issueId: "issue-1",
}),
).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1");
});
it("omits the session key for none strategy", () => {
expect(
resolveSessionKey({
strategy: "none",
companyId: "company-1",
agentId: "agent-1",
runId: "run-1",
issueId: "issue-1",
}),
).toBeNull();
});
});
describe("parseSseFramesForTest", () => {
it("parses event and data lines while preserving partial frames", () => {
const parsed = parseSseFramesForTest("event: message.delta\ndata: {\"delta\":\"hi\"}\n\n:data\ndata: later");
expect(parsed.frames).toEqual([{ event: "message.delta", data: "{\"delta\":\"hi\"}" }]);
expect(parsed.rest).toBe(":data\ndata: later");
});
});
describe("execute", () => {
it("constructs POST /v1/runs with auth, idempotency, and Hermes session headers", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/v1/runs")) {
return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 });
}
if (url.endsWith("/events")) {
return new Response(
sseStream(
[
"event: message.delta",
"data: {\"delta\":\"done\"}",
"",
"event: run.completed",
"data: {\"status\":\"completed\",\"output\":\"done\",\"session_id\":\"session-1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2},\"model\":\"hermes-agent\"}",
"",
].join("\n"),
),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
return new Response(JSON.stringify({ status: "completed", output: "done" }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const result = await execute(makeCtx({
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: "secret-key",
timeoutSec: 5,
}));
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("done");
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 2 });
const calls = fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>;
const createCall = calls.find(([input]) => String(input).endsWith("/v1/runs"));
expect(createCall).toBeTruthy();
const init = createCall?.[1] as RequestInit;
expect(init.headers).toMatchObject({
Authorization: "Bearer secret-key",
"Content-Type": "application/json",
"Idempotency-Key": "pc-run-1",
"X-Hermes-Session-Key": "paperclip:company:company-1:agent:agent-1:issue:issue-1",
});
const body = JSON.parse(String(init.body));
expect(body.input).toContain("Do the thing");
expect(body.session_id).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1");
});
it("falls back to polling when SSE is unavailable", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/v1/runs")) {
return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 });
}
if (url.endsWith("/events")) {
return new Response("no stream", { status: 503 });
}
return new Response(JSON.stringify({
status: "completed",
output: "polled done",
session_id: "session-polled",
}), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const result = await execute(makeCtx({
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: "secret-key",
timeoutSec: 5,
pollIntervalMs: 250,
}));
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("polled done");
expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/v1/runs/run-hermes-1"))).toBe(true);
});
it("maps HTTP auth failures", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "bad key" }), { status: 401 })));
const result = await execute(makeCtx({
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: "secret-key",
}));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("hermes_gateway_auth_failed");
});
it("calls stop on timeout", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/v1/runs")) {
return new Response(JSON.stringify({ run_id: "run-slow", status: "started" }), { status: 200 });
}
if (url.endsWith("/events")) {
return new Promise<Response>(() => {});
}
if (url.endsWith("/stop")) {
return new Response(JSON.stringify({ status: "stopping" }), { status: 200 });
}
if (init?.method === "GET") {
return new Response(JSON.stringify({ status: "cancelled", last_event: "run.cancelled" }), { status: 200 });
}
return new Response(JSON.stringify({ status: "running" }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const result = await execute(makeCtx({
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: "secret-key",
timeoutSec: 0.001,
}));
expect(result.timedOut).toBe(true);
expect(result.errorCode).toBe("hermes_gateway_timeout");
expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/stop"))).toBe(true);
});
});
describe("mapFinalResultForTest", () => {
it("maps failed statuses into adapter errors", () => {
const result = mapFinalResultForTest({
terminal: {
runId: "run-1",
status: "failed",
payload: { status: "failed", error: "boom" },
},
outputChunks: [],
sessionKey: "session-key",
strategy: "issue",
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("hermes_gateway_run_failed");
expect(result.errorMessage).toBe("boom");
});
});
@@ -0,0 +1,843 @@
import type {
AdapterExecutionContext,
AdapterExecutionResult,
UsageSummary,
} from "@paperclipai/adapter-utils";
import {
asNumber,
asString,
parseObject,
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
stringifyPaperclipWakePayload,
} from "@paperclipai/adapter-utils/server-utils";
import {
ADAPTER_TYPE,
DEFAULT_EVENT_RECONNECT_MS,
DEFAULT_POLL_INTERVAL_MS,
DEFAULT_TIMEOUT_SEC,
STOP_GRACE_MS,
} from "../shared/constants.js";
type SessionKeyStrategy = "issue" | "agent" | "run" | "none";
type SseFrame = {
event: string | null;
data: string;
};
type HermesHttpError = Error & {
status?: number;
code?: string;
retryNotBefore?: string | null;
body?: unknown;
};
type TerminalState = {
runId: string;
status: string;
eventName?: string | null;
payload?: Record<string, unknown> | null;
output?: string | null;
};
type ExecutionState = {
runId: string;
outputChunks: string[];
lastEventName: string | null;
terminal: TerminalState | null;
resolveTerminal: (state: TerminalState) => void;
terminalPromise: Promise<TerminalState>;
};
const CRITICAL_HEADERS = new Set([
"authorization",
"content-type",
"accept",
"idempotency-key",
"x-hermes-session-key",
]);
const SENSITIVE_KEY_PATTERN =
/(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)/i;
const TERMINAL_STATUSES = new Set([
"completed",
"failed",
"error",
"cancelled",
"canceled",
"stopped",
"interrupted",
]);
const FAILURE_STATUSES = new Set(["failed", "error"]);
const CANCELLED_STATUSES = new Set(["cancelled", "canceled", "stopped", "interrupted"]);
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseNonNegativeNumber(value: unknown, fallback: number): number {
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number.parseFloat(value)
: Number.NaN;
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, parsed);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
const raw = asString(value, "issue").trim().toLowerCase();
if (raw === "agent" || raw === "run" || raw === "none") return raw;
return "issue";
}
function normalizeBaseUrl(value: string): URL | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
url.pathname = url.pathname.replace(/\/+$/, "");
url.search = "";
url.hash = "";
return url;
} catch {
return null;
}
}
function apiUrl(baseUrl: URL, path: string): string {
const base = baseUrl.toString().replace(/\/+$/, "");
return `${base}${path}`;
}
function issueIdFromContext(ctx: AdapterExecutionContext): string | null {
return nonEmpty(ctx.context.taskId) ?? nonEmpty(ctx.context.issueId);
}
export function resolveSessionKey(input: {
strategy: SessionKeyStrategy;
companyId: string;
agentId: string;
runId: string;
issueId: string | null;
}): string | null {
if (input.strategy === "none") return null;
if (input.strategy === "agent") {
return `paperclip:company:${input.companyId}:agent:${input.agentId}`;
}
if (input.strategy === "run") {
return `paperclip:run:${input.runId}`;
}
const issuePart = input.issueId ? `issue:${input.issueId}` : `run:${input.runId}`;
return `paperclip:company:${input.companyId}:agent:${input.agentId}:${issuePart}`;
}
function stringifyForLog(value: unknown, maxChars = 4_000): string {
const text = JSON.stringify(value);
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown {
const key = keyPath[keyPath.length - 1] ?? "";
if (typeof value === "string") {
if (SENSITIVE_KEY_PATTERN.test(key)) return `[redacted len=${value.length}]`;
return value.length > 500 ? `${value.slice(0, 500)}... [truncated ${value.length - 500} chars]` : value;
}
if (value == null || typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
if (depth > 5) return "[array-truncated]";
return value.slice(0, 40).map((entry, index) => redactForLog(entry, [...keyPath, String(index)], depth + 1));
}
if (typeof value === "object") {
if (depth > 5) return "[object-truncated]";
const out: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>).slice(0, 80)) {
out[entryKey] = redactForLog(entryValue, [...keyPath, entryKey], depth + 1);
}
return out;
}
return String(value);
}
function parseHeaders(value: unknown): Record<string, string> {
const source =
typeof value === "string" && value.trim().length > 0
? (() => {
try {
return JSON.parse(value);
} catch {
return {};
}
})()
: value;
const parsed = parseObject(source);
const headers: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
const normalized = key.trim();
if (!normalized || CRITICAL_HEADERS.has(normalized.toLowerCase())) continue;
if (typeof entry === "string") headers[normalized] = entry;
}
return headers;
}
function buildHeaders(input: {
apiKey: string;
sessionKey: string | null;
runId: string;
extraHeaders: Record<string, string>;
accept: string;
contentType?: string;
}): Record<string, string> {
return {
...input.extraHeaders,
Authorization: `Bearer ${input.apiKey}`,
Accept: input.accept,
...(input.contentType ? { "Content-Type": input.contentType } : {}),
"Idempotency-Key": input.runId,
...(input.sessionKey ? { "X-Hermes-Session-Key": input.sessionKey } : {}),
};
}
function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null): string {
const wakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake);
const wakePayloadJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
const taskMarkdown = nonEmpty(ctx.context.paperclipTaskMarkdown);
const sessionHandoff = nonEmpty(ctx.context.paperclipSessionHandoffMarkdown);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context);
const lines = [
`You are ${ctx.agent.name}, an AI agent employee in a Paperclip-managed company.`,
"",
"Paperclip runtime identity:",
`- Agent ID: ${ctx.agent.id}`,
`- Company ID: ${ctx.agent.companyId}`,
`- Run ID: ${ctx.runId}`,
...(paperclipApiUrl ? [`- Paperclip API URL: ${paperclipApiUrl}`] : []),
...(issueWorkMode ? [`- Issue work mode: ${issueWorkMode}`] : []),
"",
"Execution contract:",
"- Take concrete action in this run when the task is actionable.",
"- Do not stop at a plan unless the issue asks for planning only.",
"- Leave durable progress and update the issue to a clear final disposition.",
"- Use X-Paperclip-Run-Id on mutating Paperclip API requests when a Paperclip API key is available.",
"",
wakePrompt,
...(sessionHandoff ? ["", sessionHandoff] : []),
...(taskMarkdown ? ["", taskMarkdown] : []),
...(wakePayloadJson
? [
"",
"Structured wake payload JSON:",
"```json",
wakePayloadJson,
"```",
]
: []),
];
return lines.filter((line) => line !== null && line !== undefined).join("\n").trim();
}
function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null): Record<string, unknown> {
const paperclipApiUrl = nonEmpty(ctx.config.paperclipApiUrl);
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
const input = nonEmpty(payloadTemplate.input) ?? buildInput(ctx, paperclipApiUrl);
const instructions =
nonEmpty(ctx.config.instructions) ??
nonEmpty(payloadTemplate.instructions) ??
"Follow the Paperclip wake instructions exactly. Do not expose secrets in logs, comments, or final output.";
return {
...payloadTemplate,
input,
instructions,
...(sessionKey ? { session_id: sessionKey } : {}),
};
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return { text };
}
}
function classifyHttpError(status: number): { code: string; family: AdapterExecutionResult["errorFamily"] | null } {
if (status === 401 || status === 403) return { code: "hermes_gateway_auth_failed", family: null };
if (status === 404) return { code: "hermes_gateway_runs_unsupported", family: null };
if (status === 429) return { code: "hermes_gateway_rate_limited", family: "transient_upstream" };
if (status >= 500) return { code: "hermes_gateway_upstream_error", family: "transient_upstream" };
return { code: "hermes_gateway_protocol_error", family: null };
}
async function fetchJson(input: RequestInfo | URL, init: RequestInit): Promise<unknown> {
let response: Response;
try {
response = await fetch(input, init);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const fetchErr = new Error(`Hermes gateway request failed: ${message}`) as HermesHttpError;
fetchErr.code = "hermes_gateway_connect_failed";
throw fetchErr;
}
const body = await readResponseJson(response);
if (!response.ok) {
const classified = classifyHttpError(response.status);
const err = new Error(`Hermes gateway HTTP ${response.status}`) as HermesHttpError;
err.status = response.status;
err.code = classified.code;
err.retryNotBefore = response.headers.get("retry-after");
err.body = body;
throw err;
}
return body;
}
function extractRunId(value: unknown): string | null {
const record = asRecord(value);
return nonEmpty(record?.run_id) ?? nonEmpty(record?.runId) ?? nonEmpty(record?.id);
}
function eventNameFromData(data: unknown, fallback: string | null): string | null {
const record = asRecord(data);
return nonEmpty(record?.event) ?? nonEmpty(record?.type) ?? fallback;
}
function parseJsonData(data: string): unknown {
try {
return JSON.parse(data);
} catch {
return { text: data };
}
}
export function parseSseFramesForTest(buffer: string): { frames: SseFrame[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const frames: SseFrame[] = [];
let offset = 0;
while (true) {
const idx = normalized.indexOf("\n\n", offset);
if (idx < 0) break;
const rawFrame = normalized.slice(offset, idx);
offset = idx + 2;
let event: string | null = null;
const dataLines: string[] = [];
for (const line of rawFrame.split("\n")) {
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (dataLines.length > 0) frames.push({ event, data: dataLines.join("\n") });
}
return { frames, rest: normalized.slice(offset) };
}
function createExecutionState(runId: string): ExecutionState {
let resolveTerminal!: (state: TerminalState) => void;
const terminalPromise = new Promise<TerminalState>((resolve) => {
resolveTerminal = resolve;
});
return {
runId,
outputChunks: [],
lastEventName: null,
terminal: null,
resolveTerminal,
terminalPromise,
};
}
function markTerminal(state: ExecutionState, terminal: TerminalState): void {
if (state.terminal) return;
state.terminal = terminal;
state.resolveTerminal(terminal);
}
function extractStatus(value: unknown): string | null {
const record = asRecord(value);
return nonEmpty(record?.status)?.toLowerCase() ?? null;
}
function extractOutput(value: unknown): string | null {
const record = asRecord(value);
if (!record) return null;
const direct =
nonEmpty(record.output) ??
nonEmpty(record.result) ??
nonEmpty(record.text) ??
nonEmpty(record.summary) ??
nonEmpty(record.message);
if (direct) return direct;
const nested = asRecord(record.data) ?? asRecord(record.payload);
return nested ? extractOutput(nested) : null;
}
async function handleEvent(
ctx: AdapterExecutionContext,
state: ExecutionState,
frame: SseFrame,
): Promise<void> {
const parsed = parseJsonData(frame.data);
const record = asRecord(parsed);
const eventName = eventNameFromData(parsed, frame.event);
state.lastEventName = eventName;
await ctx.onLog(
"stdout",
`[hermes-gateway:event] run=${state.runId} event=${eventName ?? "message"} data=${stringifyForLog(redactForLog(parsed), 8_000)}\n`,
);
const delta = nonEmpty(record?.delta) ?? nonEmpty(record?.text_delta);
if (eventName === "message.delta" && delta) {
state.outputChunks.push(delta);
await ctx.onLog("stdout", delta);
}
const status = extractStatus(parsed) ?? (eventName?.startsWith("run.") ? eventName.slice(4) : null);
if (status && TERMINAL_STATUSES.has(status)) {
markTerminal(state, {
runId: state.runId,
status,
eventName,
payload: record,
output: extractOutput(parsed),
});
}
}
async function delay(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
}
async function pollStatus(input: {
ctx: AdapterExecutionContext;
baseUrl: URL;
headers: Record<string, string>;
state: ExecutionState;
signal: AbortSignal;
intervalMs: number;
}): Promise<void> {
while (!input.signal.aborted && !input.state.terminal) {
await delay(input.intervalMs, input.signal);
if (input.signal.aborted || input.state.terminal) break;
try {
const status = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.state.runId)}`), {
method: "GET",
headers: input.headers,
signal: input.signal,
});
const normalized = extractStatus(status);
if (normalized && TERMINAL_STATUSES.has(normalized)) {
markTerminal(input.state, {
runId: input.state.runId,
status: normalized,
payload: asRecord(status),
output: extractOutput(status),
});
}
} catch (err) {
if (input.signal.aborted) return;
await input.ctx.onLog("stderr", `[hermes-gateway] status poll failed: ${redactErrorMessage(err)}\n`);
}
}
}
async function consumeEvents(input: {
ctx: AdapterExecutionContext;
baseUrl: URL;
headers: Record<string, string>;
state: ExecutionState;
signal: AbortSignal;
reconnectMs: number;
}): Promise<void> {
while (!input.signal.aborted && !input.state.terminal) {
try {
const response = await fetch(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.state.runId)}/events`), {
method: "GET",
headers: input.headers,
signal: input.signal,
});
if (!response.ok) {
await input.ctx.onLog("stderr", `[hermes-gateway] event stream HTTP ${response.status}; falling back to polling\n`);
await delay(input.reconnectMs, input.signal);
continue;
}
if (!response.body) {
await input.ctx.onLog("stderr", "[hermes-gateway] event stream response had no body; falling back to polling\n");
await delay(input.reconnectMs, input.signal);
continue;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!input.signal.aborted && !input.state.terminal) {
const { value, done } = await reader.read();
if (done) {
if (buffer.trim().length > 0) {
const parsed = parseSseFramesForTest(`${buffer}\n\n`);
buffer = parsed.rest;
for (const frame of parsed.frames) {
await handleEvent(input.ctx, input.state, frame);
if (input.state.terminal) break;
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseFramesForTest(buffer);
buffer = parsed.rest;
for (const frame of parsed.frames) {
await handleEvent(input.ctx, input.state, frame);
if (input.state.terminal) break;
}
}
} catch (err) {
if (input.signal.aborted || input.state.terminal) return;
await input.ctx.onLog("stderr", `[hermes-gateway] event stream disconnected: ${redactErrorMessage(err)}\n`);
}
if (!input.state.terminal) await delay(input.reconnectMs, input.signal);
}
}
function parseUsage(value: unknown): UsageSummary | undefined {
const record = asRecord(value);
if (!record) return undefined;
const source = asRecord(record.usage) ?? record;
const inputTokens = asNumber(source.input_tokens ?? source.inputTokens ?? source.input, 0);
const outputTokens = asNumber(source.output_tokens ?? source.outputTokens ?? source.output, 0);
const cachedInputTokens = asNumber(source.cached_input_tokens ?? source.cachedInputTokens, 0);
if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) return undefined;
return {
inputTokens,
outputTokens,
...(cachedInputTokens > 0 ? { cachedInputTokens } : {}),
};
}
function parseCostUsd(value: unknown): number | null {
const record = asRecord(value);
const raw = record?.cost_usd ?? record?.costUsd ?? asRecord(record?.usage)?.cost_usd ?? asRecord(record?.usage)?.costUsd;
const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : Number.NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function extractSessionId(value: unknown): string | null {
const record = asRecord(value);
return nonEmpty(record?.session_id) ?? nonEmpty(record?.sessionId) ?? nonEmpty(asRecord(record?.data)?.session_id);
}
function extractModel(value: unknown): string | null {
const record = asRecord(value);
return nonEmpty(record?.model) ?? nonEmpty(asRecord(record?.usage)?.model);
}
function extractErrorMessage(value: unknown): string | null {
const record = asRecord(value);
return nonEmpty(record?.error) ?? nonEmpty(record?.message) ?? nonEmpty(record?.detail) ?? extractOutput(value);
}
function terminalResultCode(status: string): { exitCode: number; signal: string | null; errorCode: string | null } {
if (status === "completed") return { exitCode: 0, signal: null, errorCode: null };
if (FAILURE_STATUSES.has(status)) return { exitCode: 1, signal: null, errorCode: "hermes_gateway_run_failed" };
if (CANCELLED_STATUSES.has(status)) return { exitCode: 1, signal: "SIGTERM", errorCode: "hermes_gateway_cancelled" };
return { exitCode: 1, signal: null, errorCode: "hermes_gateway_protocol_error" };
}
export function mapFinalResultForTest(input: {
terminal: TerminalState;
outputChunks: string[];
sessionKey: string | null;
strategy: SessionKeyStrategy;
}): AdapterExecutionResult {
const payload = input.terminal.payload ?? {};
const output = input.terminal.output ?? extractOutput(payload) ?? input.outputChunks.join("").trim();
const sessionId = extractSessionId(payload) ?? input.sessionKey;
const mapped = terminalResultCode(input.terminal.status);
const usage = parseUsage(payload);
const costUsd = parseCostUsd(payload);
return {
exitCode: mapped.exitCode,
signal: mapped.signal,
timedOut: false,
provider: "hermes_gateway",
model: extractModel(payload),
...(mapped.errorCode ? { errorCode: mapped.errorCode } : {}),
...(mapped.errorCode ? { errorMessage: extractErrorMessage(payload) ?? `Hermes run ${input.terminal.status}` } : {}),
...(usage ? { usage } : {}),
...(costUsd !== null ? { costUsd } : {}),
...(output ? { summary: output.slice(0, 2_000) } : {}),
sessionId,
sessionParams: {
hermesRunId: input.terminal.runId,
...(sessionId ? { hermesSessionId: sessionId } : {}),
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
strategy: input.strategy,
},
sessionDisplayId: sessionId,
resultJson: {
run_id: input.terminal.runId,
status: input.terminal.status,
session_id: sessionId,
last_event: input.terminal.eventName ?? null,
output: output ?? "",
usage: usage ?? null,
cost_usd: costUsd,
},
};
}
async function stopRun(input: {
ctx: AdapterExecutionContext;
baseUrl: URL;
headers: Record<string, string>;
runId: string;
}): Promise<Record<string, unknown> | null> {
try {
const stopped = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.runId)}/stop`), {
method: "POST",
headers: input.headers,
});
await input.ctx.onLog("stdout", `[hermes-gateway] stop requested for run ${input.runId}\n`);
return asRecord(stopped);
} catch (err) {
await input.ctx.onLog("stderr", `[hermes-gateway] stop request failed: ${redactErrorMessage(err)}\n`);
return null;
}
}
async function fetchFinalStatus(input: {
baseUrl: URL;
headers: Record<string, string>;
runId: string;
deadlineMs: number;
}): Promise<Record<string, unknown> | null> {
const deadline = Date.now() + input.deadlineMs;
while (Date.now() < deadline) {
try {
const status = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.runId)}`), {
method: "GET",
headers: input.headers,
});
const record = asRecord(status);
const normalized = extractStatus(status);
if (normalized && TERMINAL_STATUSES.has(normalized)) return record;
} catch {
return null;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return null;
}
function redactErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
return String(err).replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
}
function errorResult(err: unknown): AdapterExecutionResult {
const hermesError = err as HermesHttpError;
const code = hermesError.code ?? "hermes_gateway_protocol_error";
const classified = hermesError.status ? classifyHttpError(hermesError.status) : null;
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: code,
errorFamily: classified?.family ?? (code === "hermes_gateway_connect_failed" ? "transient_upstream" : null),
retryNotBefore: hermesError.retryNotBefore ?? null,
errorMessage: redactErrorMessage(err),
errorMeta: {
...(hermesError.status ? { status: hermesError.status } : {}),
...(hermesError.body ? { body: redactForLog(hermesError.body) as Record<string, unknown> } : {}),
},
};
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const apiBaseUrlValue = asString(ctx.config.apiBaseUrl ?? ctx.config.url, "").trim();
if (!apiBaseUrlValue) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: "hermes_gateway_api_base_url_missing",
errorMessage: "Hermes gateway adapter requires apiBaseUrl.",
};
}
const baseUrl = normalizeBaseUrl(apiBaseUrlValue);
if (!baseUrl) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: "hermes_gateway_api_base_url_invalid",
errorMessage: `Invalid Hermes gateway apiBaseUrl: ${apiBaseUrlValue}`,
};
}
const apiKey = nonEmpty(ctx.config.apiKey) ?? nonEmpty(ctx.config.token);
if (!apiKey) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: "hermes_gateway_api_key_missing",
errorMessage: "Hermes gateway adapter requires apiKey.",
};
}
const timeoutSec = parseNonNegativeNumber(ctx.config.timeoutSec, DEFAULT_TIMEOUT_SEC);
const timeoutMs = timeoutSec > 0 ? Math.ceil(timeoutSec * 1000) : 0;
const reconnectMs = Math.floor(clamp(parseNonNegativeNumber(ctx.config.eventReconnectMs, DEFAULT_EVENT_RECONNECT_MS), 250, 30_000));
const pollIntervalMs = Math.floor(clamp(parseNonNegativeNumber(ctx.config.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS), 250, 10_000));
const strategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
const sessionKey = resolveSessionKey({
strategy,
companyId: ctx.agent.companyId,
agentId: ctx.agent.id,
runId: ctx.runId,
issueId: issueIdFromContext(ctx),
});
const extraHeaders = parseHeaders(ctx.config.headers);
const runHeaders = buildHeaders({
apiKey,
sessionKey,
runId: ctx.runId,
extraHeaders,
accept: "application/json",
contentType: "application/json",
});
const eventHeaders = buildHeaders({
apiKey,
sessionKey,
runId: ctx.runId,
extraHeaders,
accept: "text/event-stream",
});
const body = buildRunBody(ctx, sessionKey);
await ctx.onMeta?.({
adapterType: ADAPTER_TYPE,
command: "POST /v1/runs",
commandArgs: [baseUrl.origin, "/v1/runs"],
context: {
runId: ctx.runId,
timeoutSec,
eventReconnectMs: reconnectMs,
sessionKeyStrategy: strategy,
hasSessionKey: Boolean(sessionKey),
},
});
await ctx.onLog("stdout", `[hermes-gateway] creating run at ${baseUrl.origin}/v1/runs (timeout=${timeoutSec}s, session=${strategy})\n`);
await ctx.onLog("stdout", `[hermes-gateway] request headers (redacted): ${stringifyForLog(redactForLog(runHeaders), 3_000)}\n`);
let runId: string | null = null;
try {
const created = await fetchJson(apiUrl(baseUrl, "/v1/runs"), {
method: "POST",
headers: runHeaders,
body: JSON.stringify(body),
});
runId = extractRunId(created);
if (!runId) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: "hermes_gateway_protocol_error",
errorMessage: "Hermes /v1/runs response did not include run_id.",
errorMeta: { response: redactForLog(created) as Record<string, unknown> },
};
}
} catch (err) {
return errorResult(err);
}
await ctx.onLog("stdout", `[hermes-gateway] run created: ${runId}\n`);
const state = createExecutionState(runId);
const controller = new AbortController();
void consumeEvents({
ctx,
baseUrl,
headers: eventHeaders,
state,
signal: controller.signal,
reconnectMs,
}).catch(() => undefined);
void pollStatus({
ctx,
baseUrl,
headers: eventHeaders,
state,
signal: controller.signal,
intervalMs: pollIntervalMs,
}).catch(() => undefined);
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<"timeout">((resolve) => {
if (timeoutMs <= 0) return;
timeoutTimer = setTimeout(() => resolve("timeout"), timeoutMs);
});
const outcome = await Promise.race([state.terminalPromise, timeoutPromise]);
if (timeoutTimer) clearTimeout(timeoutTimer);
controller.abort();
if (outcome === "timeout") {
await stopRun({ ctx, baseUrl, headers: eventHeaders, runId });
const finalStatus = await fetchFinalStatus({ baseUrl, headers: eventHeaders, runId, deadlineMs: STOP_GRACE_MS });
return {
exitCode: 1,
signal: null,
timedOut: true,
errorCode: "hermes_gateway_timeout",
errorMessage: `Hermes gateway run timed out after ${timeoutSec}s.`,
provider: "hermes_gateway",
resultJson: {
run_id: runId,
status: extractStatus(finalStatus) ?? "timeout",
last_event: state.lastEventName,
final_status: redactForLog(finalStatus),
},
sessionParams: {
hermesRunId: runId,
...(sessionKey ? { sessionKey } : {}),
strategy,
},
sessionDisplayId: sessionKey,
};
}
return mapFinalResultForTest({
terminal: outcome,
outputChunks: state.outputChunks,
sessionKey,
strategy,
});
}
@@ -0,0 +1,46 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
export { execute, resolveSessionKey, parseSseFramesForTest, mapFinalResultForTest } from "./execute.js";
export { testEnvironment } from "./test.js";
export { getConfigSchema } from "./config-schema.js";
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const hermesSessionId = readString(record.hermesSessionId) ?? readString(record.sessionId);
const sessionKey = readString(record.sessionKey);
const hermesRunId = readString(record.hermesRunId);
const strategy = readString(record.strategy);
if (!hermesSessionId && !sessionKey && !hermesRunId) return null;
return {
...(hermesRunId ? { hermesRunId } : {}),
...(hermesSessionId ? { hermesSessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(strategy ? { strategy } : {}),
};
},
serialize(params) {
if (!params) return null;
const hermesSessionId = readString(params.hermesSessionId) ?? readString(params.sessionId);
const sessionKey = readString(params.sessionKey);
const hermesRunId = readString(params.hermesRunId);
const strategy = readString(params.strategy);
if (!hermesSessionId && !sessionKey && !hermesRunId) return null;
return {
...(hermesRunId ? { hermesRunId } : {}),
...(hermesSessionId ? { hermesSessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(strategy ? { strategy } : {}),
};
},
getDisplayId(params) {
if (!params) return null;
return readString(params.hermesSessionId) ?? readString(params.sessionKey) ?? readString(params.hermesRunId);
},
};
@@ -0,0 +1,114 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString } from "@paperclipai/adapter-utils/server-utils";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function normalizeBaseUrl(value: string): URL | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url;
} catch {
return null;
}
}
function isLoopback(hostname: string): boolean {
const value = hostname.toLowerCase();
return value === "localhost" || value === "127.0.0.1" || value === "::1";
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const apiBaseUrl = asString(ctx.config.apiBaseUrl ?? ctx.config.url, "").trim();
const apiKey = asString(ctx.config.apiKey ?? ctx.config.token, "").trim();
if (!apiBaseUrl) {
checks.push({
code: "hermes_gateway_api_base_url_missing",
level: "error",
message: "Hermes Gateway requires apiBaseUrl.",
hint: "Enable Hermes API server and set apiBaseUrl, for example http://127.0.0.1:8642.",
});
}
const parsed = apiBaseUrl ? normalizeBaseUrl(apiBaseUrl) : null;
if (apiBaseUrl && !parsed) {
checks.push({
code: "hermes_gateway_api_base_url_invalid",
level: "error",
message: "apiBaseUrl must be an http:// or https:// URL.",
});
}
if (!apiKey) {
checks.push({
code: "hermes_gateway_api_key_missing",
level: "error",
message: "Hermes Gateway requires apiKey.",
hint: "Set Hermes API_SERVER_KEY and copy the same value into adapterConfig.apiKey.",
});
}
if (parsed?.protocol === "http:" && !isLoopback(parsed.hostname)) {
checks.push({
code: "hermes_gateway_plain_http_remote",
level: "warn",
message: "Non-loopback HTTP exposes Hermes traffic without transport encryption.",
hint: "Prefer HTTPS or a private overlay network for remote Hermes hosts.",
});
}
if (checks.some((check) => check.level === "error") || !parsed || !apiKey) {
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
try {
const healthUrl = new URL("/health", parsed);
const response = await fetch(healthUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(2_000),
});
checks.push({
code: response.ok ? "hermes_gateway_health_ok" : "hermes_gateway_health_failed",
level: response.ok ? "info" : "warn",
message: response.ok
? "Hermes Gateway health endpoint is reachable."
: `Hermes Gateway health endpoint returned HTTP ${response.status}.`,
});
} catch (err) {
checks.push({
code: "hermes_gateway_health_unreachable",
level: "warn",
message: "Could not reach Hermes Gateway health endpoint.",
detail: err instanceof Error ? err.message : String(err),
});
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,8 @@
export const ADAPTER_TYPE = "hermes_gateway";
export const ADAPTER_LABEL = "Hermes Gateway";
export const DEFAULT_TIMEOUT_SEC = 120;
export const DEFAULT_EVENT_RECONNECT_MS = 2_000;
export const DEFAULT_POLL_INTERVAL_MS = 1_000;
export const STOP_GRACE_MS = 10_000;
@@ -0,0 +1,2 @@
export { parseHermesGatewayStdoutLine as parseStdoutLine } from "./parse-stdout.js";
@@ -0,0 +1,48 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
if (eventMatch) {
const eventName = eventMatch[2];
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data?.delta) || asString(data?.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data?.error) || asString(data?.message) || "Hermes run failed";
return [{ kind: "stderr", ts, text: message }];
}
if (eventName === "reasoning.available") {
return [{ kind: "thinking", ts, text: "Hermes reasoning available" }];
}
return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }];
}
if (trimmed.startsWith("[hermes-gateway]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -0,0 +1,50 @@
"use strict";
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value;
}
function asString(value) {
return typeof value === "string" ? value : "";
}
function parseStdoutLine(line, ts) {
const trimmed = line.trim();
if (!trimmed) return [];
const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s);
if (eventMatch) {
const eventName = eventMatch[2];
const data = asRecord(safeJsonParse(eventMatch[3]));
if (eventName === "message.delta") {
const delta = asString(data && data.delta) || asString(data && data.text_delta);
return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : [];
}
if (eventName === "run.failed" || eventName === "run.error") {
const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed";
return [{ kind: "stderr", ts, text: message }];
}
if (eventName === "reasoning.available") {
return [{ kind: "thinking", ts, text: "Hermes reasoning available" }];
}
return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }];
}
if (trimmed.startsWith("[hermes-gateway]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
}
module.exports = { parseStdoutLine };
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
exclude: ["dist/**", "node_modules/**"],
},
});
+5
View File
@@ -44,6 +44,11 @@
"name": "@paperclipai/hermes-paperclip-adapter",
"publishFromCi": true
},
{
"dir": "packages/adapters/hermes-gateway",
"name": "@paperclipai/adapter-hermes-gateway",
"publishFromCi": true
},
{
"dir": "packages/adapters/opencode-local",
"name": "@paperclipai/adapter-opencode-local",