feat(agent): add transitional Hermes runtime adapter
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
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 './runtime-provider-registry.js';
|
||||||
export * from './tmux-fleet-runtime-provider.js';
|
export * from './tmux-fleet-runtime-provider.js';
|
||||||
|
export * from './hermes-runtime-provider.js';
|
||||||
export * from './tess-durable-session.js';
|
export * from './tess-durable-session.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user