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 <help@zocomputer.com>
Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 10:39:23 -07:00
1 parent 4f399cec9f
commit 2145925f71
2 files changed
+70 -3

No files matched your search

@@ -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");
+18 -1
View File
@@ -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, unknown>): 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<string, unknown> | undefined;
const promptConfig: Record<string, unknown> = { ...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