feat(agent): add transitional Hermes runtime adapter #734
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
@@ -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.
|
||||
55
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
55
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
180
packages/agent/src/hermes-runtime-provider.ts
Normal file
180
packages/agent/src/hermes-runtime-provider.ts
Normal file
@@ -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<string[]>;
|
||||
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
|
||||
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
|
||||
stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle>;
|
||||
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
|
||||
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<RuntimeCapabilitySet> {
|
||||
const legacyCapabilities = await this.transport.capabilities(scope);
|
||||
return {
|
||||
supported: RUNTIME_CAPABILITIES.filter((capability) =>
|
||||
legacyCapabilities.includes(capability),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
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<RuntimeSession[]> {
|
||||
await this.requireCapability('session.list', scope);
|
||||
return (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
}
|
||||
|
||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||
await this.requireCapability('session.tree', scope);
|
||||
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
const nodes = new Map<string, RuntimeSessionTree>(
|
||||
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<RuntimeStreamEvent> {
|
||||
await this.requireCapability('session.stream', scope);
|
||||
yield* this.transport.stream(sessionId, cursor, scope);
|
||||
}
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
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<RuntimeAttachHandle> {
|
||||
await this.requireCapability('session.attach', scope);
|
||||
return this.transport.attach(sessionId, mode, scope);
|
||||
}
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.transport.detach(attachmentId, scope);
|
||||
}
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
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<void> {
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user