diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationHeader.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationHeader.tsx index e552c095a..427d6ffb0 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationHeader.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/ConversationHeader.tsx @@ -3,7 +3,10 @@ import type { FC } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers' -import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types' +import type { + HarnessAgent, + HarnessAgentAdapter, +} from '@/entrypoints/app/agents/agent-harness-types' import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips' import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers' import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types' @@ -14,7 +17,7 @@ import { cn } from '@/lib/utils' interface ConversationHeaderProps { agent: HarnessAgent | null fallbackName: string - fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown' + fallbackAdapter: HarnessAgentAdapter | 'unknown' adapterHealth: AgentAdapterHealth | null backLabel: string backTarget: 'home' | 'page' diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AdapterIcon.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AdapterIcon.tsx index 3b12c98d3..6ff574a15 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AdapterIcon.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AdapterIcon.tsx @@ -15,14 +15,13 @@ interface AdapterIconProps { export const AdapterIcon: FC = ({ adapter, className }) => { switch (adapter) { case 'claude': - // Claude Code — text-based agent, sparkles to evoke the "AI assistant" feel. return case 'codex': - // Codex — code-leaning, CPU mark. return case 'openclaw': - // OpenClaw — bot/automation framing. return + case 'hermes': + return default: return } @@ -36,6 +35,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string { return 'Codex' case 'openclaw': return 'OpenClaw' + case 'hermes': + return 'Hermes' default: return 'Agent' } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx index 2ad1b6547..aae4547c7 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx @@ -117,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' { if (lower === 'claude code') return 'claude' if (lower === 'codex') return 'codex' if (lower === 'openclaw') return 'openclaw' + if (lower === 'hermes') return 'hermes' return 'unknown' } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/NewAgentDialog.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/NewAgentDialog.tsx index 8e5cdcbe7..8f557b252 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/NewAgentDialog.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/NewAgentDialog.tsx @@ -23,6 +23,7 @@ import type { HarnessAgentAdapter, } from './agent-harness-types' import type { CreateAgentRuntime, ProviderOption } from './agents-page-types' +import { getHermesCliInstallPrompt } from './hermes-install-prompt' import { ProviderSelector } from './OpenClawControls' import { type OpenClawCliProvider, @@ -89,6 +90,17 @@ export const NewAgentDialog: FC = ({ }) => { const selectedHarnessAdapter = adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0] + const selectedCreateAdapter = + createRuntime === 'openclaw' + ? selectedHarnessAdapter + : (adapters.find((adapter) => adapter.id === createRuntime) ?? + selectedHarnessAdapter) + const hermesInstallPrompt = getHermesCliInstallPrompt({ + createRuntime, + selectedAdapter: selectedCreateAdapter, + }) + const harnessModels = selectedHarnessAdapter?.models ?? [] + const harnessReasoningEfforts = selectedHarnessAdapter?.reasoningEfforts ?? [] const isHarnessRuntime = createRuntime !== 'openclaw' const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw const cliBlocked = @@ -100,6 +112,7 @@ export const NewAgentDialog: FC = ({ !creating && !openClawBlocked && !cliBlocked && + !hermesInstallPrompt && (createRuntime === 'openclaw' ? providers.length > 0 : Boolean(selectedHarnessAdapter)) @@ -143,7 +156,8 @@ export const NewAgentDialog: FC = ({ if ( value === 'openclaw' || value === 'claude' || - value === 'codex' + value === 'codex' || + value === 'hermes' ) { onRuntimeChange(value) if (value !== 'openclaw') onHarnessAdapterChange(value) @@ -198,45 +212,70 @@ export const NewAgentDialog: FC = ({ {isHarnessRuntime ? ( <> -
- - -
+ {hermesInstallPrompt ? ( + + + {hermesInstallPrompt.title} + +
+

{hermesInstallPrompt.description}

+ + {hermesInstallPrompt.installCommand} + + + Hermes ACP setup + +
+
+
+ ) : null} -
- - + + + + + {harnessModels.map((model) => ( + + {model.label} + + ))} + + +
+ ) : null} + + {harnessReasoningEfforts.length > 1 ? ( +
+ + -
+ ))} + + + + ) : null} ) : null} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-display.helpers.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-display.helpers.ts index bdb57860a..7b32a201e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-display.helpers.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-display.helpers.ts @@ -56,7 +56,7 @@ export function canRename(_agent: AgentListItem): boolean { */ export function workspaceLabel(agent: AgentListItem): string | null { if (!agent.detail) return null - if (/^(claude|codex|openclaw):main$/.test(agent.detail)) return null + if (/^(claude|codex|openclaw|hermes):main$/.test(agent.detail)) return null return agent.detail } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts index e0eeeb14e..b4d7de93a 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts @@ -1,6 +1,6 @@ import type { AgentEntry } from './useOpenClaw' -export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' +export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes' export type AgentHarnessStreamEvent = | { diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts index c24f7db38..d3cd5f083 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-actions.ts @@ -140,6 +140,7 @@ export function createAgentPageActions(input: AgentPageActionInput) { openclaw: handleOpenClawCreate, claude: handleHarnessCreate, codex: handleHarnessCreate, + hermes: handleHarnessCreate, } void createByRuntime[input.createRuntime]() } diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts index 44101abf4..f011f7b1d 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts @@ -38,7 +38,16 @@ export function getRecoveryDetail(status: OpenClawStatus): string | null { } export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string { - return adapter === 'claude' ? 'Claude Code' : 'Codex' + switch (adapter) { + case 'claude': + return 'Claude Code' + case 'codex': + return 'Codex' + case 'openclaw': + return 'OpenClaw' + case 'hermes': + return 'Hermes' + } } export function toProviderOptions( diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.test.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.test.ts new file mode 100644 index 000000000..bc7069a4e --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test' +import { + getHermesCliInstallPrompt, + HERMES_ACP_DOCS_URL, +} from './hermes-install-prompt' + +describe('getHermesCliInstallPrompt', () => { + it('prompts for Hermes install when the Hermes adapter health is unhealthy', () => { + expect( + getHermesCliInstallPrompt({ + createRuntime: 'hermes', + selectedAdapter: { + id: 'hermes', + name: 'Hermes', + defaultModelId: 'default', + defaultReasoningEffort: 'default', + modelControl: 'best-effort', + models: [], + reasoningEfforts: [], + health: { + healthy: false, + reason: 'hermes acp --help failed: command not found', + checkedAt: 1000, + }, + }, + }), + ).toEqual({ + title: 'Hermes CLI not installed', + description: + 'hermes acp --help failed: command not found. Install Hermes normally, then add the ACP extra and make sure hermes is on PATH.', + docsUrl: HERMES_ACP_DOCS_URL, + installCommand: "pip install -e '.[acp]'", + }) + }) + + it('does not prompt for healthy or non-Hermes adapter selections', () => { + const hermesDescriptor = { + id: 'hermes' as const, + name: 'Hermes', + defaultModelId: 'default', + defaultReasoningEffort: 'default', + modelControl: 'best-effort' as const, + models: [], + reasoningEfforts: [], + health: { healthy: true, checkedAt: 1000 }, + } + + expect( + getHermesCliInstallPrompt({ + createRuntime: 'hermes', + selectedAdapter: hermesDescriptor, + }), + ).toBeNull() + expect( + getHermesCliInstallPrompt({ + createRuntime: 'codex', + selectedAdapter: { ...hermesDescriptor, id: 'codex', name: 'Codex' }, + }), + ).toBeNull() + expect( + getHermesCliInstallPrompt({ + createRuntime: 'hermes', + selectedAdapter: null, + }), + ).toBeNull() + expect( + getHermesCliInstallPrompt({ + createRuntime: 'hermes', + selectedAdapter: { + ...hermesDescriptor, + id: 'codex' as const, + name: 'Codex', + health: { healthy: false, checkedAt: 1000 }, + }, + }), + ).toBeNull() + }) +}) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.ts new file mode 100644 index 000000000..c8e48955c --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/hermes-install-prompt.ts @@ -0,0 +1,41 @@ +import type { HarnessAdapterDescriptor } from './agent-harness-types' +import type { CreateAgentRuntime } from './agents-page-types' + +export const HERMES_ACP_DOCS_URL = + 'https://hermes-agent.nousresearch.com/docs/user-guide/features/acp' + +export interface HermesCliInstallPrompt { + title: string + description: string + docsUrl: string + installCommand: string +} + +/** + * Returns the blocking install prompt for host-local Hermes ACP. + * BrowserOS launches `hermes acp`, so a missing host CLI has to be + * resolved before we create the persisted agent record. + */ +export function getHermesCliInstallPrompt(input: { + createRuntime: CreateAgentRuntime + selectedAdapter: HarnessAdapterDescriptor | null | undefined +}): HermesCliInstallPrompt | null { + const health = input.selectedAdapter?.health + if ( + input.createRuntime !== 'hermes' || + input.selectedAdapter?.id !== 'hermes' || + health?.healthy !== false + ) { + return null + } + + const reason = health.reason?.trim() + return { + title: 'Hermes CLI not installed', + description: `${ + reason ? `${reason}. ` : '' + }Install Hermes normally, then add the ACP extra and make sure hermes is on PATH.`, + docsUrl: HERMES_ACP_DOCS_URL, + installCommand: "pip install -e '.[acp]'", + } +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.test.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.test.ts index 0216a401b..6b26aa639 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.test.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { adapterLabel } from './AdapterIcon' import { buildAgentApiUrl } from './agent-api-url' import { mapHarnessAgentToEntry } from './agent-harness-types' @@ -24,6 +25,34 @@ describe('mapHarnessAgentToEntry', () => { source: 'agent-harness', }) }) + + it('maps Hermes harness agents into chat-compatible entries', () => { + expect( + mapHarnessAgentToEntry({ + id: 'agent-hermes', + name: 'Hermes bot', + adapter: 'hermes', + modelId: 'default', + reasoningEffort: 'default', + permissionMode: 'approve-all', + sessionKey: 'agent:agent-hermes:main', + createdAt: 1000, + updatedAt: 1000, + }), + ).toEqual({ + agentId: 'agent-hermes', + name: 'Hermes bot', + workspace: 'hermes:main', + model: 'default', + source: 'agent-harness', + }) + }) +}) + +describe('adapterLabel', () => { + it('labels Hermes harness agents consistently', () => { + expect(adapterLabel('hermes')).toBe('Hermes') + }) }) describe('buildAgentApiUrl', () => { diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.test.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.test.ts index aede953f9..3017f01c0 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.test.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.test.ts @@ -78,6 +78,15 @@ const adapters: HarnessAdapterDescriptor[] = [ { id: 'high', label: 'High' }, ], }, + { + id: 'hermes', + name: 'Hermes', + defaultModelId: 'default', + defaultReasoningEffort: 'default', + modelControl: 'best-effort', + models: [], + reasoningEfforts: [{ id: 'default', label: 'Default', recommended: true }], + }, ] const agents: HarnessAgent[] = [ @@ -103,6 +112,17 @@ const agents: HarnessAgent[] = [ createdAt: timestamp, updatedAt: timestamp, }, + { + id: 'agent-hermes', + name: 'Hermes Bot', + adapter: 'hermes', + modelId: 'default', + reasoningEffort: 'default', + permissionMode: 'approve-all', + sessionKey: 'agent:agent-hermes:main', + createdAt: timestamp, + updatedAt: timestamp, + }, ] describe('buildSidepanelChatTargets', () => { @@ -114,6 +134,7 @@ describe('buildSidepanelChatTargets', () => { 'anthropic-sonnet', 'agent-codex', 'agent-openclaw', + 'agent-hermes', ]) }) @@ -166,6 +187,39 @@ describe('buildSidepanelChatTargets', () => { }) }) + it('labels Hermes ACP targets from the adapter catalog', () => { + const targets = buildSidepanelChatTargets({ providers, adapters, agents }) + const hermes = targets.find((target) => target.id === 'agent-hermes') + + expect(hermes).toMatchObject({ + kind: 'acp', + agentId: 'agent-hermes', + adapter: 'hermes', + adapterName: 'Hermes', + modelId: 'default', + modelLabel: 'default', + modelControl: 'best-effort', + reasoningEffort: 'default', + reasoningEffortLabel: 'Default', + }) + }) + + it('uses the Hermes display name when catalog metadata is unavailable', () => { + const targets = buildSidepanelChatTargets({ + providers, + adapters: adapters.filter((adapter) => adapter.id !== 'hermes'), + agents: agents.filter((agent) => agent.id === 'agent-hermes'), + }) + + expect( + targets.find((target) => target.id === 'agent-hermes'), + ).toMatchObject({ + kind: 'acp', + adapter: 'hermes', + adapterName: 'Hermes', + }) + }) + it('still returns LLM targets when agents and adapters are unavailable', () => { expect( buildSidepanelChatTargets({ providers, adapters: [], agents: [] }), diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts index d704790f0..c503994b3 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/sidepanel-chat-targets.ts @@ -108,6 +108,7 @@ function formatAdapterName(adapter: HarnessAgentAdapter): string { if (adapter === 'claude') return 'Claude Code' if (adapter === 'codex') return 'Codex' if (adapter === 'openclaw') return 'OpenClaw' + if (adapter === 'hermes') return 'Hermes' return adapter } diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts index 973b28714..652311636 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts @@ -9,6 +9,7 @@ import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/open import type { AgentDefinition } from './agent-types' import { prepareClaudeCodeContext } from './claude-code/prepare' import { prepareCodexContext } from './codex/prepare' +import { prepareHermesContext } from './hermes/prepare' import { maybeHandleOpenClawTurn, prepareOpenClawContext, @@ -58,6 +59,7 @@ const ADAPTERS: Record = { prepare: prepareOpenClawContext, maybeHandleTurn: maybeHandleOpenClawTurn, }, + hermes: { prepare: prepareHermesContext }, } export function getAcpxAgentAdapter( diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts index 56d4be66d..57e4e6a58 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts @@ -740,6 +740,10 @@ function createBrowserosAgentRegistry(input: { ) } + if (lower === 'hermes') { + return wrapCommandWithEnv(resolveHermesAcpCommand(), input.commandEnv) + } + if (lower === 'claude' || lower === 'codex') { return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv) } @@ -749,6 +753,15 @@ function createBrowserosAgentRegistry(input: { } } +/** + * Resolves the host-local Hermes ACP command acpx will spawn. + * HERMES_ACP_COMMAND is intentionally a full command string so local + * development can point at an unpublished adapter without changing code. + */ +function resolveHermesAcpCommand(): string { + return process.env.HERMES_ACP_COMMAND?.trim() || 'hermes acp' +} + /** * Builds the command string acpx will spawn for an `openclaw` adapter. * Runs `openclaw acp` inside the gateway container via the bundled diff --git a/packages/browseros-agent/apps/server/src/lib/agents/adapter-health.ts b/packages/browseros-agent/apps/server/src/lib/agents/adapter-health.ts index b97a9eb4e..bd638fc66 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/adapter-health.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/adapter-health.ts @@ -23,6 +23,11 @@ interface CachedHealth extends AdapterHealth { expiresAt: number } +type ExecCommand = ( + command: string, + options: { timeout: number }, +) => Promise + /** * In-memory cache of adapter binary availability. Probed lazily on * first read and refreshed every `cacheTtlMs`. The probe is one @@ -37,10 +42,19 @@ export class AdapterHealthChecker { private readonly cacheTtlMs: number private readonly probeTimeoutMs: number private readonly inflight = new Map>() + private readonly execCommand: ExecCommand - constructor(options: { cacheTtlMs?: number; probeTimeoutMs?: number } = {}) { + constructor( + options: { + cacheTtlMs?: number + probeTimeoutMs?: number + execCommand?: ExecCommand + } = {}, + ) { this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1000 this.probeTimeoutMs = options.probeTimeoutMs ?? 2_000 + this.execCommand = + options.execCommand ?? ((command, options) => execAsync(command, options)) } async getHealth(adapter: AgentAdapter): Promise { @@ -84,7 +98,7 @@ export class AdapterHealthChecker { } } try { - await execAsync(command, { timeout: this.probeTimeoutMs }) + await this.execCommand(command, { timeout: this.probeTimeoutMs }) return { healthy: true, checkedAt: Date.now() } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -105,6 +119,7 @@ export class AdapterHealthChecker { const ADAPTER_HEALTH_COMMANDS: Partial> = { claude: 'claude --version', codex: 'codex --version', + hermes: 'hermes acp --help', } function friendlyProbeFailure(adapter: AgentAdapter, raw: string): string { diff --git a/packages/browseros-agent/apps/server/src/lib/agents/agent-catalog.ts b/packages/browseros-agent/apps/server/src/lib/agents/agent-catalog.ts index 0f7232577..39c2764ac 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/agent-catalog.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/agent-catalog.ts @@ -84,6 +84,15 @@ export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [ { id: 'adaptive', label: 'Adaptive' }, ], }, + { + id: 'hermes', + name: 'Hermes', + defaultModelId: 'default', + defaultReasoningEffort: 'default', + modelControl: 'best-effort', + models: [], + reasoningEfforts: [{ id: 'default', label: 'Default', recommended: true }], + }, ] export function getAgentAdapterDescriptor( @@ -93,7 +102,12 @@ export function getAgentAdapterDescriptor( } export function isAgentAdapter(value: unknown): value is AgentAdapter { - return value === 'claude' || value === 'codex' || value === 'openclaw' + return ( + value === 'claude' || + value === 'codex' || + value === 'openclaw' || + value === 'hermes' + ) } export function resolveDefaultModelId(adapter: AgentAdapter): string { diff --git a/packages/browseros-agent/apps/server/src/lib/agents/agent-types.ts b/packages/browseros-agent/apps/server/src/lib/agents/agent-types.ts index 79c515c13..f8133333e 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/agent-types.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/agent-types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -export type AgentAdapter = 'claude' | 'codex' | 'openclaw' +export type AgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes' export type AgentPermissionMode = 'approve-all' diff --git a/packages/browseros-agent/apps/server/src/lib/agents/hermes/prepare.ts b/packages/browseros-agent/apps/server/src/lib/agents/hermes/prepare.ts new file mode 100644 index 000000000..20a4903f5 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/agents/hermes/prepare.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + PrepareAcpxAgentContextInput, + PreparedAcpxAgentContext, +} from '../acpx-agent-adapter' +import { + finishBrowserosManagedContext, + prepareBrowserosManagedContext, +} from '../acpx-agent-common' + +/** Prepares Hermes ACP with BrowserOS agent home, skills, and MCP access. */ +export async function prepareHermesContext( + input: PrepareAcpxAgentContextInput, +): Promise { + const common = await prepareBrowserosManagedContext(input) + return finishBrowserosManagedContext({ + ...common, + commandEnv: { + AGENT_HOME: common.paths.agentHome, + }, + }) +} diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts index a1c510134..fc697e194 100644 --- a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts +++ b/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts @@ -19,7 +19,7 @@ export const agentDefinitions = sqliteTable( id: text('id').primaryKey(), name: text('name').notNull(), adapter: text('adapter', { - enum: ['claude', 'codex', 'openclaw'], + enum: ['claude', 'codex', 'openclaw', 'hermes'], }).notNull(), modelId: text('model_id').notNull(), reasoningEffort: text('reasoning_effort').notNull(), diff --git a/packages/browseros-agent/apps/server/tests/api/routes/agents.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/agents.test.ts index faa07be3e..ff63ea4bf 100644 --- a/packages/browseros-agent/apps/server/tests/api/routes/agents.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/routes/agents.test.ts @@ -632,6 +632,42 @@ describe('createAgentRoutes', () => { error: `Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`, }) }) + + it('creates Hermes harness agents with default model and reasoning', async () => { + const agents: AgentDefinition[] = [] + const route = createMountedRoutes(agents) + + const response = await route.request('/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Hermes bot', adapter: 'hermes' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + agent: { + name: 'Hermes bot', + adapter: 'hermes', + }, + }) + }) + + it('rejects invalid Hermes reasoning efforts', async () => { + const route = createMountedRoutes([]) + + const response = await route.request('/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Hermes bot', + adapter: 'hermes', + reasoningEffort: 'medium', + }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Invalid reasoningEffort' }) + }) }) function createMountedRoutes( @@ -696,7 +732,7 @@ function createFakeService(agents: AgentDefinition[]) { }, async createAgent(input: { name: string - adapter: 'claude' | 'codex' | 'openclaw' + adapter: AgentDefinition['adapter'] modelId?: string reasoningEffort?: string }) { diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-agent-adapter.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-agent-adapter.test.ts index 36dc29609..7738bffe5 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-agent-adapter.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-agent-adapter.test.ts @@ -84,6 +84,39 @@ describe('prepareAcpxAgentContext', () => { expect(prepared.runPrompt).toContain('AGENT_HOME=') }) + it('prepares Hermes with BrowserOS memory, skills, BrowserOS MCP, and fingerprinted session', async () => { + const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-adapters-')) + tempDirs.push(browserosDir) + const prepared = await prepareAcpxAgentContext({ + browserosDir, + agent: makeAgent('hermes'), + sessionId: 'main', + sessionKey: 'agent:hermes-agent:main', + cwdOverride: null, + isSelectedCwd: false, + message: 'use hermes', + }) + + expect(prepared.commandEnv.AGENT_HOME).toContain('/hermes-agent/home') + expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME') + expect(prepared.useBrowserosMcp).toBe(true) + expect(prepared.openclawSessionKey).toBeNull() + expect(prepared.runtimeSessionKey).toMatch( + /^agent:hermes-agent:main:[a-f0-9]{16}$/, + ) + expect(prepared.runPrompt).toContain('\nuse hermes\n', + ) + expect( + await readFile(`${prepared.commandEnv.AGENT_HOME}/MEMORY.md`, 'utf8'), + ).toContain('# MEMORY.md') + }) + it('prepares OpenClaw without BrowserOS memory, host cwd, skills, or MCP', async () => { const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-adapters-')) tempDirs.push(browserosDir) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts index 753c0f053..04b1b1928 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts @@ -936,6 +936,120 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web, expect(command).toContain('/runtime/codex-home') }) + it('resolves Hermes to local ACP command with BrowserOS env', async () => { + const browserosDir = await mkdtemp( + join(tmpdir(), 'browseros-acpx-browseros-'), + ) + const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-')) + tempDirs.push(browserosDir, stateDir) + const calls: Array<{ method: string; input: unknown }> = [] + const runtime = new AcpxRuntime({ + browserosDir, + stateDir, + runtimeFactory: (options) => { + calls.push({ method: 'createRuntime', input: options }) + return createFakeAcpRuntime(calls) + }, + }) + const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' }) + + await collectStream( + await runtime.send({ + agent, + sessionId: 'main', + sessionKey: agent.sessionKey, + message: 'hi', + permissionMode: 'approve-all', + }), + ) + + expect( + calls.find((call) => call.method === 'ensureSession')?.input, + ).toMatchObject({ + agent: 'hermes', + }) + const command = + getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes') + expect(command).toContain('env AGENT_HOME=') + expect(command).toContain('hermes acp') + expect(command).not.toContain('CODEX_HOME=') + expect(getCreateRuntimeOptions(calls).mcpServers).toMatchObject([ + { name: 'browseros' }, + ]) + }) + + it('resolves Hermes with HERMES_ACP_COMMAND override', async () => { + const previous = process.env.HERMES_ACP_COMMAND + process.env.HERMES_ACP_COMMAND = 'python -m acp_adapter' + try { + const browserosDir = await mkdtemp( + join(tmpdir(), 'browseros-acpx-browseros-'), + ) + const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-')) + tempDirs.push(browserosDir, stateDir) + const calls: Array<{ method: string; input: unknown }> = [] + const runtime = new AcpxRuntime({ + browserosDir, + stateDir, + runtimeFactory: (options) => { + calls.push({ method: 'createRuntime', input: options }) + return createFakeAcpRuntime(calls) + }, + }) + const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' }) + + await collectStream( + await runtime.send({ + agent, + sessionId: 'main', + sessionKey: agent.sessionKey, + message: 'hi', + permissionMode: 'approve-all', + }), + ) + + const command = + getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes') + expect(command).toContain('env AGENT_HOME=') + expect(command).toContain('python -m acp_adapter') + expect(command).not.toContain('hermes acp') + } finally { + if (previous === undefined) { + delete process.env.HERMES_ACP_COMMAND + } else { + process.env.HERMES_ACP_COMMAND = previous + } + } + }) + + it('continues Hermes turns with a best-effort model status when model is non-default', async () => { + const calls: Array<{ method: string; input: unknown }> = [] + const runtime = new AcpxRuntime({ + cwd: '/tmp/browseros-acpx-runtime', + stateDir: '/tmp/browseros-acpx-state', + runtimeFactory: () => createFakeAcpRuntime(calls), + }) + const agent: AgentDefinition = { + ...makeAgent({ id: 'agent-1', adapter: 'hermes' }), + modelId: 'custom-hermes-model', + } + + const events = await collectStream( + await runtime.send({ + agent, + sessionId: 'main', + sessionKey: agent.sessionKey, + message: 'hello', + permissionMode: 'approve-all', + }), + ) + + expect(events[0]).toMatchObject({ + type: 'status', + text: expect.stringContaining('Using adapter default'), + }) + }) + it('does not reuse an Acpx runtime across different command identities', async () => { const browserosDir = await mkdtemp( join(tmpdir(), 'browseros-acpx-browseros-'), diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/adapter-health.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/adapter-health.test.ts new file mode 100644 index 000000000..0a86f5f10 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/agents/adapter-health.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { describe, expect, it } from 'bun:test' +import { AdapterHealthChecker } from '../../../src/lib/agents/adapter-health' + +describe('AdapterHealthChecker', () => { + it('probes Hermes with its ACP subcommand', async () => { + const calls: Array<{ command: string; timeout: number }> = [] + const checker = new AdapterHealthChecker({ + probeTimeoutMs: 123, + execCommand: async (command, options) => { + calls.push({ command, timeout: options.timeout }) + }, + }) + + const health = await checker.getHealth('hermes') + + expect(health.healthy).toBe(true) + expect(calls).toEqual([{ command: 'hermes acp --help', timeout: 123 }]) + }) + + it('returns a friendly missing-command reason for Hermes', async () => { + const checker = new AdapterHealthChecker({ + execCommand: async () => { + throw new Error('zsh: command not found: hermes') + }, + }) + + const health = await checker.getHealth('hermes') + + expect(health).toMatchObject({ + healthy: false, + reason: 'hermes acp --help failed: command not found', + }) + }) + + it('deduplicates inflight Hermes probes and caches the result', async () => { + let resolveProbe: (() => void) | null = null + let probeCount = 0 + const checker = new AdapterHealthChecker({ + cacheTtlMs: 10_000, + execCommand: async () => { + probeCount += 1 + await new Promise((resolve) => { + resolveProbe = resolve + }) + }, + }) + + const first = checker.getHealth('hermes') + const second = checker.getHealth('hermes') + + expect(probeCount).toBe(1) + resolveProbe?.() + await Promise.all([first, second]) + await checker.getHealth('hermes') + + expect(probeCount).toBe(1) + }) + + it('does not probe OpenClaw through the host CLI health checker', async () => { + const checker = new AdapterHealthChecker({ + execCommand: async () => { + throw new Error('unexpected probe') + }, + }) + + const health = await checker.getHealth('openclaw') + + expect(health.healthy).toBe(true) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/agent-catalog.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/agent-catalog.test.ts index a3ff7dc21..36b998ac1 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/agent-catalog.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/agent-catalog.test.ts @@ -7,16 +7,20 @@ import { describe, expect, it } from 'bun:test' import { AGENT_ADAPTER_CATALOG, getAgentAdapterDescriptor, + isAgentAdapter, isSupportedAgentModel, isSupportedReasoningEffort, + resolveDefaultModelId, + resolveDefaultReasoningEffort, } from '../../../src/lib/agents/agent-catalog' describe('AGENT_ADAPTER_CATALOG', () => { - it('exposes Claude, Codex, and OpenClaw adapters with model and effort options', () => { + it('exposes Claude, Codex, OpenClaw, and Hermes adapters with model and effort options', () => { expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([ 'claude', 'codex', 'openclaw', + 'hermes', ]) expect(getAgentAdapterDescriptor('claude')).toMatchObject({ @@ -46,6 +50,15 @@ describe('AGENT_ADAPTER_CATALOG', () => { // gateway-side agent record and is sourced from the LlmProviderConfig. expect(getAgentAdapterDescriptor('openclaw')?.models).toEqual([]) + expect(getAgentAdapterDescriptor('hermes')).toMatchObject({ + id: 'hermes', + name: 'Hermes', + defaultModelId: 'default', + defaultReasoningEffort: 'default', + modelControl: 'best-effort', + }) + expect(getAgentAdapterDescriptor('hermes')?.models).toEqual([]) + expect(isSupportedAgentModel('claude', 'haiku')).toBe(true) expect(isSupportedAgentModel('claude', 'claude-opus-4-7')).toBe(true) expect(isSupportedAgentModel('claude', 'claude-sonnet-4-6')).toBe(true) @@ -58,10 +71,19 @@ describe('AGENT_ADAPTER_CATALOG', () => { expect(isSupportedAgentModel('openclaw', undefined)).toBe(true) expect(isSupportedAgentModel('openclaw', 'default')).toBe(true) expect(isSupportedAgentModel('openclaw', 'gpt-5.5')).toBe(false) + expect(isSupportedAgentModel('hermes', undefined)).toBe(true) + expect(isSupportedAgentModel('hermes', 'default')).toBe(true) + expect(isSupportedAgentModel('hermes', 'gpt-5.5')).toBe(false) expect(isSupportedReasoningEffort('codex', 'xhigh')).toBe(true) expect(isSupportedReasoningEffort('claude', 'banana')).toBe(false) expect(isSupportedReasoningEffort('openclaw', 'adaptive')).toBe(true) expect(isSupportedReasoningEffort('openclaw', 'xhigh')).toBe(false) + expect(isSupportedReasoningEffort('hermes', 'default')).toBe(true) + expect(isSupportedReasoningEffort('hermes', 'medium')).toBe(false) + expect(resolveDefaultModelId('hermes')).toBe('default') + expect(resolveDefaultReasoningEffort('hermes')).toBe('default') + expect(isAgentAdapter('hermes')).toBe(true) + expect(isAgentAdapter('not-real')).toBe(false) }) })