@anureo/adapter-hermes 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rpc.js ADDED
@@ -0,0 +1,266 @@
1
+ import { BoundedEventQueue, HarnessSdkError } from '@anureo/sdk';
2
+ import { event, GATEWAY_PROTOCOL, HermesRpcError, object, protocolError } from "./native.js";
3
+ /**
4
+ * Internal, bounded JSON-RPC channel for adapter probes and sessions. Responses settle requests
5
+ * immediately; events and server requests run serially so a handler can await an RPC acknowledgement.
6
+ * HermesAdapter.probeRuntime disposes its probe. HermesSession owns the channel during observation
7
+ * and disposes it on recovery or close.
8
+ */
9
+ export class HermesRpc {
10
+ socket;
11
+ handlers;
12
+ options;
13
+ /** Install transport listeners and start serial delivery before connect waits for the opening handshake. */
14
+ constructor(socket, handlers, options) {
15
+ this.socket = socket;
16
+ this.handlers = handlers;
17
+ this.options = options;
18
+ this.queue = new BoundedEventQueue({ maxFrameBytes: options.maxFrameBytes, maxBytes: options.maxBufferedBytes, maxEvents: options.maxEvents });
19
+ this.finished = this.read().catch(cause => {
20
+ const error = cause instanceof HarnessSdkError ? cause : protocolError();
21
+ this.stop(error);
22
+ if (!this.disposed)
23
+ this.handlers.failure(error);
24
+ });
25
+ socket.binaryType = 'arraybuffer';
26
+ socket.addEventListener('message', message => {
27
+ if (this.closed)
28
+ return;
29
+ try {
30
+ this.receive(message.data);
31
+ }
32
+ catch (cause) {
33
+ this.stop(cause instanceof HarnessSdkError ? cause : protocolError());
34
+ }
35
+ });
36
+ socket.addEventListener('close', () => this.stop(new HarnessSdkError('CONNECTION_CLOSED', 'Hermes connection closed', false, true)));
37
+ socket.addEventListener('error', () => this.stop(new HarnessSdkError('CONNECTION_CLOSED', 'Hermes connection failed', false, true)));
38
+ }
39
+ /**
40
+ * Open a fresh grant and require hermes-gateway-v1 negotiation. Reject factory/open failures,
41
+ * timeout, protocol mismatch or abort; after channel construction, failure disposes its socket.
42
+ * The signal only cancels opening. On success the caller must dispose the channel separately
43
+ * and must use a freshly minted grant for each connection attempt.
44
+ */
45
+ static async connect(grant, handlers, options, signal) {
46
+ if (signal?.aborted)
47
+ throw new HarnessSdkError('RUNTIME_DISPOSED');
48
+ let socket;
49
+ try {
50
+ socket = options.webSocketFactory ? options.webSocketFactory(grant.url, [...grant.protocols]) : new WebSocket(grant.url, grant.protocols);
51
+ }
52
+ catch {
53
+ throw new HarnessSdkError('HARNESS_UNAVAILABLE', 'Cannot connect to Hermes gateway', true);
54
+ }
55
+ const client = new HermesRpc(socket, handlers, options);
56
+ try {
57
+ await waitForOpen(socket, options.timeoutMs ?? 30_000, signal);
58
+ return client;
59
+ }
60
+ catch (cause) {
61
+ client.dispose();
62
+ throw cause;
63
+ }
64
+ }
65
+ /** Whether the local channel still permits writes. HermesSession.ready uses this to decide when to recover. */
66
+ get connected() { return !this.closed && this.socket.readyState === 1; }
67
+ /**
68
+ * Resolve with the correlated native result, without waiting for event delivery or execution
69
+ * completion. Reject native RPC errors, admission/write failures, timeout or channel closure.
70
+ * HermesSession and HermesInteractions set mutation for submissions, creation, interrupts and
71
+ * prompt answers so timeout, channel loss or an unclassified write failure reports an unknown
72
+ * outcome. A request never retries or cancels remote work.
73
+ */
74
+ request(method, params = {}, mutation = false) {
75
+ if (!this.connected)
76
+ return Promise.reject(new HarnessSdkError('CONNECTION_CLOSED'));
77
+ if (this.pending.size >= (this.options.maxPending ?? 128))
78
+ return Promise.reject(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
79
+ const id = `anureo-${++this.nextId}`;
80
+ return new Promise((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ this.pending.delete(id);
83
+ reject(new HarnessSdkError('RPC_TIMEOUT', 'Hermes request timed out', false, mutation));
84
+ }, this.options.timeoutMs ?? 30_000);
85
+ this.pending.set(id, { resolve, reject, timer, mutation });
86
+ try {
87
+ this.send({ jsonrpc: '2.0', id, method, params });
88
+ }
89
+ catch (cause) {
90
+ this.forgetRequest(id, timer);
91
+ reject(cause instanceof HarnessSdkError ? cause : new HarnessSdkError('CONNECTION_CLOSED', 'Hermes write failed', false, mutation));
92
+ }
93
+ });
94
+ }
95
+ /**
96
+ * Send HermesInteractions' answer to a gateway request. Resolve after local socket.send, without
97
+ * remote acknowledgement; any write failure rejects RESULT_UNKNOWN with an unknown outcome.
98
+ */
99
+ reply(id, result) {
100
+ return this.respond({ jsonrpc: '2.0', id, result });
101
+ }
102
+ /** Reject an unsupported gateway request; delivery and failure semantics match reply. */
103
+ reject(id) {
104
+ return this.respond({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Unsupported client interaction' } });
105
+ }
106
+ /**
107
+ * Idempotently close local observation and reject pending RPCs without stopping native execution.
108
+ * Suppress failure callbacks; callers needing handler completion must also await finished.
109
+ */
110
+ dispose() {
111
+ if (this.disposed)
112
+ return;
113
+ this.disposed = true;
114
+ this.stop(new HarnessSdkError('CONNECTION_CLOSED', 'Hermes observation closed', false, true));
115
+ }
116
+ /**
117
+ * Settle when queued delivery ends and any failure callback returns. Disposal skips buffered
118
+ * entries and lets the active handler finish. HermesSession.retryRecovery awaits this before
119
+ * opening a replacement. Reader failures are reported to the callback, whose own throw rejects this promise.
120
+ */
121
+ finished;
122
+ queue;
123
+ pending = new Map();
124
+ nextId = 0;
125
+ closed = false;
126
+ disposed = false;
127
+ /**
128
+ * Deliver queued events/requests in order. The constructor's catch closes the channel on handler
129
+ * failure and reports it; disposed channels skip buffered entries while an active handler finishes.
130
+ */
131
+ async read() {
132
+ for await (const entry of this.queue) {
133
+ if (this.disposed)
134
+ continue;
135
+ if (entry.kind === 'event')
136
+ await this.handlers.event(entry.value);
137
+ else
138
+ await this.handlers.request(entry.value);
139
+ }
140
+ }
141
+ /** The message listener validates/routes each frame; throwing closes the channel through that listener. */
142
+ receive(raw) {
143
+ const frame = decodeFrame(raw, this.options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES);
144
+ const { id, method } = frame;
145
+ if (typeof method !== 'string') {
146
+ this.receiveResponse(frame);
147
+ return;
148
+ }
149
+ if (id === undefined) {
150
+ if (method === 'event')
151
+ this.queue.push({ kind: 'event', value: event(frame.params) });
152
+ return;
153
+ }
154
+ if (typeof id !== 'string' && (typeof id !== 'number' || !Number.isSafeInteger(id)))
155
+ throw protocolError();
156
+ this.queue.push({ kind: 'request', value: { id, method, params: object(frame.params ?? {}) } });
157
+ }
158
+ /**
159
+ * Settle matching requests without entering the handler queue. Ignore uncorrelated responses
160
+ * after checking the envelope, so late replies after timeout leave other requests pending.
161
+ */
162
+ receiveResponse(frame) {
163
+ const id = frame.id;
164
+ const hasError = Object.hasOwn(frame, 'error');
165
+ if ((typeof id !== 'string' && typeof id !== 'number') || Object.hasOwn(frame, 'result') === hasError)
166
+ throw protocolError();
167
+ const waiting = this.pending.get(id);
168
+ if (!waiting)
169
+ return;
170
+ // Invalid error data must leave the waiter visible so stop can reject it with the channel failure.
171
+ const nativeError = hasError ? new HermesRpcError(object(frame.error)) : undefined;
172
+ this.forgetRequest(id, waiting.timer);
173
+ if (nativeError)
174
+ waiting.reject(nativeError);
175
+ else
176
+ waiting.resolve(frame.result);
177
+ }
178
+ /** Release the pending slot and its deadline before settling a response or reporting a write failure. */
179
+ forgetRequest(id, timer) {
180
+ clearTimeout(timer);
181
+ this.pending.delete(id);
182
+ }
183
+ /** Enforce per-frame and socket backlog limits before a synchronous write; callers decide how to report failures. */
184
+ send(frame) {
185
+ if (!this.connected)
186
+ throw new HarnessSdkError('CONNECTION_CLOSED', 'Hermes connection is closed');
187
+ const value = JSON.stringify(frame);
188
+ const bytes = Buffer.byteLength(value);
189
+ if (bytes > (this.options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES))
190
+ throw new HarnessSdkError('FRAME_TOO_LARGE');
191
+ if (this.socket.bufferedAmount + bytes > (this.options.maxBufferedBytes ?? 16 * 1024 * 1024))
192
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
193
+ this.socket.send(value);
194
+ }
195
+ /** Share response delivery/error mapping so answers and unsupported-request replies cannot drift. */
196
+ respond(frame) {
197
+ try {
198
+ this.send(frame);
199
+ return Promise.resolve();
200
+ }
201
+ catch {
202
+ return Promise.reject(new HarnessSdkError('RESULT_UNKNOWN', 'Hermes response could not be delivered', false, true));
203
+ }
204
+ }
205
+ /** End observation once. Reject pending RPCs with their mutation uncertainty before ending queued delivery and closing the socket. */
206
+ stop(cause) {
207
+ if (this.closed)
208
+ return;
209
+ this.closed = true;
210
+ for (const waiting of this.pending.values()) {
211
+ clearTimeout(waiting.timer);
212
+ waiting.reject(new HarnessSdkError(cause.code, cause.message, false, waiting.mutation));
213
+ }
214
+ this.pending.clear();
215
+ this.queue.end(cause);
216
+ this.socket.close();
217
+ }
218
+ }
219
+ const DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024;
220
+ /** Require bounded UTF-8 JSON-RPC input; decoding/parsing errors are normalized by the message listener. */
221
+ function decodeFrame(raw, maxFrameBytes) {
222
+ let value;
223
+ if (typeof raw === 'string')
224
+ value = raw;
225
+ else if (raw instanceof ArrayBuffer)
226
+ value = new TextDecoder('utf-8', { fatal: true }).decode(raw);
227
+ else
228
+ throw protocolError();
229
+ if (Buffer.byteLength(value) > maxFrameBytes)
230
+ throw new HarnessSdkError('FRAME_TOO_LARGE');
231
+ const frame = object(JSON.parse(value));
232
+ if (frame.jsonrpc !== '2.0')
233
+ throw protocolError();
234
+ return frame;
235
+ }
236
+ /** Own opening listeners/deadline only; connect disposes the channel if negotiation, cancellation or transport fails. */
237
+ function waitForOpen(socket, timeoutMs, signal) {
238
+ return new Promise((resolve, reject) => {
239
+ const cleanup = () => {
240
+ clearTimeout(timer);
241
+ socket.removeEventListener('open', opened);
242
+ socket.removeEventListener('close', failed);
243
+ socket.removeEventListener('error', failed);
244
+ signal?.removeEventListener('abort', aborted);
245
+ };
246
+ const opened = () => {
247
+ cleanup();
248
+ socket.protocol === GATEWAY_PROTOCOL ? resolve() : reject(new HarnessSdkError('HERMES_GATEWAY_UNSUPPORTED', 'Hermes WebSocket protocol mismatch'));
249
+ };
250
+ const failed = () => { cleanup(); reject(new HarnessSdkError('HARNESS_UNAVAILABLE', 'Hermes connection failed', true)); };
251
+ const aborted = () => { cleanup(); reject(new HarnessSdkError('RUNTIME_DISPOSED')); };
252
+ const timer = setTimeout(failed, timeoutMs);
253
+ socket.addEventListener('open', opened, { once: true });
254
+ socket.addEventListener('close', failed, { once: true });
255
+ socket.addEventListener('error', failed, { once: true });
256
+ signal?.addEventListener('abort', aborted, { once: true });
257
+ if (signal?.aborted) {
258
+ aborted();
259
+ return;
260
+ }
261
+ if (socket.readyState === 1)
262
+ opened();
263
+ else if (socket.readyState > 1)
264
+ failed();
265
+ });
266
+ }
@@ -0,0 +1,151 @@
1
+ import { type BackendSession, type SessionEventSink, type SessionMethod } from '@anureo/sdk';
2
+ import { type AhpRequestMap, type AhpResultMap, type Capabilities, type SessionBinding, type SessionOpenParams, type SessionState } from '@anureo/protocol';
3
+ import type { HermesAdapterOptions } from "./options.js";
4
+ /** Return a fresh native capability snapshot for HermesAdapter.initialize and each HermesSession. */
5
+ export declare function hermesCapabilities(): Capabilities;
6
+ /**
7
+ * Internal BackendSession owned by HermesAdapter.openSession and adopted by OutputSession.open.
8
+ * Reconcile durable admissions and replay before releasing live traffic; native acceptance and turn
9
+ * completion remain independent. The owner must dispose this observation, including after open fails.
10
+ */
11
+ export declare class HermesSession implements BackendSession {
12
+ /** Native session capabilities read by OutputSession before it applies any structured-output fallback. */
13
+ readonly capabilities: Capabilities;
14
+ /** SDK identity augmented with the native reference after snapshot validation. */
15
+ binding: SessionBinding;
16
+ /** Set up local observation state for HermesAdapter.openSession. Call open before dispatching native requests. */
17
+ constructor({ params, sink, options, profileId, released }: {
18
+ params: SessionOpenParams;
19
+ sink: SessionEventSink;
20
+ options: HermesAdapterOptions;
21
+ profileId: string;
22
+ /** Remove this handle from HermesAdapter's tracked sessions when local disposal finishes. */
23
+ released: () => void;
24
+ });
25
+ /** Supply OutputSession with the native attachment snapshot, including active turns restored from pending rows. */
26
+ get state(): SessionState;
27
+ /**
28
+ * HermesAdapter.openSession awaits connection, creation/resume, snapshot projection and held events.
29
+ * Reject native/validation/publication failures; the adapter disposes partial setup before rethrowing.
30
+ */
31
+ open(): Promise<void>;
32
+ /**
33
+ * Handle a native submission, interrupt or observation close for OutputSession.execute. A queued
34
+ * receipt confirms admission, and a later native claim publishes its turn binding. Interrupt
35
+ * acknowledges the targeted generation, while turn completion arrives through the sink.
36
+ * Close attempts detach and disposes local observation with execution stop unconfirmed.
37
+ * Reject invalid bindings, unsupported requests and native failures with their outcome uncertainty.
38
+ */
39
+ execute<M extends SessionMethod>(method: M, params: AhpRequestMap[M]): Promise<AhpResultMap[M]>;
40
+ /** Idempotently abort local work, drop retained evidence and release adapter tracking; native execution is left running. */
41
+ dispose(): Promise<void>;
42
+ private nativeId;
43
+ private rpc?;
44
+ private description?;
45
+ private subscriptionId;
46
+ private cursor;
47
+ private readonly submissions;
48
+ private submissionBytes;
49
+ private readonly turns;
50
+ private readonly retired;
51
+ private readonly interactions;
52
+ private holding;
53
+ private held;
54
+ private heldBytes;
55
+ private recovery?;
56
+ private readonly ownerRecoveries;
57
+ private disposed;
58
+ private readonly abort;
59
+ private title?;
60
+ private model?;
61
+ /**
62
+ * Ask a connected owner to detach, then release local observation. Report an unconfirmed detach
63
+ * through the diagnostic callback. A callback exception rejects close before disposal.
64
+ */
65
+ private closeObservation;
66
+ /**
67
+ * After channel recovery, interrupt only a live turn in the current authority. Verify the ACK
68
+ * against the original turn, which may have completed while the request was in flight.
69
+ */
70
+ private interrupt;
71
+ /** Request the initial attachment snapshot. Only creation sends editor configuration, after capability discovery. */
72
+ private openSnapshot;
73
+ /** Preserve omission of toolsets/model and opt into ACP only for explicitly supplied session MCP servers. */
74
+ private createParameters;
75
+ /** Require local channel setup; connectedness and recovery are checked separately by ready. */
76
+ private channel;
77
+ /**
78
+ * Attach and describe the expected profile. HermesRpc invokes these event/request callbacks
79
+ * serially. Process traffic only from the current channel while the session is live. The RPC
80
+ * failure callback starts recovery or reports observation loss through sink.fail. The session
81
+ * owns successful channels and disposes them on retry or teardown.
82
+ */
83
+ private connect;
84
+ /** Reject overflow during setup or recovery because dropping held traffic would lose correlation evidence. */
85
+ private hold;
86
+ /** RPC's request callback buffers during reconciliation, otherwise registers a nonblocking interaction. */
87
+ private serverRequest;
88
+ /** RPC's event callback buffers during reconciliation, otherwise awaits ordered projection. */
89
+ private receive;
90
+ /** Drain held batches, including traffic arriving during awaits; only snapshot recovery tolerates replay discontinuity. */
91
+ private flush;
92
+ /** Reuse an execution only with matching authority/generation; evict terminal turns only when unacknowledged inputs cannot need them. */
93
+ private turn;
94
+ /**
95
+ * Match receipt evidence to local inputs, publish bindings and project any supplied terminal result.
96
+ * Receipts without an execution generation create no turn; unknown execution or transferred local input
97
+ * rejects with outcome uncertainty. Snapshot, event, submit and recovery paths all use this rule.
98
+ */
99
+ private associate;
100
+ /** Record evidence before publishing a binding or reporting uncertainty. A known input cannot change admissions. */
101
+ private recordAdmission;
102
+ /** Validate admission evidence against this observation's profile and native session. */
103
+ private parseReceipt;
104
+ /** Adopt validated attachment identity/metadata, then reconcile native pending rows and interactions before releasing held traffic. */
105
+ private snapshot;
106
+ /** Trust the pending row's explicit input id only for a locally tracked submission. */
107
+ private associatePending;
108
+ /** Apply live or replayed traffic, advancing the cursor only after all resulting publications succeed. */
109
+ private apply;
110
+ /** Ignore unrelated or already applied events, but send explicit replay gaps back through recovery. */
111
+ private eventPosition;
112
+ /**
113
+ * Associate native execution evidence before projecting progress. A terminal event waits only for
114
+ * responders already delivering answers, so their ACK/settlement can precede turn completion.
115
+ */
116
+ private applyTurnEvent;
117
+ /** Await any current recovery, or start one for a disconnected channel, before dispatching another request. */
118
+ private ready;
119
+ /** Share one recovery across RPC failure callbacks and request paths; keep live traffic held until reconciliation succeeds. */
120
+ private recover;
121
+ /** Wait for the old reader and run the retry budget. Dispose failed channels and propagate the first non-retryable failure or the final retryable one. */
122
+ private retryRecovery;
123
+ /** Open a replacement channel and reconcile execution evidence before restoring live delivery. */
124
+ private recoverObservation;
125
+ /** Try attachment first; subsequent attempts may request owner recovery within the rolling per-session budget. */
126
+ private recoveryMode;
127
+ /** Replay available events, reconcile the resume snapshot and watermark, then select continuity for the final held batch. */
128
+ private replaySnapshot;
129
+ /** Query known admissions during recovery. Reject if terminal evidence still leaves an associated turn unfinished. */
130
+ private reconcileAdmissions;
131
+ /** Fetch and validate result-bearing receipt evidence for both submit completion and recovery reconciliation. */
132
+ private resultReceipt;
133
+ /** Mark every retained live turn's intermediate progress incomplete; each turn emits at most one notice. */
134
+ private reportProgressGap;
135
+ /**
136
+ * Dispatch a text-only native input once. Resolve a queued or started admission after reconciling
137
+ * available evidence, or reject while retaining correlation for uncertain acceptance.
138
+ */
139
+ private submit;
140
+ /** Enforce identity/count/byte budgets before native dispatch, evicting only acknowledged inputs with known outcomes. */
141
+ private retainInput;
142
+ /** Recover evidence after a retryable submit failure. Return the recovered admission or reject with an unknown outcome. */
143
+ private submitReceipt;
144
+ /** Release retained text idempotently without discarding the admission's replay/correlation guard. */
145
+ private releaseInput;
146
+ private readonly params;
147
+ private readonly sink;
148
+ private readonly options;
149
+ private readonly profileId;
150
+ private readonly released;
151
+ }