mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Add Hermes gateway adapter plugin
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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/**"],
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user