From 47b8a145ac43688499d275a54b434a52551c1abd Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 13 Jul 2026 06:15:47 -0500 Subject: [PATCH] feat(agent): add transitional Hermes runtime adapter --- docs/tess/hermes-runtime-adapter-design.md | 19 ++ .../agent/src/hermes-runtime-provider.test.ts | 55 ++++++ packages/agent/src/hermes-runtime-provider.ts | 180 ++++++++++++++++++ packages/agent/src/index.ts | 1 + 4 files changed, 255 insertions(+) create mode 100644 docs/tess/hermes-runtime-adapter-design.md create mode 100644 packages/agent/src/hermes-runtime-provider.test.ts create mode 100644 packages/agent/src/hermes-runtime-provider.ts diff --git a/docs/tess/hermes-runtime-adapter-design.md b/docs/tess/hermes-runtime-adapter-design.md new file mode 100644 index 0000000..4623c25 --- /dev/null +++ b/docs/tess/hermes-runtime-adapter-design.md @@ -0,0 +1,19 @@ +# TESS-HRM-001 — Hermes runtime adapter boundary + +## Normalized provider surface + +`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`. + +The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call. + +## Boundary line + +**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response. + +## Initial mapping and safety posture + +- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists. +- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state. +- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event. +- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority. +- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists. diff --git a/packages/agent/src/hermes-runtime-provider.test.ts b/packages/agent/src/hermes-runtime-provider.test.ts new file mode 100644 index 0000000..e94b822 --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js'; + +const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' }; +const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({ + capabilities: vi.fn(async () => capabilities), + health: vi.fn(async () => ({ status: 'healthy' })), + sessions: vi.fn(async () => [ + { + conversation_id: 'child', + agent_id: 'hermes-a', + parent_conversation_id: 'parent', + status: 'running', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + conversation_id: 'parent', + agent_id: 'hermes-a', + status: 'unknown', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]), + stream: async function* () {}, + send: vi.fn(), + attach: vi.fn(), + detach: vi.fn(), + terminate: vi.fn(), +}); +describe('HermesRuntimeProvider normalization boundary', () => { + it('normalizes legacy sessions without exposing legacy fields', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.listSessions(scope)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }), + ]), + ); + const result = await provider.listSessions(scope); + expect(result[0]).not.toHaveProperty('conversation_id'); + }); + it('forms normalized hierarchy and fails closed for unbridged operations', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.getSessionTree(scope)).resolves.toEqual([ + expect.objectContaining({ + session: expect.objectContaining({ id: 'parent', state: 'failed' }), + children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })], + }), + ]); + await expect( + provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope), + ).rejects.toMatchObject({ code: 'capability_unsupported' }); + }); +}); diff --git a/packages/agent/src/hermes-runtime-provider.ts b/packages/agent/src/hermes-runtime-provider.ts new file mode 100644 index 0000000..90c7df9 --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.ts @@ -0,0 +1,180 @@ +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionState, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +const HERMES_PROVIDER_ID = 'runtime.hermes'; +const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', +]; + +/** Legacy transport boundary. These shapes are intentionally adapter-local. */ +export interface HermesLegacySession { + conversation_id: string; + agent_id: string; + parent_conversation_id?: string; + status: string; + created_at: string; + updated_at: string; +} +export interface HermesRuntimeTransport { + capabilities(scope: RuntimeScope): Promise; + health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>; + sessions(scope: RuntimeScope): Promise; + stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise; + detach(attachmentId: string, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} + +export class HermesRuntimeProviderError extends Error { + constructor( + readonly code: 'capability_unsupported' | 'invalid_request', + message: string, + ) { + super(message); + this.name = HermesRuntimeProviderError.name; + } +} + +/** + * Transitional Hermes adapter. Legacy identifiers and schemas do not cross this + * boundary: callers only observe Mosaic AgentRuntimeProvider contracts. + */ +export class HermesRuntimeProvider implements AgentRuntimeProvider { + readonly id = HERMES_PROVIDER_ID; + + constructor(private readonly transport: HermesRuntimeTransport) {} + + async capabilities(scope: RuntimeScope): Promise { + const legacyCapabilities = await this.transport.capabilities(scope); + return { + supported: RUNTIME_CAPABILITIES.filter((capability) => + legacyCapabilities.includes(capability), + ), + }; + } + + async health(scope: RuntimeScope): Promise { + const health = await this.transport.health(scope); + return { + status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down', + checkedAt: new Date().toISOString(), + ...(health.detail ? { detail: health.detail } : {}), + }; + } + + async listSessions(scope: RuntimeScope): Promise { + await this.requireCapability('session.list', scope); + return (await this.transport.sessions(scope)).map((session) => this.session(session)); + } + + async getSessionTree(scope: RuntimeScope): Promise { + await this.requireCapability('session.tree', scope); + const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session)); + const nodes = new Map( + sessions.map((session) => [session.id, { session, children: [] }]), + ); + const roots: RuntimeSessionTree[] = []; + for (const session of sessions) { + const node = nodes.get(session.id)!; + const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + async *streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + await this.requireCapability('session.stream', scope); + yield* this.transport.stream(sessionId, cursor, scope); + } + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.send', scope); + if (!message.content.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Message content is required'); + await this.transport.send(sessionId, message, scope); + } + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.attach', scope); + return this.transport.attach(sessionId, mode, scope); + } + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.transport.detach(attachmentId, scope); + } + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.requireCapability('session.terminate', scope); + if (!approvalRef.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required'); + await this.transport.terminate(sessionId, approvalRef, scope); + } + + private async requireCapability( + capability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + if (!(await this.capabilities(scope)).supported.includes(capability)) { + throw new HermesRuntimeProviderError( + 'capability_unsupported', + `Hermes does not bridge ${capability}`, + ); + } + } + private session(value: HermesLegacySession): RuntimeSession { + return { + id: value.conversation_id, + providerId: this.id, + runtimeId: value.agent_id, + ...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}), + state: state(value.status), + createdAt: value.created_at, + updatedAt: value.updated_at, + }; + } +} +function state(value: string): RuntimeSessionState { + return ( + ( + { running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record< + string, + RuntimeSessionState + > + )[value] ?? 'failed' + ); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 68d9d14..f7b1569 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,4 +2,5 @@ export const VERSION = '0.0.0'; export * from './runtime-provider-registry.js'; export * from './tmux-fleet-runtime-provider.js'; +export * from './hermes-runtime-provider.js'; export * from './tess-durable-session.js'; -- 2.49.1