@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/LICENSE +201 -0
- package/package.json +45 -4
- package/protocol/provenance.json +15 -0
- package/src/attach.d.ts +29 -0
- package/src/attach.js +150 -0
- package/src/events.d.ts +87 -0
- package/src/events.js +274 -0
- package/src/index.d.ts +81 -0
- package/src/index.js +169 -0
- package/src/interactions.d.ts +83 -0
- package/src/interactions.js +206 -0
- package/src/native.d.ts +97 -0
- package/src/native.js +138 -0
- package/src/options.d.ts +60 -0
- package/src/options.js +29 -0
- package/src/rpc.d.ts +94 -0
- package/src/rpc.js +266 -0
- package/src/session.d.ts +151 -0
- package/src/session.js +646 -0
- package/README.md +0 -3
package/src/session.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { HarnessSdkError } from '@anureo/sdk';
|
|
3
|
+
import { assertRequest } from '@anureo/protocol';
|
|
4
|
+
import { attachGateway } from "./attach.js";
|
|
5
|
+
import { HermesRpc } from "./rpc.js";
|
|
6
|
+
import { array, describe, event as nativeEvent, identity, integer, object, protocolError, publicReceipt, receipt } from "./native.js";
|
|
7
|
+
import { HermesTurn, sessionUsage } from "./events.js";
|
|
8
|
+
import { HermesInteractions } from "./interactions.js";
|
|
9
|
+
/** Return a fresh native capability snapshot for HermesAdapter.initialize and each HermesSession. */
|
|
10
|
+
export function hermesCapabilities() {
|
|
11
|
+
return {
|
|
12
|
+
session: { resume: true, fork: [], revert: [], history: false },
|
|
13
|
+
input: { delivery: ['native'], interrupt: { targets: ['turn'], reportsPendingInputs: false } },
|
|
14
|
+
turn: { outputSchema: false, overrides: [], inputModalities: ['text'] },
|
|
15
|
+
interactions: ['approval', 'userInput'], config: { options: [] },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Internal BackendSession owned by HermesAdapter.openSession and adopted by OutputSession.open.
|
|
20
|
+
* Reconcile durable admissions and replay before releasing live traffic; native acceptance and turn
|
|
21
|
+
* completion remain independent. The owner must dispose this observation, including after open fails.
|
|
22
|
+
*/
|
|
23
|
+
export class HermesSession {
|
|
24
|
+
/** Native session capabilities read by OutputSession before it applies any structured-output fallback. */
|
|
25
|
+
capabilities = hermesCapabilities();
|
|
26
|
+
/** SDK identity augmented with the native reference after snapshot validation. */
|
|
27
|
+
binding;
|
|
28
|
+
/** Set up local observation state for HermesAdapter.openSession. Call open before dispatching native requests. */
|
|
29
|
+
constructor({ params, sink, options, profileId, released }) {
|
|
30
|
+
this.params = params;
|
|
31
|
+
this.sink = sink;
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.profileId = profileId;
|
|
34
|
+
this.released = released;
|
|
35
|
+
this.binding = structuredClone(params.binding);
|
|
36
|
+
this.model = params.model;
|
|
37
|
+
this.interactions = new HermesInteractions({ sessionId: () => this.nativeId, rpc: () => this.channel(), sink,
|
|
38
|
+
turnId: generation => [...this.turns.values()].find(value => value.epoch === this.description?.epoch && value.generation === generation)?.id,
|
|
39
|
+
epoch: () => this.description.epoch, options });
|
|
40
|
+
}
|
|
41
|
+
/** Supply OutputSession with the native attachment snapshot, including active turns restored from pending rows. */
|
|
42
|
+
get state() {
|
|
43
|
+
return { binding: this.binding, status: this.disposed ? 'closed' : 'ready', config: {}, capabilities: this.capabilities,
|
|
44
|
+
capabilityRevision: 0, activeTurnIds: [...this.turns.values()].filter(value => !value.terminal).map(value => value.id),
|
|
45
|
+
...(this.model ? { model: this.model } : {}), ...(this.title ? { metadata: { title: this.title } } : {}) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* HermesAdapter.openSession awaits connection, creation/resume, snapshot projection and held events.
|
|
49
|
+
* Reject native/validation/publication failures; the adapter disposes partial setup before rethrowing.
|
|
50
|
+
*/
|
|
51
|
+
async open() {
|
|
52
|
+
await this.connect(this.options.startup ?? 'start');
|
|
53
|
+
const rpc = this.channel();
|
|
54
|
+
const snapshot = await this.openSnapshot(rpc);
|
|
55
|
+
await this.snapshot(snapshot);
|
|
56
|
+
this.cursor = { epoch: identity(snapshot.replay_epoch), sequence: integer(snapshot.last_sequence) };
|
|
57
|
+
await this.flush('contiguous');
|
|
58
|
+
this.holding = false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Handle a native submission, interrupt or observation close for OutputSession.execute. A queued
|
|
62
|
+
* receipt confirms admission, and a later native claim publishes its turn binding. Interrupt
|
|
63
|
+
* acknowledges the targeted generation, while turn completion arrives through the sink.
|
|
64
|
+
* Close attempts detach and disposes local observation with execution stop unconfirmed.
|
|
65
|
+
* Reject invalid bindings, unsupported requests and native failures with their outcome uncertainty.
|
|
66
|
+
*/
|
|
67
|
+
async execute(method, params) {
|
|
68
|
+
assertRequest(method, params);
|
|
69
|
+
if (params.sessionId !== this.binding.sessionId)
|
|
70
|
+
throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
|
|
71
|
+
switch (method) {
|
|
72
|
+
case 'input/submit': return await this.submit(params);
|
|
73
|
+
case 'session/close': return await this.closeObservation();
|
|
74
|
+
case 'turn/interrupt': return await this.interrupt(params);
|
|
75
|
+
default: throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Idempotently abort local work, drop retained evidence and release adapter tracking; native execution is left running. */
|
|
79
|
+
async dispose() {
|
|
80
|
+
if (this.disposed)
|
|
81
|
+
return;
|
|
82
|
+
this.disposed = true;
|
|
83
|
+
this.abort.abort();
|
|
84
|
+
this.rpc?.dispose();
|
|
85
|
+
this.held = [];
|
|
86
|
+
this.heldBytes = 0;
|
|
87
|
+
for (const input of this.submissions.values())
|
|
88
|
+
this.releaseInput(input);
|
|
89
|
+
this.submissions.clear();
|
|
90
|
+
this.turns.clear();
|
|
91
|
+
this.retired.clear();
|
|
92
|
+
this.released();
|
|
93
|
+
}
|
|
94
|
+
nativeId = '';
|
|
95
|
+
rpc;
|
|
96
|
+
description;
|
|
97
|
+
subscriptionId = '';
|
|
98
|
+
cursor = { epoch: '', sequence: 0 };
|
|
99
|
+
submissions = new Map();
|
|
100
|
+
submissionBytes = 0;
|
|
101
|
+
turns = new Map();
|
|
102
|
+
retired = new Set();
|
|
103
|
+
interactions;
|
|
104
|
+
holding = true;
|
|
105
|
+
held = [];
|
|
106
|
+
heldBytes = 0;
|
|
107
|
+
recovery;
|
|
108
|
+
ownerRecoveries = [];
|
|
109
|
+
disposed = false;
|
|
110
|
+
abort = new AbortController();
|
|
111
|
+
title;
|
|
112
|
+
model;
|
|
113
|
+
/**
|
|
114
|
+
* Ask a connected owner to detach, then release local observation. Report an unconfirmed detach
|
|
115
|
+
* through the diagnostic callback. A callback exception rejects close before disposal.
|
|
116
|
+
*/
|
|
117
|
+
async closeObservation() {
|
|
118
|
+
try {
|
|
119
|
+
if (this.channel().connected)
|
|
120
|
+
await this.channel().request('session.detach', { session_id: this.nativeId, subscription_id: this.subscriptionId });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
this.options.diagnostic?.('HERMES_DETACH_UNCONFIRMED');
|
|
124
|
+
}
|
|
125
|
+
await this.dispose();
|
|
126
|
+
return { closed: true, observationClosed: true, executionStopped: 'unconfirmed' };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* After channel recovery, interrupt only a live turn in the current authority. Verify the ACK
|
|
130
|
+
* against the original turn, which may have completed while the request was in flight.
|
|
131
|
+
*/
|
|
132
|
+
async interrupt(request) {
|
|
133
|
+
await this.ready();
|
|
134
|
+
const turn = [...this.turns.values()].find(value => value.id === request.turnId);
|
|
135
|
+
if (!turn || turn.terminal || turn.epoch !== this.description.epoch)
|
|
136
|
+
throw new HarnessSdkError('TURN_NOT_ACTIVE');
|
|
137
|
+
const response = object(await this.channel().request('session.interrupt', { session_id: this.nativeId, execution_generation: turn.generation }, true));
|
|
138
|
+
const ref = object(response.ref);
|
|
139
|
+
if (ref.session_id !== this.nativeId || ref.profile_id !== this.profileId || response.execution_generation !== turn.generation || response.authority_epoch !== turn.epoch)
|
|
140
|
+
throw protocolError();
|
|
141
|
+
return { accepted: true, turnId: turn.id };
|
|
142
|
+
}
|
|
143
|
+
/** Request the initial attachment snapshot. Only creation sends editor configuration, after capability discovery. */
|
|
144
|
+
async openSnapshot(rpc) {
|
|
145
|
+
if (this.params.mode === 'new')
|
|
146
|
+
return object(await rpc.request('session.create', this.createParameters(), true));
|
|
147
|
+
if (this.binding.nativeRef?.provider !== 'hermes')
|
|
148
|
+
throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
|
|
149
|
+
return object(await rpc.request('session.resume', { session_id: identity(this.binding.nativeRef.value), omit_messages: true }));
|
|
150
|
+
}
|
|
151
|
+
/** Preserve omission of toolsets/model and opt into ACP only for explicitly supplied session MCP servers. */
|
|
152
|
+
createParameters() {
|
|
153
|
+
if (this.options.toolsets !== undefined && !this.description.createParameters.includes('toolsets'))
|
|
154
|
+
throw new HarnessSdkError('HERMES_GATEWAY_UNSUPPORTED', 'Hermes gateway does not support explicit toolsets');
|
|
155
|
+
const params = { request_id: this.params.requestId, source: 'tool', cwd: this.params.cwd };
|
|
156
|
+
if (this.params.mcpServers !== undefined) {
|
|
157
|
+
if (!this.description.editorMcp)
|
|
158
|
+
throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
|
|
159
|
+
// The gateway accepts session-scoped MCP only on its editor (ACP) surface.
|
|
160
|
+
params.source = 'acp';
|
|
161
|
+
params.editor = { edit_approval_policy: 'ask', mcp_servers: this.params.mcpServers.map(server => server.type === 'stdio'
|
|
162
|
+
? { name: server.name, command: server.command, args: server.args ?? [], env: Object.entries(server.env ?? {}).map(([name, value]) => ({ name, value })) }
|
|
163
|
+
: { type: 'http', name: server.name, url: server.url, headers: Object.entries(server.headers ?? {}).map(([name, value]) => ({ name, value })) }) };
|
|
164
|
+
}
|
|
165
|
+
if (this.options.toolsets !== undefined)
|
|
166
|
+
params.toolsets = [...this.options.toolsets];
|
|
167
|
+
if (this.params.model !== undefined)
|
|
168
|
+
params.model = this.params.model;
|
|
169
|
+
if (this.description.createParameters.includes('client'))
|
|
170
|
+
params.client = { name: 'anureo-sdk', version: '0.2.0' };
|
|
171
|
+
return params;
|
|
172
|
+
}
|
|
173
|
+
/** Require local channel setup; connectedness and recovery are checked separately by ready. */
|
|
174
|
+
channel() {
|
|
175
|
+
if (!this.rpc)
|
|
176
|
+
throw new HarnessSdkError('NOT_INITIALIZED');
|
|
177
|
+
return this.rpc;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Attach and describe the expected profile. HermesRpc invokes these event/request callbacks
|
|
181
|
+
* serially. Process traffic only from the current channel while the session is live. The RPC
|
|
182
|
+
* failure callback starts recovery or reports observation loss through sink.fail. The session
|
|
183
|
+
* owns successful channels and disposes them on retry or teardown.
|
|
184
|
+
*/
|
|
185
|
+
async connect(mode) {
|
|
186
|
+
const grant = await attachGateway(this.options, mode, this.abort.signal);
|
|
187
|
+
if (this.disposed)
|
|
188
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
189
|
+
if (grant.profileId !== this.profileId)
|
|
190
|
+
throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
|
|
191
|
+
let channel;
|
|
192
|
+
channel = await HermesRpc.connect(grant, {
|
|
193
|
+
event: async (value) => { if (this.rpc === channel && !this.disposed)
|
|
194
|
+
await this.receive(value); },
|
|
195
|
+
request: async (value) => { if (this.rpc === channel && !this.disposed)
|
|
196
|
+
await this.serverRequest(value); },
|
|
197
|
+
failure: cause => {
|
|
198
|
+
if (this.rpc !== channel || this.disposed)
|
|
199
|
+
return;
|
|
200
|
+
if (!recoverable(cause) || (this.options.reconnectAttempts ?? 3) === 0)
|
|
201
|
+
void this.sink.fail(cause);
|
|
202
|
+
else
|
|
203
|
+
void this.recover().catch(error => this.sink.fail(error));
|
|
204
|
+
},
|
|
205
|
+
}, this.options, this.abort.signal);
|
|
206
|
+
if (this.disposed) {
|
|
207
|
+
channel.dispose();
|
|
208
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
209
|
+
}
|
|
210
|
+
this.rpc = channel;
|
|
211
|
+
try {
|
|
212
|
+
this.description = describe(await channel.request('runtime.describe'), grant);
|
|
213
|
+
}
|
|
214
|
+
catch (cause) {
|
|
215
|
+
channel.dispose();
|
|
216
|
+
throw cause;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Reject overflow during setup or recovery because dropping held traffic would lose correlation evidence. */
|
|
220
|
+
hold(value) {
|
|
221
|
+
const bytes = Buffer.byteLength(JSON.stringify(value));
|
|
222
|
+
if (this.held.length >= (this.options.maxEvents ?? 2048) || this.heldBytes + bytes > (this.options.maxBufferedBytes ?? 16 * 1024 * 1024))
|
|
223
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
224
|
+
this.held.push(value);
|
|
225
|
+
this.heldBytes += bytes;
|
|
226
|
+
}
|
|
227
|
+
/** RPC's request callback buffers during reconciliation, otherwise registers a nonblocking interaction. */
|
|
228
|
+
async serverRequest(request) {
|
|
229
|
+
if (this.holding) {
|
|
230
|
+
this.hold(request);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await this.interactions.serverRequest(request);
|
|
234
|
+
}
|
|
235
|
+
/** RPC's event callback buffers during reconciliation, otherwise awaits ordered projection. */
|
|
236
|
+
async receive(event) {
|
|
237
|
+
if (this.holding) {
|
|
238
|
+
this.hold(event);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await this.apply(event);
|
|
242
|
+
}
|
|
243
|
+
/** Drain held batches, including traffic arriving during awaits; only snapshot recovery tolerates replay discontinuity. */
|
|
244
|
+
async flush(continuity) {
|
|
245
|
+
while (this.held.length) {
|
|
246
|
+
const entries = this.held;
|
|
247
|
+
this.held = [];
|
|
248
|
+
this.heldBytes = 0;
|
|
249
|
+
for (const value of entries) {
|
|
250
|
+
if ('method' in value)
|
|
251
|
+
await this.interactions.serverRequest(value);
|
|
252
|
+
else
|
|
253
|
+
await this.apply(value, continuity);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Reuse an execution only with matching authority/generation; evict terminal turns only when unacknowledged inputs cannot need them. */
|
|
258
|
+
turn(admission, epoch, generation) {
|
|
259
|
+
const existing = this.turns.get(admission);
|
|
260
|
+
if (existing) {
|
|
261
|
+
if (existing.epoch !== epoch || existing.generation !== generation)
|
|
262
|
+
throw protocolError();
|
|
263
|
+
return existing;
|
|
264
|
+
}
|
|
265
|
+
if (this.turns.size >= (this.options.maxExecutions ?? 128)) {
|
|
266
|
+
const old = [...this.turns].find(([id, value]) => value.terminal && ![...this.submissions.values()].some(input => !input.acknowledged && (!input.admission || input.admission.id === id)));
|
|
267
|
+
if (!old)
|
|
268
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
269
|
+
this.turns.delete(old[0]);
|
|
270
|
+
this.retired.add(old[0]);
|
|
271
|
+
if (this.retired.size > 1024)
|
|
272
|
+
this.retired.delete(this.retired.values().next().value);
|
|
273
|
+
}
|
|
274
|
+
const turn = new HermesTurn({ admissionId: admission, epoch, generation, sessionId: this.nativeId, sink: this.sink, options: this.options });
|
|
275
|
+
this.turns.set(admission, turn);
|
|
276
|
+
return turn;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Match receipt evidence to local inputs, publish bindings and project any supplied terminal result.
|
|
280
|
+
* Receipts without an execution generation create no turn; unknown execution or transferred local input
|
|
281
|
+
* rejects with outcome uncertainty. Snapshot, event, submit and recovery paths all use this rule.
|
|
282
|
+
*/
|
|
283
|
+
async associate(value, submissionId) {
|
|
284
|
+
const inputs = submissionId ? [[submissionId, this.submissions.get(submissionId)]]
|
|
285
|
+
: [...this.submissions].filter(([, input]) => input.admission?.id === value.id);
|
|
286
|
+
if (value.steering) {
|
|
287
|
+
if (inputs.some(([, input]) => input))
|
|
288
|
+
throw new HarnessSdkError('HERMES_CORRELATION_UNSUPPORTED', 'A queued input was transferred by another viewer', false, true);
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
this.recordAdmission(value, inputs);
|
|
292
|
+
if (value.status === 'unknown')
|
|
293
|
+
throw new HarnessSdkError('HERMES_EXECUTION_UNKNOWN', 'Hermes execution outcome is unknown', false, true);
|
|
294
|
+
if (value.generation === undefined) {
|
|
295
|
+
if (value.status === 'terminal')
|
|
296
|
+
for (const [id, input] of inputs) {
|
|
297
|
+
if (!input || input.rejected)
|
|
298
|
+
continue;
|
|
299
|
+
input.rejected = true;
|
|
300
|
+
await this.sink.emit({ type: 'submission/status', payload: { submissionId: id, status: 'rejected',
|
|
301
|
+
error: { code: 'INPUT_CANCELLED', message: 'Hermes input cancelled before execution', retryable: false, outcomeUnknown: false } } });
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
if (this.retired.has(value.id))
|
|
306
|
+
return undefined;
|
|
307
|
+
const turn = this.turn(value.id, value.epoch, value.generation);
|
|
308
|
+
await turn.start();
|
|
309
|
+
for (const [id, input] of inputs)
|
|
310
|
+
if (input)
|
|
311
|
+
await turn.bind(id, input.input);
|
|
312
|
+
if (value.status === 'terminal' && !turn.terminal && value.result)
|
|
313
|
+
await turn.fromReceipt(value.result, value.outcome);
|
|
314
|
+
return turn;
|
|
315
|
+
}
|
|
316
|
+
/** Record evidence before publishing a binding or reporting uncertainty. A known input cannot change admissions. */
|
|
317
|
+
recordAdmission(value, inputs) {
|
|
318
|
+
for (const [, input] of inputs) {
|
|
319
|
+
if (!input)
|
|
320
|
+
continue;
|
|
321
|
+
if (input.admission && input.admission.id !== value.id)
|
|
322
|
+
throw protocolError();
|
|
323
|
+
input.admission = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Validate admission evidence against this observation's profile and native session. */
|
|
327
|
+
parseReceipt(value) { return receipt(value, this.profileId, this.nativeId); }
|
|
328
|
+
/** Adopt validated attachment identity/metadata, then reconcile native pending rows and interactions before releasing held traffic. */
|
|
329
|
+
async snapshot(snapshot) {
|
|
330
|
+
this.nativeId = identity(snapshot.session_id);
|
|
331
|
+
const info = object(snapshot.info);
|
|
332
|
+
if (integer(snapshot.authority_epoch) !== this.description.epoch || info.profile_id !== undefined && info.profile_id !== this.profileId)
|
|
333
|
+
throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
|
|
334
|
+
this.subscriptionId = identity(snapshot.subscription_id);
|
|
335
|
+
this.binding = { ...this.binding, nativeRef: { provider: 'hermes', value: this.nativeId } };
|
|
336
|
+
if (typeof info.title === 'string')
|
|
337
|
+
this.title = info.title;
|
|
338
|
+
if (typeof info.model === 'string')
|
|
339
|
+
this.model = info.model;
|
|
340
|
+
for (const value of array(snapshot.pending)) {
|
|
341
|
+
await this.associatePending(value);
|
|
342
|
+
}
|
|
343
|
+
await this.interactions.snapshot(array(snapshot.prompts), snapshot.open_requests == null ? [] : array(snapshot.open_requests));
|
|
344
|
+
}
|
|
345
|
+
/** Trust the pending row's explicit input id only for a locally tracked submission. */
|
|
346
|
+
async associatePending(value) {
|
|
347
|
+
const pending = this.parseReceipt(value);
|
|
348
|
+
await this.associate(pending, pending.inputId && this.submissions.has(pending.inputId) ? pending.inputId : undefined);
|
|
349
|
+
}
|
|
350
|
+
/** Apply live or replayed traffic, advancing the cursor only after all resulting publications succeed. */
|
|
351
|
+
async apply(event, continuity = 'contiguous') {
|
|
352
|
+
const position = this.eventPosition(event);
|
|
353
|
+
if (!position)
|
|
354
|
+
return;
|
|
355
|
+
if (position.epoch !== this.cursor.epoch || position.sequence !== this.cursor.sequence + 1) {
|
|
356
|
+
if (continuity === 'contiguous')
|
|
357
|
+
throw new HarnessSdkError('HERMES_REPLAY_GAP', 'Hermes event sequence changed', false, true);
|
|
358
|
+
await this.reportProgressGap();
|
|
359
|
+
}
|
|
360
|
+
const payload = event.payload;
|
|
361
|
+
switch (event.type) {
|
|
362
|
+
case 'session.info':
|
|
363
|
+
if (Array.isArray(payload.pending))
|
|
364
|
+
for (const value of payload.pending)
|
|
365
|
+
await this.associatePending(value);
|
|
366
|
+
if (typeof payload.title === 'string') {
|
|
367
|
+
this.title = payload.title;
|
|
368
|
+
await this.sink.emit({ type: 'session/metadata', payload: { title: payload.title } });
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
case 'approval.request':
|
|
372
|
+
case 'clarify.request':
|
|
373
|
+
await this.interactions.prompt({ ...payload, kind: event.type === 'approval.request' ? 'approval' : 'clarify', authority_epoch: event.authority_epoch ?? this.description.epoch });
|
|
374
|
+
break;
|
|
375
|
+
case 'approval.settled':
|
|
376
|
+
case 'clarify.settled':
|
|
377
|
+
await this.interactions.settled({ wire: 'prompt', nativeId: identity(payload.prompt_id), generation: integer(payload.execution_generation) }, 'resolved');
|
|
378
|
+
break;
|
|
379
|
+
case 'request.cancel':
|
|
380
|
+
await this.interactions.settled({ wire: 'request', nativeId: identity(payload.id) }, identity(payload.reason));
|
|
381
|
+
break;
|
|
382
|
+
case 'session.usage':
|
|
383
|
+
if (payload.usage != null)
|
|
384
|
+
await sessionUsage(this.sink, object(payload.usage));
|
|
385
|
+
break;
|
|
386
|
+
case 'message.start':
|
|
387
|
+
case 'message.delta':
|
|
388
|
+
case 'message.interim':
|
|
389
|
+
case 'message.complete':
|
|
390
|
+
case 'reasoning.delta':
|
|
391
|
+
case 'tool.start':
|
|
392
|
+
case 'tool.complete':
|
|
393
|
+
await this.applyTurnEvent(event);
|
|
394
|
+
break;
|
|
395
|
+
case 'error':
|
|
396
|
+
// A bare error does not identify an execution's terminal state.
|
|
397
|
+
throw new HarnessSdkError('HERMES_REPLAY_GAP', 'Hermes execution requires reconciliation', false, true);
|
|
398
|
+
}
|
|
399
|
+
this.cursor = position;
|
|
400
|
+
}
|
|
401
|
+
/** Ignore unrelated or already applied events, but send explicit replay gaps back through recovery. */
|
|
402
|
+
eventPosition(event) {
|
|
403
|
+
if (event.session_id !== this.nativeId)
|
|
404
|
+
return undefined;
|
|
405
|
+
if (event.type === 'session.replay_gap')
|
|
406
|
+
throw new HarnessSdkError('HERMES_REPLAY_GAP', 'Hermes event replay is required', false, true);
|
|
407
|
+
const epoch = identity(event.replay_epoch), sequence = integer(event.seq);
|
|
408
|
+
if (epoch === this.cursor.epoch && sequence <= this.cursor.sequence)
|
|
409
|
+
return undefined;
|
|
410
|
+
return { epoch, sequence };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Associate native execution evidence before projecting progress. A terminal event waits only for
|
|
414
|
+
* responders already delivering answers, so their ACK/settlement can precede turn completion.
|
|
415
|
+
*/
|
|
416
|
+
async applyTurnEvent(event) {
|
|
417
|
+
const payload = event.payload;
|
|
418
|
+
const admissionId = identity(event.admission_id ?? payload.admission_id);
|
|
419
|
+
if (this.retired.has(admissionId))
|
|
420
|
+
return;
|
|
421
|
+
const generation = event.execution_generation;
|
|
422
|
+
if (generation === undefined) {
|
|
423
|
+
// A cancelled FIFO row has a terminal frame but never had a native execution.
|
|
424
|
+
if (event.type !== 'message.complete' || payload.outcome !== 'cancelled')
|
|
425
|
+
throw protocolError();
|
|
426
|
+
for (const [id, input] of this.submissions)
|
|
427
|
+
if (input.admission?.id === admissionId) {
|
|
428
|
+
await this.associate({ ...input.admission, status: 'terminal', outcome: 'cancelled' }, id);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const turn = this.turn(admissionId, integer(event.authority_epoch), generation);
|
|
433
|
+
const submissionId = payload.persisted_turn == null ? undefined : object(payload.persisted_turn).submission_id;
|
|
434
|
+
if (typeof submissionId === 'string') {
|
|
435
|
+
const input = this.submissions.get(submissionId);
|
|
436
|
+
if (input)
|
|
437
|
+
await this.associate({ id: admissionId, sequence: input.admission?.sequence ?? 0, status: 'started', outcome: null, epoch: turn.epoch, generation }, submissionId);
|
|
438
|
+
}
|
|
439
|
+
if (event.type === 'message.complete')
|
|
440
|
+
await this.interactions.waitResponders();
|
|
441
|
+
await turn.apply(event);
|
|
442
|
+
if (turn.terminal)
|
|
443
|
+
for (const input of this.submissions.values())
|
|
444
|
+
if (input.admission?.id === admissionId && input.acknowledged)
|
|
445
|
+
this.releaseInput(input);
|
|
446
|
+
}
|
|
447
|
+
/** Await any current recovery, or start one for a disconnected channel, before dispatching another request. */
|
|
448
|
+
async ready() {
|
|
449
|
+
if (this.disposed)
|
|
450
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
451
|
+
if (this.recovery)
|
|
452
|
+
await this.recovery;
|
|
453
|
+
else if (!this.channel().connected)
|
|
454
|
+
await this.recover();
|
|
455
|
+
if (this.disposed)
|
|
456
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
457
|
+
}
|
|
458
|
+
/** Share one recovery across RPC failure callbacks and request paths; keep live traffic held until reconciliation succeeds. */
|
|
459
|
+
recover() {
|
|
460
|
+
if (this.recovery)
|
|
461
|
+
return this.recovery;
|
|
462
|
+
this.holding = true;
|
|
463
|
+
const old = this.channel();
|
|
464
|
+
old.dispose();
|
|
465
|
+
const task = this.retryRecovery(old);
|
|
466
|
+
this.recovery = task;
|
|
467
|
+
void task.finally(() => { if (this.recovery === task)
|
|
468
|
+
this.recovery = undefined; }).catch(() => { });
|
|
469
|
+
return task;
|
|
470
|
+
}
|
|
471
|
+
/** 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. */
|
|
472
|
+
async retryRecovery(old) {
|
|
473
|
+
await old.finished;
|
|
474
|
+
let last = new HarnessSdkError('CONNECTION_CLOSED', 'Hermes connection lost', false, true);
|
|
475
|
+
const attempts = this.options.reconnectAttempts ?? 3;
|
|
476
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
477
|
+
if (this.disposed)
|
|
478
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
479
|
+
if (attempt)
|
|
480
|
+
await delay((this.options.reconnectDelayMs ?? 100) * 2 ** (attempt - 1), undefined, { signal: this.abort.signal });
|
|
481
|
+
try {
|
|
482
|
+
await this.recoverObservation(this.recoveryMode(attempt));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
catch (cause) {
|
|
486
|
+
this.rpc?.dispose();
|
|
487
|
+
last = cause;
|
|
488
|
+
if (!recoverable(cause))
|
|
489
|
+
throw cause;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
throw last;
|
|
493
|
+
}
|
|
494
|
+
/** Open a replacement channel and reconcile execution evidence before restoring live delivery. */
|
|
495
|
+
async recoverObservation(mode) {
|
|
496
|
+
this.held = [];
|
|
497
|
+
this.heldBytes = 0;
|
|
498
|
+
await this.connect(mode);
|
|
499
|
+
const rpc = this.channel();
|
|
500
|
+
const continuity = await this.replaySnapshot(rpc);
|
|
501
|
+
await this.reconcileAdmissions(rpc);
|
|
502
|
+
await this.flush(continuity);
|
|
503
|
+
this.holding = false;
|
|
504
|
+
}
|
|
505
|
+
/** Try attachment first; subsequent attempts may request owner recovery within the rolling per-session budget. */
|
|
506
|
+
recoveryMode(attempt) {
|
|
507
|
+
if (!attempt || this.options.startup === 'attach')
|
|
508
|
+
return 'attach';
|
|
509
|
+
while (this.ownerRecoveries.length && this.ownerRecoveries[0] < Date.now() - 60_000)
|
|
510
|
+
this.ownerRecoveries.shift();
|
|
511
|
+
if (this.ownerRecoveries.length >= (this.options.maxOwnerRecoveriesPerMinute ?? 2))
|
|
512
|
+
throw new HarnessSdkError('HARNESS_UNAVAILABLE', 'Hermes owner recovery limit reached');
|
|
513
|
+
this.ownerRecoveries.push(Date.now());
|
|
514
|
+
return 'recover';
|
|
515
|
+
}
|
|
516
|
+
/** Replay available events, reconcile the resume snapshot and watermark, then select continuity for the final held batch. */
|
|
517
|
+
async replaySnapshot(rpc) {
|
|
518
|
+
const replay = object(await rpc.request('session.events.since', { session_id: this.nativeId, replay_epoch: this.cursor.epoch, last_sequence: this.cursor.sequence }));
|
|
519
|
+
if (typeof replay.snapshot_required !== 'boolean')
|
|
520
|
+
throw protocolError();
|
|
521
|
+
if (!replay.snapshot_required)
|
|
522
|
+
for (const value of array(replay.events))
|
|
523
|
+
await this.apply(nativeEvent(value));
|
|
524
|
+
const snapshot = object(await rpc.request('session.resume', { session_id: this.nativeId, omit_messages: true }));
|
|
525
|
+
const gap = replay.snapshot_required || snapshot.replay_epoch !== this.cursor.epoch;
|
|
526
|
+
if (gap) {
|
|
527
|
+
await this.reportProgressGap();
|
|
528
|
+
this.cursor = { epoch: identity(snapshot.replay_epoch), sequence: 0 };
|
|
529
|
+
}
|
|
530
|
+
await this.snapshot(snapshot);
|
|
531
|
+
const continuity = gap ? 'snapshot' : 'contiguous';
|
|
532
|
+
await this.flush(continuity);
|
|
533
|
+
const watermark = integer(snapshot.last_sequence);
|
|
534
|
+
if (this.cursor.sequence < watermark) {
|
|
535
|
+
await this.reportProgressGap();
|
|
536
|
+
this.cursor = { epoch: identity(snapshot.replay_epoch), sequence: watermark };
|
|
537
|
+
}
|
|
538
|
+
return continuity;
|
|
539
|
+
}
|
|
540
|
+
/** Query known admissions during recovery. Reject if terminal evidence still leaves an associated turn unfinished. */
|
|
541
|
+
async reconcileAdmissions(rpc) {
|
|
542
|
+
const admissions = new Set([...this.submissions.values()].filter(value => !value.rejected).map(value => value.admission?.id).filter((id) => !!id));
|
|
543
|
+
for (const turn of this.turns.values())
|
|
544
|
+
if (!turn.terminal)
|
|
545
|
+
admissions.add(turn.admissionId);
|
|
546
|
+
for (const id of admissions) {
|
|
547
|
+
if (this.turns.get(id)?.terminal)
|
|
548
|
+
continue;
|
|
549
|
+
const current = await this.resultReceipt(rpc, id);
|
|
550
|
+
const turn = await this.associate(current);
|
|
551
|
+
if (current.status === 'terminal' && turn && !turn.terminal)
|
|
552
|
+
throw new HarnessSdkError('HERMES_RESULT_UNAVAILABLE', 'Hermes terminal result was unavailable', false, true);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Fetch and validate result-bearing receipt evidence for both submit completion and recovery reconciliation. */
|
|
556
|
+
async resultReceipt(rpc, admissionId) {
|
|
557
|
+
return this.parseReceipt(await rpc.request('prompt.receipt', { session_id: this.nativeId, admission_id: admissionId, include_result: true }));
|
|
558
|
+
}
|
|
559
|
+
/** Mark every retained live turn's intermediate progress incomplete; each turn emits at most one notice. */
|
|
560
|
+
async reportProgressGap() {
|
|
561
|
+
for (const turn of this.turns.values())
|
|
562
|
+
await turn.gap();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Dispatch a text-only native input once. Resolve a queued or started admission after reconciling
|
|
566
|
+
* available evidence, or reject while retaining correlation for uncertain acceptance.
|
|
567
|
+
*/
|
|
568
|
+
async submit(params) {
|
|
569
|
+
if (params.delivery !== 'native' || params.outputSchema !== undefined || params.input.some(value => value.type !== 'text') || Object.keys(params.overrides ?? {}).length)
|
|
570
|
+
throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
|
|
571
|
+
await this.ready();
|
|
572
|
+
const input = this.retainInput(params);
|
|
573
|
+
try {
|
|
574
|
+
let native = await this.submitReceipt(params.submissionId, input);
|
|
575
|
+
let turn = await this.associate(native, params.submissionId);
|
|
576
|
+
if (native.status === 'terminal' && turn && !turn.terminal) {
|
|
577
|
+
native = await this.resultReceipt(this.channel(), native.id);
|
|
578
|
+
turn = await this.associate(native, params.submissionId);
|
|
579
|
+
if (!turn?.terminal)
|
|
580
|
+
throw new HarnessSdkError('HERMES_RESULT_UNAVAILABLE', 'Hermes terminal result was unavailable', false, true);
|
|
581
|
+
}
|
|
582
|
+
if (input.rejected)
|
|
583
|
+
throw new HarnessSdkError('INPUT_CANCELLED');
|
|
584
|
+
return turn ? { submissionId: params.submissionId, accepted: true, disposition: 'started', turnId: turn.id, receipt: publicReceipt(native) }
|
|
585
|
+
: { submissionId: params.submissionId, accepted: true, disposition: 'queued', receipt: publicReceipt(native) };
|
|
586
|
+
}
|
|
587
|
+
catch (cause) {
|
|
588
|
+
if (cause instanceof HarnessSdkError && !cause.outcomeUnknown)
|
|
589
|
+
input.rejected = true;
|
|
590
|
+
throw cause;
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
input.acknowledged = true;
|
|
594
|
+
if (input.rejected || input.admission && this.turns.get(input.admission.id)?.terminal)
|
|
595
|
+
this.releaseInput(input);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** Enforce identity/count/byte budgets before native dispatch, evicting only acknowledged inputs with known outcomes. */
|
|
599
|
+
retainInput(params) {
|
|
600
|
+
if (this.submissions.has(params.submissionId))
|
|
601
|
+
throw new HarnessSdkError('SUBMISSION_ID_CONFLICT');
|
|
602
|
+
if (this.submissions.size >= (this.options.maxSubmissions ?? 1024)) {
|
|
603
|
+
const old = [...this.submissions].find(([, value]) => value.acknowledged && (value.rejected || value.admission && (this.turns.get(value.admission.id)?.terminal || this.retired.has(value.admission.id))));
|
|
604
|
+
if (!old)
|
|
605
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
606
|
+
this.releaseInput(old[1]);
|
|
607
|
+
this.submissions.delete(old[0]);
|
|
608
|
+
}
|
|
609
|
+
const input = { input: params.input.map(value => value.type === 'text' ? value.text : '').join('\n'), acknowledged: false, rejected: false };
|
|
610
|
+
const bytes = Buffer.byteLength(input.input);
|
|
611
|
+
if (this.submissionBytes + bytes > (this.options.maxBufferedBytes ?? 16 * 1024 * 1024))
|
|
612
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
613
|
+
this.submissionBytes += bytes;
|
|
614
|
+
this.submissions.set(params.submissionId, input);
|
|
615
|
+
return input;
|
|
616
|
+
}
|
|
617
|
+
/** Recover evidence after a retryable submit failure. Return the recovered admission or reject with an unknown outcome. */
|
|
618
|
+
async submitReceipt(submissionId, input) {
|
|
619
|
+
try {
|
|
620
|
+
return this.parseReceipt(await this.channel().request('prompt.submit', { session_id: this.nativeId, text: input.input, submission_id: submissionId }, true));
|
|
621
|
+
}
|
|
622
|
+
catch (cause) {
|
|
623
|
+
if (!recoverable(cause) || (this.options.reconnectAttempts ?? 3) === 0)
|
|
624
|
+
throw cause;
|
|
625
|
+
try {
|
|
626
|
+
await this.recover();
|
|
627
|
+
}
|
|
628
|
+
catch (recovery) {
|
|
629
|
+
const code = recovery instanceof HarnessSdkError ? recovery.code : 'CONNECTION_CLOSED';
|
|
630
|
+
throw new HarnessSdkError(code, 'Hermes submission outcome is unknown after reconnect', false, true);
|
|
631
|
+
}
|
|
632
|
+
if (!input.admission)
|
|
633
|
+
throw new HarnessSdkError('RESULT_UNKNOWN', 'Hermes submission outcome is unknown; do not resend', false, true);
|
|
634
|
+
return input.admission;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Release retained text idempotently without discarding the admission's replay/correlation guard. */
|
|
638
|
+
releaseInput(input) { this.submissionBytes -= Buffer.byteLength(input.input); input.input = ''; }
|
|
639
|
+
params;
|
|
640
|
+
sink;
|
|
641
|
+
options;
|
|
642
|
+
profileId;
|
|
643
|
+
released;
|
|
644
|
+
}
|
|
645
|
+
/** Retry only transport/replay failures; correlation, validation and terminal-evidence failures remain final. */
|
|
646
|
+
const recoverable = (cause) => cause instanceof HarnessSdkError && ['CONNECTION_CLOSED', 'RPC_TIMEOUT', 'HARNESS_UNAVAILABLE', 'HERMES_REPLAY_GAP'].includes(cause.code);
|
package/README.md
DELETED