fix(adapters): pass Hermes custom providers as args (#8231)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters translate Paperclip configuration into the
CLI/runtime flags needed by each provider.
> - Hermes custom providers are represented as `custom:*` values, but
the adapter registry was not passing that provider value through as
Hermes CLI arguments.
> - The fix is isolated to adapter argument construction and its
regression tests.
> - This pull request extracts only the Hermes custom-provider
pass-through fix onto `origin/master`.
> - The benefit is a small adapter PR that can merge independently from
UI, skills catalog, and migration work.

## Linked Issues or Issue Description

No GitHub issue exists for this branch split. Internal source task:
[PAP-11234](/PAP/issues/PAP-11234).

Problem/motivation:
- Hermes `custom:*` providers need to reach the Hermes process as
`--provider <value>`.
- The adapter should preserve existing auth injection and avoid adding
duplicate provider flags when the user already supplied one.

Proposed solution:
- Detect Hermes `custom:*` provider values in adapter registry argument
construction.
- Add `--provider <custom value>` unless an explicit provider arg
already exists.
- Cover both spaced and equals-style existing provider args in
regression tests.

Related PR search:
- Found related Hermes adapter PRs such as #7544 and #3027, but none
duplicates this specific custom-provider arg pass-through behavior.

Roadmap alignment:
- Checked `ROADMAP.md`; no duplicate planned item was found for this
adapter fix.

## What Changed

- Updated Hermes adapter registration argument construction to pass
`custom:*` providers through `extraArgs` as `--provider <value>`.
- Preserved existing auth injection behavior.
- Avoided duplicate provider arguments when `--provider value` or
`--provider=value` is already present.
- Added adapter registry regression coverage.

## Verification

- `CI=true NODE_ENV=development pnpm install --frozen-lockfile
--prefer-offline`
- `pnpm exec vitest server/src/__tests__/adapter-registry.test.ts --run`
— 1 file, 20 tests passed.

## Risks

- Low risk; change is scoped to Hermes adapter argument construction.
- Review should confirm the provider flag syntax matches current Hermes
CLI expectations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via Paperclip `codex_local` / CodexCoder, GPT-5-class
coding model with tool use and shell execution. Exact runtime snapshot
and context-window setting were not exposed by the Paperclip run
context.

## 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 available from the run context)
- [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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A; no UI change)
- [x] I have updated relevant documentation to reflect my changes (N/A;
no docs behavior changed)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending on draft PR)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending follow-up loop)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-17 11:21:33 -05:00
committed by GitHub
parent 1b89a8e3b8
commit 6a684a5053
2 changed files with 122 additions and 2 deletions
@@ -61,12 +61,14 @@ describe("server adapter registry", () => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
setOverridePaused("hermes_local", true);
});
afterEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
setOverridePaused("hermes_local", false);
hermesExecuteMock.mockClear();
});
@@ -385,6 +387,105 @@ describe("server adapter registry", () => {
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
});
it("passes Hermes custom providers through extraArgs while injecting auth", async () => {
const adapter = requireServerAdapter("hermes_local");
await adapter.execute({
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "Hermes Agent",
role: "engineer",
adapterType: "hermes_local",
adapterConfig: {
provider: "custom:paperclip-openai",
extraArgs: ["--source", "tool"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.provider).toBe("custom:paperclip-openai");
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--source",
"tool",
"--provider",
"custom:paperclip-openai",
]);
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
});
it("does not duplicate an explicit Hermes provider extraArg", async () => {
const adapter = requireServerAdapter("hermes_local");
await adapter.execute({
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "Hermes Agent",
role: "engineer",
adapterType: "hermes_local",
adapterConfig: {
provider: "custom:paperclip-openai",
extraArgs: ["--provider=custom:paperclip-openai"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--provider=custom:paperclip-openai",
]);
});
it("does not duplicate spaced Hermes provider extraArgs", async () => {
const adapter = requireServerAdapter("hermes_local");
await adapter.execute({
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "Hermes Agent",
role: "engineer",
adapterType: "hermes_local",
adapterConfig: {
provider: "custom:paperclip-openai",
extraArgs: ["--provider", "custom:paperclip-openai"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--provider",
"custom:paperclip-openai",
]);
});
it("passes the original Hermes context through when authToken is absent", async () => {
const adapter = requireServerAdapter("hermes_local");
const ctx = {
+21 -2
View File
@@ -214,6 +214,24 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
return ctx;
}
function passHermesCustomProviderThroughExtraArgs(config: Record<string, unknown>): Record<string, unknown> {
const provider = typeof config.provider === "string" ? config.provider.trim() : "";
if (!provider.startsWith("custom:")) return config;
const existingExtraArgs = Array.isArray(config.extraArgs)
? config.extraArgs.filter((arg): arg is string => typeof arg === "string")
: [];
const alreadyHasProviderArg = existingExtraArgs.some((arg) =>
arg === "--provider" || arg.startsWith("--provider=")
);
if (alreadyHasProviderArg) return config;
return {
...config,
extraArgs: [...existingExtraArgs, "--provider", provider],
};
}
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const result: AdapterModel[] = [];
@@ -470,19 +488,20 @@ const hermesLocalAdapter: ServerAdapterModule = {
PAPERCLIP_RUN_ID: normalizedCtx.runId,
},
};
const effectivePatchedConfig = passHermesCustomProviderThroughExtraArgs(patchedConfig);
// Only inject the auth guard into promptTemplate when a custom template already exists.
// When no custom template is set, Hermes uses its built-in default heartbeat/task prompt —
// overwriting it with only the auth guard text would strip the assigned issue/workflow instructions.
if (promptTemplate) {
patchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`;
effectivePatchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`;
}
const patchedCtx = {
...normalizedCtx,
agent: {
...normalizedCtx.agent,
adapterConfig: patchedConfig,
adapterConfig: effectivePatchedConfig,
},
};