mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## 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>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { expect, test } from "vitest";
|
|
|
|
import { HERMES_CLI } from "../shared/constants.js";
|
|
import { execute, resolveHermesCommand } from "./execute.js";
|
|
import { testEnvironment } from "./test.js";
|
|
|
|
test("resolveHermesCommand prefers hermesCommand over command", () => {
|
|
expect(resolveHermesCommand({ hermesCommand: "hermes_maximus", command: "hermes_backup" }))
|
|
.toBe("hermes_maximus");
|
|
});
|
|
|
|
test("resolveHermesCommand falls back to command before default hermes binary", () => {
|
|
expect(resolveHermesCommand({ command: "hermes_maximus" })).toBe("hermes_maximus");
|
|
expect(resolveHermesCommand({})).toBe(HERMES_CLI);
|
|
});
|
|
|
|
test("testEnvironment accepts config.command when hermesCommand is absent", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-command-resolution-"));
|
|
const cliPath = path.join(tempDir, "fake-hermes");
|
|
|
|
try {
|
|
await writeFile(
|
|
cliPath,
|
|
"#!/bin/sh\necho fake-hermes 1.2.3\n",
|
|
"utf8",
|
|
);
|
|
await chmod(cliPath, 0o755);
|
|
|
|
const result = await testEnvironment({
|
|
companyId: "company-test",
|
|
adapterType: "hermes_local",
|
|
config: {
|
|
command: cliPath,
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe("fail");
|
|
expect(result.checks.some((check) => check.code === "hermes_cli_not_found")).toBe(false);
|
|
expect(result.checks.some(
|
|
(check) => check.code === "hermes_version" && check.message.includes("fake-hermes 1.2.3"),
|
|
)).toBe(true);
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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");
|
|
try {
|
|
await writeFile(cliPath, '#!/bin/sh\n[ "$HERMES_HOME" = "' + tempDir + '" ] || exit 1\n[ "$OPENAI_API_KEY" = "selected-key" ] || exit 1\n[ "$ANTHROPIC_API_KEY" = "" ] || exit 1\n[ "$1" = "chat" ] || exit 1\necho hello\n');
|
|
await chmod(cliPath, 0o755);
|
|
const config = { command: cliPath, managedAiRouting: true, model: "custom/model", provider: "auto", env: { HERMES_HOME: tempDir, OPENAI_API_KEY: "selected-key", ANTHROPIC_API_KEY: "" } };
|
|
const passed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config });
|
|
expect(passed.status).toBe("pass");
|
|
expect(passed.checks[0]?.code).toBe("hermes_hello_probe_passed");
|
|
const failed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config: { ...config, env: { ...config.env, OPENAI_API_KEY: "wrong-key" } } });
|
|
expect(failed.status).toBe("fail");
|
|
expect(failed.checks[0]?.code).toBe("hermes_hello_probe_failed");
|
|
expect(JSON.stringify(failed)).not.toContain("wrong-key");
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|