From ed86920ffa628338fa9fe7cb6db9d1926e0461bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 03:05:30 +0000 Subject: [PATCH] Add Hermes gateway adapter plugin Co-Authored-By: Paperclip --- packages/adapters/hermes-gateway/README.md | 16 + packages/adapters/hermes-gateway/package.json | 81 ++ .../hermes-gateway/src/cli/format-event.ts | 13 + .../adapters/hermes-gateway/src/cli/index.ts | 2 + packages/adapters/hermes-gateway/src/index.ts | 73 ++ .../src/server/config-schema.ts | 68 ++ .../hermes-gateway/src/server/execute.test.ts | 218 +++++ .../hermes-gateway/src/server/execute.ts | 843 ++++++++++++++++++ .../hermes-gateway/src/server/index.ts | 46 + .../hermes-gateway/src/server/test.ts | 114 +++ .../hermes-gateway/src/shared/constants.ts | 8 + .../adapters/hermes-gateway/src/ui/index.ts | 2 + .../hermes-gateway/src/ui/parse-stdout.ts | 48 + .../adapters/hermes-gateway/tsconfig.json | 8 + .../adapters/hermes-gateway/ui-parser.cjs | 50 ++ .../adapters/hermes-gateway/vitest.config.ts | 8 + scripts/release-package-manifest.json | 5 + 17 files changed, 1603 insertions(+) create mode 100644 packages/adapters/hermes-gateway/README.md create mode 100644 packages/adapters/hermes-gateway/package.json create mode 100644 packages/adapters/hermes-gateway/src/cli/format-event.ts create mode 100644 packages/adapters/hermes-gateway/src/cli/index.ts create mode 100644 packages/adapters/hermes-gateway/src/index.ts create mode 100644 packages/adapters/hermes-gateway/src/server/config-schema.ts create mode 100644 packages/adapters/hermes-gateway/src/server/execute.test.ts create mode 100644 packages/adapters/hermes-gateway/src/server/execute.ts create mode 100644 packages/adapters/hermes-gateway/src/server/index.ts create mode 100644 packages/adapters/hermes-gateway/src/server/test.ts create mode 100644 packages/adapters/hermes-gateway/src/shared/constants.ts create mode 100644 packages/adapters/hermes-gateway/src/ui/index.ts create mode 100644 packages/adapters/hermes-gateway/src/ui/parse-stdout.ts create mode 100644 packages/adapters/hermes-gateway/tsconfig.json create mode 100644 packages/adapters/hermes-gateway/ui-parser.cjs create mode 100644 packages/adapters/hermes-gateway/vitest.config.ts diff --git a/packages/adapters/hermes-gateway/README.md b/packages/adapters/hermes-gateway/README.md new file mode 100644 index 00000000000..c09b42d83af --- /dev/null +++ b/packages/adapters/hermes-gateway/README.md @@ -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. diff --git a/packages/adapters/hermes-gateway/package.json b/packages/adapters/hermes-gateway/package.json new file mode 100644 index 00000000000..c96a611278d --- /dev/null +++ b/packages/adapters/hermes-gateway/package.json @@ -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" + } +} diff --git a/packages/adapters/hermes-gateway/src/cli/format-event.ts b/packages/adapters/hermes-gateway/src/cli/format-event.ts new file mode 100644 index 00000000000..58705bc40a6 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/cli/format-event.ts @@ -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); +} diff --git a/packages/adapters/hermes-gateway/src/cli/index.ts b/packages/adapters/hermes-gateway/src/cli/index.ts new file mode 100644 index 00000000000..428df9534c6 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/cli/index.ts @@ -0,0 +1,2 @@ +export { printHermesGatewayStreamEvent as formatStdoutEvent } from "./format-event.js"; + diff --git a/packages/adapters/hermes-gateway/src/index.ts b/packages/adapters/hermes-gateway/src/index.ts new file mode 100644 index 00000000000..cdfeb3ec02d --- /dev/null +++ b/packages/adapters/hermes-gateway/src/index.ts @@ -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 . + +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, + }; +} + diff --git a/packages/adapters/hermes-gateway/src/server/config-schema.ts b/packages/adapters/hermes-gateway/src/server/config-schema.ts new file mode 100644 index 00000000000..4b631224f0c --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/config-schema.ts @@ -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.", + }, + ], + }; +} diff --git a/packages/adapters/hermes-gateway/src/server/execute.test.ts b/packages/adapters/hermes-gateway/src/server/execute.test.ts new file mode 100644 index 00000000000..7c67a2b8f89 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/execute.test.ts @@ -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): 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 { + return new ReadableStream({ + 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(() => {}); + } + 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"); + }); +}); diff --git a/packages/adapters/hermes-gateway/src/server/execute.ts b/packages/adapters/hermes-gateway/src/server/execute.ts new file mode 100644 index 00000000000..aa41253a6f1 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/execute.ts @@ -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 | null; + output?: string | null; +}; + +type ExecutionState = { + runId: string; + outputChunks: string[]; + lastEventName: string | null; + terminal: TerminalState | null; + resolveTerminal: (state: TerminalState) => void; + terminalPromise: Promise; +}; + +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 | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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 = {}; + for (const [entryKey, entryValue] of Object.entries(value as Record).slice(0, 80)) { + out[entryKey] = redactForLog(entryValue, [...keyPath, entryKey], depth + 1); + } + return out; + } + return String(value); +} + +function parseHeaders(value: unknown): Record { + const source = + typeof value === "string" && value.trim().length > 0 + ? (() => { + try { + return JSON.parse(value); + } catch { + return {}; + } + })() + : value; + const parsed = parseObject(source); + const headers: Record = {}; + 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; + accept: string; + contentType?: string; +}): Record { + 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 { + 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 { + 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 { + 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((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 { + 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 { + if (signal.aborted) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +async function pollStatus(input: { + ctx: AdapterExecutionContext; + baseUrl: URL; + headers: Record; + state: ExecutionState; + signal: AbortSignal; + intervalMs: number; +}): Promise { + 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; + state: ExecutionState; + signal: AbortSignal; + reconnectMs: number; +}): Promise { + 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; + runId: string; +}): Promise | 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; + runId: string; + deadlineMs: number; +}): Promise | 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 } : {}), + }, + }; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + 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 }, + }; + } + } 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 | 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, + }); +} diff --git a/packages/adapters/hermes-gateway/src/server/index.ts b/packages/adapters/hermes-gateway/src/server/index.ts new file mode 100644 index 00000000000..ad5c6ccb273 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/index.ts @@ -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; + 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); + }, +}; + diff --git a/packages/adapters/hermes-gateway/src/server/test.ts b/packages/adapters/hermes-gateway/src/server/test.ts new file mode 100644 index 00000000000..d487991ea81 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/test.ts @@ -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 { + 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(), + }; +} + diff --git a/packages/adapters/hermes-gateway/src/shared/constants.ts b/packages/adapters/hermes-gateway/src/shared/constants.ts new file mode 100644 index 00000000000..cf09a0b2796 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/shared/constants.ts @@ -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; + diff --git a/packages/adapters/hermes-gateway/src/ui/index.ts b/packages/adapters/hermes-gateway/src/ui/index.ts new file mode 100644 index 00000000000..ea90701fa49 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseHermesGatewayStdoutLine as parseStdoutLine } from "./parse-stdout.js"; + diff --git a/packages/adapters/hermes-gateway/src/ui/parse-stdout.ts b/packages/adapters/hermes-gateway/src/ui/parse-stdout.ts new file mode 100644 index 00000000000..8a9d53308ad --- /dev/null +++ b/packages/adapters/hermes-gateway/src/ui/parse-stdout.ts @@ -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 | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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 }]; +} + diff --git a/packages/adapters/hermes-gateway/tsconfig.json b/packages/adapters/hermes-gateway/tsconfig.json new file mode 100644 index 00000000000..e1b71318a68 --- /dev/null +++ b/packages/adapters/hermes-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/hermes-gateway/ui-parser.cjs b/packages/adapters/hermes-gateway/ui-parser.cjs new file mode 100644 index 00000000000..9c08a54a319 --- /dev/null +++ b/packages/adapters/hermes-gateway/ui-parser.cjs @@ -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 }; + diff --git a/packages/adapters/hermes-gateway/vitest.config.ts b/packages/adapters/hermes-gateway/vitest.config.ts new file mode 100644 index 00000000000..ad7d95889ad --- /dev/null +++ b/packages/adapters/hermes-gateway/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index edc68fb3d01..f33aaee8f85 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -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",