mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters are the boundary between the control plane and the runtimes that actually do work. > - Hermes support needs to be available as first-class local and gateway adapters while still preserving the adapter-manager override path for external packages. > - The adapter work touches runtime execution, UI adapter metadata, onboarding prompts, scoped credentials, release packaging, and smoke coverage, so the handoff needs concrete verification rather than only unit tests. > - This pull request adds built-in Hermes local and Hermes gateway support, keeps external adapter overrides compatible, and documents/tests the gateway flow end to end. > - The benefit is that operators can hire Hermes-backed agents without a manual plugin install, while self-hosted installs can still override/shadow the built-ins through Adapter manager packages. ## Linked Issues or Issue Description No public GitHub issue exists for this exact Hermes built-in adapter, gateway onboarding, and release-source work. Problem description: - Hermes local and gateway adapters need a public, reviewable source path in the monorepo so package artifacts and built-in adapter behavior match the application source. - Operators need built-in `hermes_local` and `hermes_gateway` adapter choices without losing the ability to install external Hermes packages as overrides. - Gateway onboarding needs secure defaults for API server URLs, API keys, and generated agent setup text. - Hermes-originated task bridge credentials need narrower API-key scope configuration. - Related public PRs found during duplicate search include #3027, #2363, #7544, #7950, #8095, and #8543. ## What Changed - Added the unified Hermes adapter package with local and gateway server/UI/CLI exports, config schemas, transcript parsing, model detection, and package metadata. - Registered `hermes_local` and `hermes_gateway` as built-in adapters across shared constants, server registries, CLI packaging, and UI adapter registries. - Kept the external adapter override path compatible so installed Hermes packages can shadow built-ins and restore the built-in parser when disabled. - Added Hermes gateway onboarding docs, board-operator docs, Docker smoke assets, and shell smoke harnesses for join/e2e validation. - Added scoped task-bridge API-key support, authorization checks, issue-origin handling, and tests for Hermes-created Paperclip tasks. - Hardened gateway transport and redaction behavior for API keys, headers, session data, and smoke diagnostics. - Updated release packaging/bootstrap checks for the Hermes packages while leaving `pnpm-lock.yaml` out of the PR per repository policy. ## Verification Targeted local verification recorded before PR handoff: - `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run src/gateway/server/execute.test.ts` — 14/14 passed. - `pnpm test:hermes-gateway-smoke` — 6/6 passed. - Hermes package typecheck/build checks passed. - Focused server/UI adapter tests passed — 31/31. - Release helper Node tests passed — 18/18. - `git diff --check origin/master..HEAD` passed. Fresh Docker E2E smoke evidence: - Ran `pnpm smoke:hermes-gateway-e2e` on 2026-06-26 with a fresh state directory and fresh Docker container against a live Paperclip dev server. - Hermes direct execution reached `completed`. - Hermes stop/cancel path reached `cancelled`. - Hermes gateway created a Paperclip task, Paperclip ran the Hermes agent, and the task reached `done` with the expected marker response. - Temporary board auth keys, token files, smoke state, and Docker containers were cleaned up after the run. PR checks on head `b5eae40ce`: - GitHub Actions passed: `policy`, `review`, `Typecheck + Release Registry`, all general test shards, all serialized server shards, `Build`, `Canary Dry Run`, `e2e`, and aggregate `verify`. - External checks passed: Snyk and Socket Project Report. - External Socket Pull Request Alerts remained pending after the first-party CI matrix completed. ## Risks - Medium risk: this spans adapter registration, package publishing, gateway execution, onboarding docs, API-key scoping, and UI adapter metadata. - Migration risk is low: the scope-config migration adds a nullable column and does not rewrite existing keys. - Gateway execution depends on operator-provided Hermes API configuration; the smoke covers the Docker gateway path but real deployments may differ by network/auth setup. - Direct Greptile review on the latest expanded diff is file-count limited, although the commitperclip review gate passed. > 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 Codex, GPT-5 coding agent, tool use enabled in a local repository workspace. Context window size is not exposed in this environment. ## 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] Commitperclip review gate is green; direct Greptile review is file-count limited on the latest expanded diff - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
/**
|
|
* Server-side execution logic for the Hermes Agent adapter.
|
|
*
|
|
* Spawns `hermes chat -q "..." -Q` as a child process, streams output,
|
|
* and returns structured results to Paperclip.
|
|
*
|
|
* Verified CLI flags (hermes chat):
|
|
* -q/--query single query (non-interactive)
|
|
* -Q/--quiet quiet mode (no banner/spinner, only response + session_id)
|
|
* -m/--model model name (e.g. anthropic/claude-sonnet-4)
|
|
* -t/--toolsets comma-separated toolsets to enable
|
|
* --provider inference provider (auto, openrouter, nous, etc.)
|
|
* -r/--resume resume session by ID
|
|
* -w/--worktree isolated git worktree
|
|
* -v/--verbose verbose output
|
|
* --checkpoints filesystem checkpoints
|
|
* --yolo bypass dangerous-command approval prompts (agents have no TTY)
|
|
* --source session source tag for filtering
|
|
*/
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type {
|
|
AdapterExecutionContext,
|
|
AdapterExecutionResult,
|
|
UsageSummary,
|
|
} from "@paperclipai/adapter-utils";
|
|
|
|
import {
|
|
runChildProcess,
|
|
buildPaperclipEnv,
|
|
renderTemplate,
|
|
ensureAbsoluteDirectory,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
joinPromptSections,
|
|
renderPaperclipWakePrompt,
|
|
stringifyPaperclipWakePayload,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
import {
|
|
HERMES_CLI,
|
|
DEFAULT_TIMEOUT_SEC,
|
|
DEFAULT_GRACE_SEC,
|
|
DEFAULT_MODEL,
|
|
VALID_PROVIDERS,
|
|
} from "../shared/constants.js";
|
|
|
|
import {
|
|
detectModel,
|
|
resolveProvider,
|
|
} from "./detect-model.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function cfgString(v: unknown): string | undefined {
|
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
}
|
|
function cfgNumber(v: unknown): number | undefined {
|
|
return typeof v === "number" ? v : undefined;
|
|
}
|
|
function cfgBoolean(v: unknown): boolean | undefined {
|
|
return typeof v === "boolean" ? v : undefined;
|
|
}
|
|
function cfgStringArray(v: unknown): string[] | undefined {
|
|
return Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
? (v as string[])
|
|
: undefined;
|
|
}
|
|
|
|
export function resolveHermesCommand(config: Record<string, unknown>): string {
|
|
return cfgString(config.hermesCommand) || cfgString(config.command) || HERMES_CLI;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wake-up prompt builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HERMES_DEFAULT_PROMPT_TEMPLATE = [
|
|
'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.',
|
|
"",
|
|
"Paperclip runtime identity:",
|
|
"- Agent ID: {{agent.id}}",
|
|
"- Company ID: {{agent.companyId}}",
|
|
"- Run ID: {{run.id}}",
|
|
"- API base: {{paperclipApiUrl}}",
|
|
"",
|
|
"Paperclip API guidance:",
|
|
"- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.",
|
|
"- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.",
|
|
"- Displayed command logs may redact secrets; rely on environment variables instead of printed token values.",
|
|
"- Include `-H \"Authorization: Bearer $PAPERCLIP_API_KEY\"` on API requests.",
|
|
"- Include `-H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\"` on mutating issue requests.",
|
|
"- For multiline comments or status updates, preserve newlines with `jq --arg` or a heredoc-fed helper rather than hand-escaping JSON.",
|
|
"",
|
|
"Safe multiline update pattern:",
|
|
"```bash",
|
|
"api=\"${PAPERCLIP_API_URL%/}\"",
|
|
"case \"$api\" in */api) ;; *) api=\"$api/api\" ;; esac",
|
|
"",
|
|
"body=$(cat <<'MD'",
|
|
"Summary line",
|
|
"",
|
|
"- Detail one",
|
|
"- Detail two",
|
|
"MD",
|
|
")",
|
|
"jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\",
|
|
" curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\",
|
|
" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\",
|
|
" -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\",
|
|
" -H \"Content-Type: application/json\" \\",
|
|
" --data-binary @-",
|
|
"```",
|
|
"",
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
].join("\n");
|
|
|
|
function renderConditionalSections(template: string, vars: Record<string, unknown>): string {
|
|
const isTruthy = (key: string) => {
|
|
if (key === "noTask") return !vars.taskId;
|
|
const value = vars[key];
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
return Boolean(value);
|
|
};
|
|
return template.replace(
|
|
/\{\{#([a-zA-Z0-9_.-]+)\}\}([\s\S]*?)\{\{\/\1\}\}/g,
|
|
(_match, key: string, body: string) => (isTruthy(key) ? body : ""),
|
|
);
|
|
}
|
|
|
|
export function buildPrompt(
|
|
ctx: AdapterExecutionContext,
|
|
config: Record<string, unknown>,
|
|
options: { resumedSession?: boolean } = {},
|
|
): string {
|
|
const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE;
|
|
|
|
const context = (ctx as any).context || {};
|
|
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
|
|
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
|
|
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
|
|
const commentId = cfgString(context.commentId) || cfgString(context.wakeCommentId) || cfgString(ctx.config?.commentId) || "";
|
|
const wakeReason = cfgString(context.wakeReason) || cfgString(ctx.config?.wakeReason) || "";
|
|
const agentName = ctx.agent?.name || "Hermes Agent";
|
|
const companyName = cfgString(context.companyName) || cfgString(ctx.config?.companyName) || "";
|
|
const projectName = cfgString(context.projectName) || cfgString(ctx.config?.projectName) || "";
|
|
|
|
// Build API URL — ensure it has the /api path
|
|
let paperclipApiUrl =
|
|
cfgString(config.paperclipApiUrl) ||
|
|
process.env.PAPERCLIP_API_URL ||
|
|
"http://127.0.0.1:3100/api";
|
|
// Ensure /api suffix
|
|
if (!paperclipApiUrl.endsWith("/api")) {
|
|
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
|
|
}
|
|
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
|
resumedSession: options.resumedSession === true,
|
|
});
|
|
const paperclipTaskMarkdown = cfgString(context.paperclipTaskMarkdown)?.trim() || "";
|
|
const sessionHandoffMarkdown = cfgString(context.paperclipSessionHandoffMarkdown)?.trim() || "";
|
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake) || "";
|
|
|
|
const vars: Record<string, unknown> = {
|
|
agentId: ctx.agent?.id || "",
|
|
agentName,
|
|
companyId: ctx.agent?.companyId || "",
|
|
companyName,
|
|
runId: ctx.runId || "",
|
|
agent: ctx.agent || {},
|
|
company: { id: ctx.agent?.companyId || "", name: companyName },
|
|
run: { id: ctx.runId || "", source: "on_demand" },
|
|
context,
|
|
taskId: taskId || "",
|
|
taskTitle,
|
|
taskBody,
|
|
commentId,
|
|
wakeReason,
|
|
projectName,
|
|
paperclipApiUrl,
|
|
paperclipWakePrompt: wakePrompt,
|
|
paperclipTaskMarkdown,
|
|
taskContext: paperclipTaskMarkdown,
|
|
paperclipWakeJson: wakePayloadJson,
|
|
wakePayloadJson,
|
|
paperclipApiKeyEnv: "PAPERCLIP_API_KEY",
|
|
paperclipRunIdEnv: "PAPERCLIP_RUN_ID",
|
|
};
|
|
|
|
const rendered = renderTemplate(renderConditionalSections(template, vars), vars);
|
|
return joinPromptSections([
|
|
wakePrompt,
|
|
sessionHandoffMarkdown,
|
|
paperclipTaskMarkdown,
|
|
rendered,
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Regex to extract session ID from Hermes quiet-mode output: "session_id: <id>" */
|
|
const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
|
|
|
|
/** Regex for legacy session output format */
|
|
const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
|
|
|
|
/** Regex to extract token usage from Hermes output. */
|
|
const TOKEN_USAGE_REGEX =
|
|
/tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
|
|
|
|
/** Regex to extract cost from Hermes output. */
|
|
const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;
|
|
|
|
interface ParsedOutput {
|
|
sessionId?: string;
|
|
response?: string;
|
|
usage?: UsageSummary;
|
|
costUsd?: number;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response cleaning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Strip noise lines from a Hermes response (tool output, system messages, etc.) */
|
|
function cleanResponse(raw: string): string {
|
|
return raw
|
|
.split("\n")
|
|
.filter((line) => {
|
|
const t = line.trim();
|
|
if (!t) return true; // keep blank lines for paragraph separation
|
|
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]")) return false;
|
|
if (t.startsWith("session_id:")) return false;
|
|
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
|
|
if (/^\[done\]\s*┊/.test(t)) return false;
|
|
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
|
|
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
|
|
return true;
|
|
})
|
|
.map((line) => {
|
|
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
|
|
t = t.replace(/^\[done\]\s*/, "").trim();
|
|
return t;
|
|
})
|
|
.join("\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseHermesOutput(stdout: string, stderr: string): ParsedOutput {
|
|
const combined = stdout + "\n" + stderr;
|
|
const result: ParsedOutput = {};
|
|
|
|
// In quiet mode, Hermes outputs:
|
|
// <response text>
|
|
//
|
|
// session_id: <id>
|
|
const sessionMatch = stdout.match(SESSION_ID_REGEX);
|
|
if (sessionMatch?.[1]) {
|
|
result.sessionId = sessionMatch?.[1] ?? null;
|
|
// The response is everything before the session_id line
|
|
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
|
|
if (sessionLineIdx > 0) {
|
|
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
|
|
}
|
|
} else {
|
|
// Legacy format (non-quiet mode)
|
|
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
|
|
if (legacyMatch?.[1]) {
|
|
result.sessionId = legacyMatch?.[1] ?? null;
|
|
}
|
|
// In non-quiet mode, extract clean response from stdout by
|
|
// filtering out tool lines, system messages, and noise
|
|
const cleaned = cleanResponse(stdout);
|
|
if (cleaned.length > 0) {
|
|
result.response = cleaned;
|
|
}
|
|
}
|
|
|
|
// Extract token usage
|
|
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
|
|
if (usageMatch) {
|
|
result.usage = {
|
|
inputTokens: parseInt(usageMatch[1], 10) || 0,
|
|
outputTokens: parseInt(usageMatch[2], 10) || 0,
|
|
};
|
|
}
|
|
|
|
// Extract cost
|
|
const costMatch = combined.match(COST_REGEX);
|
|
if (costMatch?.[1]) {
|
|
result.costUsd = parseFloat(costMatch[1]);
|
|
}
|
|
|
|
// Check for error patterns in stderr
|
|
if (stderr.trim()) {
|
|
const errorLines = stderr
|
|
.split("\n")
|
|
.filter((line) => /error|exception|traceback|failed/i.test(line))
|
|
.filter((line) => !/INFO|DEBUG|warn/i.test(line)); // skip log-level noise
|
|
if (errorLines.length > 0) {
|
|
result.errorMessage = errorLines.slice(0, 5).join("\n");
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main execute
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function execute(
|
|
ctx: AdapterExecutionContext,
|
|
): Promise<AdapterExecutionResult> {
|
|
const config = (ctx.config ?? ctx.agent?.adapterConfig ?? {}) as Record<string, unknown>;
|
|
|
|
// ── Resolve configuration ──────────────────────────────────────────────
|
|
const hermesCmd = resolveHermesCommand(config);
|
|
const model = cfgString(config.model) || DEFAULT_MODEL;
|
|
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
|
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
|
const maxTurns = cfgNumber(config.maxTurnsPerRun);
|
|
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
|
const extraArgs = cfgStringArray(config.extraArgs);
|
|
const persistSession = cfgBoolean(config.persistSession) !== false;
|
|
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
|
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
|
const prevSessionId = cfgString(
|
|
(ctx.runtime?.sessionParams as Record<string, unknown> | null)?.sessionId,
|
|
);
|
|
|
|
// ── Resolve provider (defense in depth) ────────────────────────────────
|
|
// Priority chain:
|
|
// 1. Explicit provider in adapterConfig (user override)
|
|
// 2. Provider from ~/.hermes/config.yaml (detected at runtime)
|
|
// 3. Provider inferred from model name prefix
|
|
// 4. "auto" (let Hermes decide)
|
|
//
|
|
// This ensures that even if the agent was created before provider tracking
|
|
// was added, or if the model was changed without updating provider, the
|
|
// correct provider is still used.
|
|
let detectedConfig: Awaited<ReturnType<typeof detectModel>> | null = null;
|
|
const explicitProvider = cfgString(config.provider);
|
|
|
|
if (!explicitProvider) {
|
|
try {
|
|
detectedConfig = await detectModel();
|
|
} catch {
|
|
// Non-fatal — detection failure shouldn't block execution
|
|
}
|
|
}
|
|
|
|
const { provider: resolvedProvider, resolvedFrom } = resolveProvider({
|
|
explicitProvider,
|
|
detectedProvider: detectedConfig?.provider,
|
|
detectedModel: detectedConfig?.model,
|
|
detectedBaseUrl: detectedConfig?.baseUrl,
|
|
detectedHasApiKey: detectedConfig?.hasApiKey,
|
|
detectedApiMode: detectedConfig?.apiMode,
|
|
model,
|
|
});
|
|
|
|
// ── Load agent instructions file (Paperclip instruction bundles) ──────
|
|
// Paperclip can materialize managed instructions into instructionsFilePath;
|
|
// when present, inject that bundle into the Hermes prompt.
|
|
const instructionsFilePath = cfgString(config.instructionsFilePath);
|
|
let agentInstructions = "";
|
|
if (instructionsFilePath) {
|
|
try {
|
|
agentInstructions = await fs.readFile(instructionsFilePath, "utf-8");
|
|
const loadedInstructionsLength = agentInstructions.length;
|
|
const instructionsFileDir = path.dirname(instructionsFilePath);
|
|
agentInstructions += `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}/.`;
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[hermes] Loaded agent instructions from ${instructionsFilePath} (${loadedInstructionsLength} chars)\n`,
|
|
);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
// Non-fatal: log to stdout with an explicit "Warning:" prefix so the
|
|
// Paperclip UI doesn't render this as a red error (stderr output is
|
|
// surfaced as an error signal even when execution continues).
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[hermes] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Build prompt ───────────────────────────────────────────────────────
|
|
let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) });
|
|
if (agentInstructions) {
|
|
prompt = agentInstructions + "\n\n---\n\n" + prompt;
|
|
}
|
|
|
|
// ── Build command args ─────────────────────────────────────────────────
|
|
// Use -Q (quiet) to get clean output: just response + session_id line
|
|
const useQuiet = cfgBoolean(config.quiet) === true; // default false
|
|
const args: string[] = ["chat", "-q", prompt];
|
|
if (useQuiet) args.push("-Q");
|
|
|
|
if (model) {
|
|
args.push("-m", model);
|
|
}
|
|
|
|
// Always pass --provider when we have a resolved one (not "auto").
|
|
// "auto" means Hermes will decide on its own — no need to pass it.
|
|
if (resolvedProvider !== "auto") {
|
|
args.push("--provider", resolvedProvider);
|
|
}
|
|
|
|
if (toolsets) {
|
|
args.push("-t", toolsets);
|
|
}
|
|
|
|
if (maxTurns && maxTurns > 0) {
|
|
args.push("--max-turns", String(maxTurns));
|
|
}
|
|
|
|
if (worktreeMode) args.push("-w");
|
|
if (checkpoints) args.push("--checkpoints");
|
|
if (cfgBoolean(config.verbose) === true) args.push("-v");
|
|
|
|
// Tag sessions as "tool" source so they don't clutter the user's session history.
|
|
// Requires hermes-agent >= PR #3255 (feat/session-source-tag).
|
|
args.push("--source", "tool");
|
|
|
|
// Bypass Hermes dangerous-command approval prompts.
|
|
// Paperclip agents run as non-interactive subprocesses with no TTY,
|
|
// so approval prompts would always timeout and deny legitimate commands
|
|
// (curl, python3 -c, etc.). Agents operate in a sandbox — the approval
|
|
// system is designed for human-attended interactive sessions.
|
|
args.push("--yolo");
|
|
|
|
if (persistSession && prevSessionId) {
|
|
args.push("--resume", prevSessionId);
|
|
}
|
|
|
|
if (extraArgs?.length) {
|
|
args.push(...extraArgs);
|
|
}
|
|
|
|
// ── Build environment ──────────────────────────────────────────────────
|
|
const userEnv = config.env as Record<string, string> | undefined;
|
|
const env: Record<string, string> = {
|
|
...(process.env as Record<string, string>),
|
|
...(userEnv && typeof userEnv === "object" ? userEnv : {}),
|
|
...buildPaperclipEnv(ctx.agent),
|
|
};
|
|
|
|
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
|
|
|
// BUG FIX: Inject authToken as PAPERCLIP_API_KEY (matches adapter-claude-local behavior)
|
|
if ((ctx as any).authToken) env.PAPERCLIP_API_KEY = (ctx as any).authToken;
|
|
|
|
// BUG FIX: Read task context from ctx.context (wake context), not ctx.config (adapter config)
|
|
const ctxContext = (ctx as any).context || {};
|
|
const envTaskId = cfgString(ctxContext.taskId) || cfgString(ctxContext.issueId) || cfgString(ctx.config?.taskId);
|
|
if (envTaskId) env.PAPERCLIP_TASK_ID = envTaskId;
|
|
const envWakeReason = cfgString(ctxContext.wakeReason) || cfgString(ctx.config?.wakeReason);
|
|
if (envWakeReason) env.PAPERCLIP_WAKE_REASON = envWakeReason;
|
|
const envCommentId = cfgString(ctxContext.commentId) || cfgString(ctxContext.wakeCommentId) || cfgString(ctx.config?.commentId);
|
|
if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId;
|
|
const wakePayloadJson = stringifyPaperclipWakePayload(ctxContext.paperclipWake);
|
|
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
|
|
|
// ── Resolve working directory ──────────────────────────────────────────
|
|
const cwd =
|
|
cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
|
|
try {
|
|
await ensureAbsoluteDirectory(cwd);
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
|
|
// ── Log start ──────────────────────────────────────────────────────────
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`,
|
|
);
|
|
if (prevSessionId) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[hermes] Resuming session: ${prevSessionId}\n`,
|
|
);
|
|
}
|
|
|
|
// ── Execute ────────────────────────────────────────────────────────────
|
|
// Hermes writes non-error noise to stderr (MCP init, INFO logs, etc).
|
|
// Paperclip renders all stderr as red/error in the UI.
|
|
// Wrap onLog to reclassify benign stderr lines as stdout.
|
|
const wrappedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
|
if (stream === "stderr") {
|
|
const trimmed = chunk.trimEnd();
|
|
// Benign patterns that should NOT appear as errors:
|
|
// - Structured log lines: [timestamp] INFO/DEBUG/WARN: ...
|
|
// - MCP server registration messages
|
|
// - Python import/site noise
|
|
const isBenign = /^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) || // structured timestamps
|
|
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) || // log levels
|
|
/Successfully registered all tools/.test(trimmed) ||
|
|
/MCP [Ss]erver/.test(trimmed) ||
|
|
/tool registered successfully/.test(trimmed) ||
|
|
/Application initialized/.test(trimmed);
|
|
if (isBenign) {
|
|
return ctx.onLog("stdout", chunk);
|
|
}
|
|
}
|
|
return ctx.onLog(stream, chunk);
|
|
};
|
|
|
|
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog: wrappedOnLog,
|
|
});
|
|
|
|
// ── Parse output ───────────────────────────────────────────────────────
|
|
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[hermes] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
|
|
);
|
|
if (parsed.sessionId) {
|
|
await ctx.onLog("stdout", `[hermes] Session: ${parsed.sessionId}\n`);
|
|
}
|
|
|
|
// ── Build result ───────────────────────────────────────────────────────
|
|
const executionResult: AdapterExecutionResult = {
|
|
exitCode: result.exitCode,
|
|
signal: result.signal,
|
|
timedOut: result.timedOut,
|
|
provider: resolvedProvider,
|
|
model,
|
|
};
|
|
|
|
if (parsed.errorMessage) {
|
|
executionResult.errorMessage = parsed.errorMessage;
|
|
}
|
|
|
|
if (parsed.usage) {
|
|
executionResult.usage = parsed.usage;
|
|
}
|
|
|
|
if (parsed.costUsd !== undefined) {
|
|
executionResult.costUsd = parsed.costUsd;
|
|
}
|
|
|
|
// Summary from agent response
|
|
if (parsed.response) {
|
|
executionResult.summary = parsed.response.slice(0, 2000);
|
|
}
|
|
|
|
// Set resultJson so Paperclip can persist run metadata (used for UI display + auto-comments)
|
|
executionResult.resultJson = {
|
|
result: parsed.response || "",
|
|
session_id: parsed.sessionId || null,
|
|
usage: parsed.usage || null,
|
|
cost_usd: parsed.costUsd ?? null,
|
|
};
|
|
|
|
// Store session ID for next run
|
|
if (persistSession && parsed.sessionId) {
|
|
executionResult.sessionParams = { sessionId: parsed.sessionId };
|
|
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
|
|
}
|
|
|
|
return executionResult;
|
|
}
|