From 2145925f71a206ccf2963636e1326a377e5747ca Mon Sep 17 00:00:00 2001 From: Tiangao <53409436+tiangao88@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:39:23 +0800 Subject: [PATCH] fix: use Hermes adapter API URL env in prompt (#9425) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Hermes adapter builds the command environment and renders the prompt that tells Hermes how to call back into Paperclip. > - Adapter configuration can provide `PAPERCLIP_API_URL` through `config.env`, which is often the most precise API base for the spawned worker. > - The current prompt rendering path does not read that adapter env value when deciding which Paperclip API base to show. > - This can leave Hermes with prompt instructions that point at a less appropriate process-level or public URL, even though the adapter env contains the intended API base. > - This pull request makes prompt rendering honor the adapter env value before falling back to broader process defaults. > - The benefit is a more consistent Hermes prompt without changing command execution behavior. ## Linked Issues or Issue Description Adapter: Hermes Agent or provider Hermes local adapter. Why this adapter is useful The Hermes adapter lets Paperclip run Hermes-backed agents while preserving Paperclip run context and callback instructions. How the agent is invoked Paperclip spawns the configured Hermes command and passes a rendered prompt plus adapter-provided environment variables. What happened? When `PAPERCLIP_API_URL` is supplied through adapter env, the Hermes subprocess receives it, but the prompt can still render its API base from a process-level fallback instead of that configured adapter env value. Expected behavior If an explicit `paperclipApiUrl` option is not configured, the prompt should prefer `config.env.PAPERCLIP_API_URL` when present, then fall back to the existing defaults. Related context: supersedes NousResearch/hermes-paperclip-adapter#118 for Paperclip's bundled Hermes adapter. ## What Changed - Added a small env-value resolver for raw string values and `{ value: string }` wrappers. - Builds a prompt-specific config that uses `config.env.PAPERCLIP_API_URL` when `paperclipApiUrl` is not explicitly set. - Added a focused command-resolution test that verifies the rendered prompt API base uses the adapter env URL instead of the process fallback. ## Verification - `pnpm --filter @paperclipai/hermes-paperclip-adapter test:command-resolution` - `pnpm --filter @paperclipai/hermes-paperclip-adapter typecheck` - `pnpm --filter @paperclipai/hermes-paperclip-adapter build` ## Risks Low risk. This only affects prompt rendering for Hermes adapter runs where `paperclipApiUrl` is unset and `config.env.PAPERCLIP_API_URL` is present. Explicit `paperclipApiUrl` values keep precedence, and existing fallback behavior remains in place when the env value is absent. ## Model Used OpenAI Codex, GPT-5.5, tool-enabled coding session with local shell, Git, and GitHub CLI validation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: zocomputer Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com> --- .../src/server/command-resolution.test.ts | 54 ++++++++++++++++++- .../adapters/hermes/src/server/execute.ts | 19 ++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/adapters/hermes/src/server/command-resolution.test.ts b/packages/adapters/hermes/src/server/command-resolution.test.ts index 2b8b1a97ed3..9512b78fcea 100644 --- a/packages/adapters/hermes/src/server/command-resolution.test.ts +++ b/packages/adapters/hermes/src/server/command-resolution.test.ts @@ -1,10 +1,10 @@ import os from "node:os"; import path from "node:path"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { expect, test } from "vitest"; import { HERMES_CLI } from "../shared/constants.js"; -import { resolveHermesCommand } from "./execute.js"; +import { execute, resolveHermesCommand } from "./execute.js"; import { testEnvironment } from "./test.js"; test("resolveHermesCommand prefers hermesCommand over command", () => { @@ -47,6 +47,56 @@ test("testEnvironment accepts config.command when hermesCommand is absent", asyn } }); +test("execute renders prompt API base from adapter env PAPERCLIP_API_URL", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-prompt-api-url-")); + const cliPath = path.join(tempDir, "fake-hermes"); + const argvPath = path.join(tempDir, "argv.txt"); + const originalApiUrl = process.env.PAPERCLIP_API_URL; + + try { + process.env.PAPERCLIP_API_URL = "https://public.example.com/api"; + await writeFile( + cliPath, + [ + "#!/bin/sh", + `printf '%s\\n' "$@" > ${JSON.stringify(argvPath)}`, + "printf 'done\\n\\nsession_id: test-session\\n'", + "", + ].join("\n"), + "utf8", + ); + await chmod(cliPath, 0o755); + + await execute({ + runId: "run-test", + agent: { + id: "agent-test", + name: "Hermes Test Agent", + companyId: "company-test", + }, + config: { + hermesCommand: cliPath, + env: { + PAPERCLIP_API_URL: { type: "plain", value: "http://127.0.0.1:3101" }, + }, + }, + runtime: {}, + onLog: async () => {}, + } as any); + + const argv = await readFile(argvPath, "utf8"); + expect(argv).toContain("- API base: http://127.0.0.1:3101/api"); + expect(argv).not.toContain("https://public.example.com/api"); + } finally { + if (originalApiUrl === undefined) { + delete process.env.PAPERCLIP_API_URL; + } else { + process.env.PAPERCLIP_API_URL = originalApiUrl; + } + await rm(tempDir, { recursive: true, force: true }); + } +}); + test("managed connections probe the selected environment without falling back to host keys", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-managed-probe-")); const cliPath = path.join(tempDir, "fake-hermes"); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 98a5d85846e..b018acab243 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -72,6 +72,14 @@ function cfgStringArray(v: unknown): string[] | undefined { ? (v as string[]) : undefined; } +function cfgEnvString(v: unknown): string | undefined { + if (typeof v === "string" && v.length > 0) return v; + if (v && typeof v === "object" && "value" in v) { + const value = (v as { value?: unknown }).value; + return typeof value === "string" && value.length > 0 ? value : undefined; + } + return undefined; +} export function resolveHermesCommand(config: Record): string { return cfgString(config.hermesCommand) || cfgString(config.command) || HERMES_CLI; @@ -432,8 +440,17 @@ export async function execute( } // ── Build prompt ─────────────────────────────────────────────────────── + const userEnvForPrompt = config.env as Record | undefined; + const promptConfig: Record = { ...config }; + if (!cfgString(promptConfig.paperclipApiUrl) && userEnvForPrompt && typeof userEnvForPrompt === "object") { + const configuredPaperclipApiUrl = cfgEnvString(userEnvForPrompt.PAPERCLIP_API_URL); + if (configuredPaperclipApiUrl) { + promptConfig.paperclipApiUrl = configuredPaperclipApiUrl; + } + } + const sessionId = persistSession ? prevSessionId : undefined; - const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) }); + const prompt = buildPrompt(ctx, promptConfig, { resumedSession: Boolean(sessionId) }); // ── Build command args ───────────────────────────────────────────────── // Use -Q (quiet) to get clean output: just response + session_id line