@anureo/sdk 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.
Files changed (76) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +185 -2
  3. package/package.json +66 -4
  4. package/src/adapter/identity.d.ts +41 -0
  5. package/src/adapter/identity.js +71 -0
  6. package/src/client-options.d.ts +71 -0
  7. package/src/client-options.js +0 -0
  8. package/src/client.d.ts +106 -0
  9. package/src/client.js +254 -0
  10. package/src/diagnostics/errors.d.ts +15 -0
  11. package/src/diagnostics/errors.js +59 -0
  12. package/src/diagnostics/events.d.ts +46 -0
  13. package/src/diagnostics/events.js +47 -0
  14. package/src/diagnostics.d.ts +20 -0
  15. package/src/diagnostics.js +56 -0
  16. package/src/errors.d.ts +48 -0
  17. package/src/errors.js +63 -0
  18. package/src/index.d.ts +27 -0
  19. package/src/index.js +23 -0
  20. package/src/lifecycle/approval.d.ts +16 -0
  21. package/src/lifecycle/approval.js +18 -0
  22. package/src/lifecycle/deferred.d.ts +10 -0
  23. package/src/lifecycle/deferred.js +12 -0
  24. package/src/lifecycle/errors.d.ts +18 -0
  25. package/src/lifecycle/errors.js +45 -0
  26. package/src/lifecycle/events.d.ts +35 -0
  27. package/src/lifecycle/events.js +141 -0
  28. package/src/lifecycle/execution-observation.d.ts +33 -0
  29. package/src/lifecycle/execution-observation.js +0 -0
  30. package/src/lifecycle/immutable.d.ts +12 -0
  31. package/src/lifecycle/immutable.js +28 -0
  32. package/src/lifecycle/input.d.ts +9 -0
  33. package/src/lifecycle/input.js +19 -0
  34. package/src/lifecycle/interactions.d.ts +129 -0
  35. package/src/lifecycle/interactions.js +282 -0
  36. package/src/lifecycle/limits.d.ts +7 -0
  37. package/src/lifecycle/limits.js +13 -0
  38. package/src/lifecycle/result.d.ts +77 -0
  39. package/src/lifecycle/result.js +213 -0
  40. package/src/lifecycle/value.d.ts +4 -0
  41. package/src/lifecycle/value.js +5 -0
  42. package/src/ports.d.ts +224 -0
  43. package/src/ports.js +0 -0
  44. package/src/privacy/file-change-limits.d.ts +20 -0
  45. package/src/privacy/file-change-limits.js +124 -0
  46. package/src/privacy/secrets.d.ts +205 -0
  47. package/src/privacy/secrets.js +564 -0
  48. package/src/process.d.ts +92 -0
  49. package/src/process.js +331 -0
  50. package/src/queue.d.ts +143 -0
  51. package/src/queue.js +266 -0
  52. package/src/request.d.ts +28 -0
  53. package/src/request.js +81 -0
  54. package/src/requests/guard.d.ts +15 -0
  55. package/src/requests/guard.js +93 -0
  56. package/src/session.d.ts +459 -0
  57. package/src/session.js +1135 -0
  58. package/src/structured-output/adapter.d.ts +8 -0
  59. package/src/structured-output/adapter.js +54 -0
  60. package/src/structured-output/bridge.d.ts +81 -0
  61. package/src/structured-output/bridge.js +274 -0
  62. package/src/structured-output/errors.d.ts +17 -0
  63. package/src/structured-output/errors.js +31 -0
  64. package/src/structured-output/limits.d.ts +19 -0
  65. package/src/structured-output/limits.js +0 -0
  66. package/src/structured-output/schema.d.ts +20 -0
  67. package/src/structured-output/schema.js +122 -0
  68. package/src/structured-output/session.d.ts +329 -0
  69. package/src/structured-output/session.js +846 -0
  70. package/src/structured-output/stdio.mjs +83 -0
  71. package/src/structured-output/tool.d.ts +38 -0
  72. package/src/structured-output/tool.js +34 -0
  73. package/src/turn.d.ts +75 -0
  74. package/src/turn.js +95 -0
  75. package/src/types.d.ts +62 -0
  76. package/src/types.js +0 -0
package/src/session.js ADDED
@@ -0,0 +1,1135 @@
1
+ // Coordinates dispatch, ordered observation and teardown for one native session.
2
+ import { assertResult, assertEvent, publicInteraction, publicValue, supportsExtension } from '@anureo/protocol';
3
+ import { HarnessSdkError, sdkError } from "./errors.js";
4
+ import { invalidWait, waitForRequest } from "./request.js";
5
+ import { InteractionRegistry } from "./lifecycle/interactions.js";
6
+ import { SecretRedactor } from "./privacy/secrets.js";
7
+ import { EventHub } from "./lifecycle/events.js";
8
+ import { guardRequest } from "./requests/guard.js";
9
+ import { ResultAccumulator } from "./lifecycle/result.js";
10
+ import { Turn } from "./turn.js";
11
+ import { deferred } from "./lifecycle/deferred.js";
12
+ import { immutable } from "./lifecycle/immutable.js";
13
+ import { inputBlocks } from "./lifecycle/input.js";
14
+ import { positive } from "./lifecycle/limits.js";
15
+ import { combined, observationClosed } from "./lifecycle/errors.js";
16
+ /**
17
+ * Submit input, dispatch requests and observe events for a session opened through Client.
18
+ * Await close to request native close and release resources, or dispose to release observation
19
+ * and backend resources with native stop unconfirmed. Client.dispose also releases its sessions.
20
+ */
21
+ export class Session {
22
+ params;
23
+ options;
24
+ createId;
25
+ /**
26
+ * Client.open constructs this sink before starting native open so callbacks and teardown can race
27
+ * attachment safely. Clone initial state, allocate stream identity and install privacy/interaction
28
+ * hooks without acquiring a backend. ID generation or cloning can throw; Client owns later disposal.
29
+ */
30
+ constructor(params, options, capabilities, createId) {
31
+ this.params = params;
32
+ this.options = options;
33
+ this.createId = createId;
34
+ this.streamId = createId();
35
+ this.state = { binding: immutable(params.binding), capabilities: immutable(capabilities), capabilityRevision: 1, config: structuredClone(params.config ?? {}), status: 'opening', activeTurnIds: [], ...(params.model ? { model: params.model } : {}) };
36
+ this.secrets = new SecretRedactor(value => options.runtime?.onMarkedSecret?.(this.binding, value));
37
+ /**
38
+ * InteractionRegistry.respond, cancel and resolveExternally invoke this registered publisher.
39
+ * Abort/remove handler identity before awaiting ordered resolution publication; rejection goes
40
+ * back to registry settlement. startInteractionHandler still owns capacity until host work settles.
41
+ */
42
+ this.interactions = new InteractionRegistry(async (result) => {
43
+ this.handlers.get(result.interactionId)?.abort();
44
+ this.handlers.delete(result.interactionId);
45
+ if (!this.closed)
46
+ await this.emit({ type: 'interaction/resolved', payload: result });
47
+ }, { maxPending: options.runtime?.maxPendingInteractions ?? 32, timeoutMs: options.runtime?.interactionTimeoutMs ?? 600_000, responseTimeoutMs: options.runtime?.requestTimeoutMs }, (request, response) => this.secrets.rememberResponse(request, response));
48
+ }
49
+ /**
50
+ * Read the stable session ID used in request and event envelopes.
51
+ */
52
+ get id() { return this.state.binding.sessionId; }
53
+ /**
54
+ * Read a detached, frozen copy of the current binding. Its native reference may change after
55
+ * a revert or a native state update. Cloning failures throw synchronously.
56
+ */
57
+ get binding() { return immutable(this.state.binding); }
58
+ /**
59
+ * Capture the current binding and the original working directory for Client.sessions.resume or
60
+ * Client.sessions.fork. Cloning failures throw synchronously.
61
+ */
62
+ get reference() { return immutable({ binding: this.binding, cwd: this.params.cwd }); }
63
+ /**
64
+ * Read a detached, frozen copy of the session's current capabilities before choosing an operation.
65
+ * Cloning failures throw synchronously.
66
+ */
67
+ get capabilities() { return immutable(this.state.capabilities); }
68
+ /**
69
+ * Read a frozen, privacy-filtered copy of the currently admitted state without waiting for
70
+ * the event queue. Projection or cloning failures throw synchronously.
71
+ */
72
+ get snapshot() { return immutable(this.secrets.state(this.state)); }
73
+ /**
74
+ * OutputSession.publishOutput invokes this optional sink hook before revalidating final JSON.
75
+ * Return marked-secret projection without mutating the candidate; OutputSession owns schema failure
76
+ * handling and final publication.
77
+ */
78
+ projectOutput(text) { return this.secrets.text(text); }
79
+ /**
80
+ * Attach the backend, restore active observations and publish initial state. Client.attachSession
81
+ * awaits this before returning a session to the host. Dispose a mismatched or already-closed
82
+ * attachment before rejecting. Adoption or publication failure rejects to Client.open, which
83
+ * owns session cleanup.
84
+ */
85
+ async attach(backend) {
86
+ if (!matchesBinding(backend.binding, this.binding)) {
87
+ await backend.dispose();
88
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
89
+ }
90
+ if (this.closed) {
91
+ await backend.dispose();
92
+ throw this.observationFailure ?? new HarnessSdkError('SESSION_CLOSED');
93
+ }
94
+ if (backend.state) {
95
+ if (!matchesBinding(backend.state.binding, this.binding)) {
96
+ await backend.dispose();
97
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
98
+ }
99
+ this.adoptState(backend.state);
100
+ for (const id of backend.state.activeTurnIds)
101
+ this.context(id).started = true;
102
+ }
103
+ this.backend = backend;
104
+ this.state.binding = immutable(backend.binding);
105
+ this.state.capabilities = immutable(backend.capabilities);
106
+ this.updateStatus();
107
+ await this.emit({ type: 'session/state', payload: this.state });
108
+ }
109
+ /**
110
+ * Subscribe to future session events without replay; subscribe before submitting to observe its
111
+ * lifecycle from admission. Each subscription is isolated and bounded; invalid limits throw and later
112
+ * failures reject reads. Consumers must exhaust, return or abort the iterator to release it. Detaching
113
+ * or overflowing this observer does not stop native execution or affect sibling subscriptions.
114
+ */
115
+ events(options) { return this.hub.subscribe(options); }
116
+ /**
117
+ * Client.attachSession supplies this sink to OutputSession, whose event forwarding calls it;
118
+ * Session.bindSubmission, outcome bookkeeping and interaction hooks also await the same ordered path.
119
+ * Clone and enqueue a payload. Resolve once the ordered queue processes it, even when a duplicate
120
+ * is suppressed or observation has since closed. Reject closed admission, serialization, cloning,
121
+ * capacity or processing failures. Overflow and processing failures also end observation.
122
+ */
123
+ emit(payload, scope = {}) {
124
+ if (this.closed)
125
+ return Promise.reject(new HarnessSdkError('STREAM_CLOSED'));
126
+ let bytes;
127
+ try {
128
+ bytes = Buffer.byteLength(JSON.stringify(payload));
129
+ }
130
+ catch {
131
+ return Promise.reject(new HarnessSdkError('PROTOCOL_ERROR'));
132
+ }
133
+ if (this.eventExceedsByteCapacity(bytes)) {
134
+ const cause = new HarnessSdkError('BACKPRESSURE_EXCEEDED');
135
+ void this.fail(cause);
136
+ return Promise.reject(cause);
137
+ }
138
+ if (++this.backlog > this.maxQueuedEvents()) {
139
+ this.backlog--;
140
+ void this.fail(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
141
+ return Promise.reject(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
142
+ }
143
+ this.pendingBytes += bytes;
144
+ let copy;
145
+ try {
146
+ copy = structuredClone(payload);
147
+ }
148
+ catch {
149
+ this.backlog--;
150
+ this.pendingBytes -= bytes;
151
+ return Promise.reject(new HarnessSdkError('PROTOCOL_ERROR'));
152
+ }
153
+ const target = { ...scope };
154
+ const operation = this.source.then(() => this.publish(copy, target));
155
+ this.source = operation.catch(cause => { void this.fail(cause); }).finally(
156
+ /** emit registers this after failure observation; release the event's queue reservation on either outcome without awaiting teardown. */
157
+ () => { this.backlog--; this.pendingBytes -= bytes; });
158
+ return operation;
159
+ }
160
+ /**
161
+ * A direct adapter sink consumer unable to await publication can use this admission probe. Return
162
+ * false on closure, serialization or capacity failure and invoke fail; true means the immediate
163
+ * checks passed, with emit rejection observed asynchronously. It does not promise successful
164
+ * publication. OutputSession.open supplies its own native-adapter tryEmit forwarding callback.
165
+ */
166
+ tryEmit(payload, scope = {}) {
167
+ if (this.closed || this.backlog >= this.maxQueuedEvents()) {
168
+ void this.fail(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
169
+ return false;
170
+ }
171
+ try {
172
+ const bytes = Buffer.byteLength(JSON.stringify(payload));
173
+ if (this.eventExceedsByteCapacity(bytes)) {
174
+ void this.fail(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
175
+ return false;
176
+ }
177
+ }
178
+ catch {
179
+ void this.fail(new HarnessSdkError('PROTOCOL_ERROR'));
180
+ return false;
181
+ }
182
+ void this.emit(payload, scope).catch(() => { });
183
+ return true;
184
+ }
185
+ /**
186
+ * Native sink consumers may report association here; onRequestSucceeded awaits it when a receipt
187
+ * contains turnId. Queue submission/bound through emit so event admission orders association against
188
+ * native completion. Publication failures reject; successful association can release submit's Turn
189
+ * before a separate native receipt settles. Wrapped adapters reach the same event path via OutputSession.
190
+ */
191
+ bindSubmission(submissionId, turnId) {
192
+ return this.emit({ type: 'submission/bound', payload: { submissionId, turnId } });
193
+ }
194
+ /**
195
+ * Submit text or protocol blocks. Delivery defaults to native, so the adapter may queue the input
196
+ * or join an existing execution. Resolve a Turn as soon as the input is bound to an execution,
197
+ * independently of receipt or terminal timing. Normalization, admission, dispatch and wait failures
198
+ * can reject before association. Use Turn.result for the terminal outcome and dispose the handle
199
+ * when finished observing. Abort or timeout leaves admitted work running.
200
+ */
201
+ submit(options, wait = {}) {
202
+ try {
203
+ return this.submitExecution({
204
+ method: 'input/submit', options: { ...options, input: inputBlocks(options.input), delivery: options.delivery ?? 'native' },
205
+ }, wait);
206
+ }
207
+ catch (cause) {
208
+ return Promise.reject(cause);
209
+ }
210
+ }
211
+ /**
212
+ * Hosts start a native review through submitExecution's association lifecycle. Resolve a Turn on
213
+ * binding or reject admission/dispatch/wait failure; caller cancellation leaves admitted work alive.
214
+ * ID generation or option cloning can throw synchronously. The returned observer follows the same
215
+ * disposal and native-control rules as submit.
216
+ */
217
+ review(options, wait = {}) {
218
+ return this.submitExecution({ method: 'review/start', options }, wait);
219
+ }
220
+ /**
221
+ * Request interruption for the whole session. Set cancelQueued only if advertised by the session.
222
+ * Resolve the native receipt or reject on capability, dispatch or wait failure. Caller cancellation
223
+ * affects waiting only. Use Turn.result for a terminal execution outcome.
224
+ * Request ID generation can throw synchronously before request is called.
225
+ */
226
+ interrupt(options = {}, wait) {
227
+ return this.request('session/interrupt', { ...this.requestIdentity(), ...options }, wait);
228
+ }
229
+ /**
230
+ * Dispatch a typed session request and await its receipt. Matching method/request IDs replay the
231
+ * cached outcome while retained; changed params or a submission reserved by another request reject
232
+ * with a conflict. session/close instead shares its existing close work regardless of request ID.
233
+ * Hosts and context's Turn control callbacks use this entry. Ordinary calls validate
234
+ * wait options before dispatch and return a caller-scoped receipt wait; session/close validates the
235
+ * request first and uses closeRequest's shared teardown. Validation, dispatch and wait failures reject;
236
+ * admitted native work, bookkeeping and cleanup survive cancellation without automatic retries.
237
+ */
238
+ request(method, params, wait = {}) {
239
+ if (method === 'session/close') {
240
+ try {
241
+ guardRequest(method, params, this.id, this.capabilities);
242
+ }
243
+ catch (cause) {
244
+ return Promise.reject(cause);
245
+ }
246
+ return this.closeRequest(params, wait);
247
+ }
248
+ const waitError = requestWaitError(wait);
249
+ if (waitError)
250
+ return Promise.reject(waitError);
251
+ return waitForRequest(() => this.dispatchOperation(method, params), wait, this.options.runtime?.requestTimeoutMs);
252
+ }
253
+ /**
254
+ * Settle a native interaction through a configured handler or manual interaction/respond request.
255
+ * OutputSession.open forwards the adapter's request/responder here. An own key in
256
+ * ClientOptions.interactions enables the kind; undefined selects manual handling and omission rejects
257
+ * it. Notify native rejection for closed/unsupported/handler-capacity cases, otherwise register
258
+ * settlement and publish interaction/requested before scheduling a configured host handler. Return
259
+ * the registry's response after native settlement/publication, or reject its failure. The host handler
260
+ * owns its work; Session aborts its signal on settlement/teardown without cancelling that work.
261
+ */
262
+ async requestInteraction(request, responder) {
263
+ if (this.closed) {
264
+ await responder(null, 'session_closed');
265
+ throw new HarnessSdkError('SESSION_CLOSED');
266
+ }
267
+ if (!this.capabilities.interactions?.includes(request.kind) || !Object.hasOwn(this.options.interactions ?? {}, request.kind)) {
268
+ await responder(null, 'unsupported');
269
+ throw new HarnessSdkError('INTERACTION_UNSUPPORTED');
270
+ }
271
+ const handler = this.options.interactions?.[request.kind];
272
+ if (handler && this.handlerCount >= (this.options.runtime?.maxPendingInteractions ?? 32)) {
273
+ await responder(null, 'capacity');
274
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
275
+ }
276
+ const result = this.interactions.begin(request, responder);
277
+ const controller = handler ? new AbortController() : undefined;
278
+ if (controller) {
279
+ this.handlers.set(request.interactionId, controller);
280
+ this.handlerCount++;
281
+ }
282
+ await this.emit({ type: 'interaction/requested', payload: publicInteraction(request) }, { turnId: request.turnId, itemId: request.itemId });
283
+ if (handler && controller)
284
+ this.startInteractionHandler({ request, handler, controller });
285
+ return result;
286
+ }
287
+ /**
288
+ * OutputSession.open forwards native settlement reports here; direct adapters can use the sink too.
289
+ * Return InteractionRegistry.resolveExternally's publication promise without invoking the responder.
290
+ * The original interaction wait rejects even for responded because native supplied no response value;
291
+ * unknown/already-resolved IDs are ignored by the registry.
292
+ */
293
+ resolveInteraction(result) { return this.interactions.resolveExternally(result); }
294
+ /**
295
+ * OutputSession forwards adapter observation loss here; emit, tryEmit, rejectNativeResult and cached
296
+ * bookkeeping also invoke it. Idempotently end observation with a safe unknown-outcome SDK error,
297
+ * await interaction cancellation, then attempt backend release while suppressing release-promise
298
+ * rejection. A synchronous disposal throw rejects this call. Native execution stop stays unconfirmed;
299
+ * the first caller can await cleanup without initiating a native interrupt; later calls return early.
300
+ */
301
+ async fail(cause) {
302
+ if (this.closed)
303
+ return;
304
+ this.endObservation(new HarnessSdkError(sdkError(cause).code, 'Session observation failed', false, true, { source: 'sdk' }));
305
+ await this.interactions.cancelAll('connection_lost');
306
+ await this.disposeBackend().catch(() => { });
307
+ }
308
+ /**
309
+ * Request native close and await interaction and backend cleanup. Concurrent close calls share the
310
+ * work. A session whose observation already ended awaits backend release and returns its stored
311
+ * receipt or an unconfirmed-stop receipt. Cleanup failure can reject after native close succeeds.
312
+ * Abort or timeout rejects only this caller's wait.
313
+ * Request ID generation can throw synchronously before closeRequest is called.
314
+ */
315
+ close(wait = {}) {
316
+ return this.closeRequest(this.requestIdentity(), wait);
317
+ }
318
+ /**
319
+ * Client.open awaits this on setup/wait failure and Client.dispose awaits it before adapter release;
320
+ * host session consumers may also call it directly. Share teardown and its rejection: end observation,
321
+ * abort handler signals, await interaction cancellation, then release the backend. Native close is
322
+ * caller-owned and execution stop remains unconfirmed; repeated disposal never repeats owned work.
323
+ */
324
+ dispose() {
325
+ /** dispose starts this owned teardown once; repeated calls share its completion/rejection while Client waits before releasing the adapter. */
326
+ return this.disposal ??= (async () => {
327
+ const cause = observationClosed();
328
+ this.endObservation(cause);
329
+ await this.interactions.cancelAll('runtime_disposed');
330
+ await this.disposeBackend();
331
+ })();
332
+ }
333
+ backend;
334
+ observationFailure;
335
+ closed = false;
336
+ recovering = false;
337
+ closing;
338
+ closeReceipt;
339
+ disposal;
340
+ backendDisposal;
341
+ ended = deferred();
342
+ seq = 0;
343
+ streamId;
344
+ source = Promise.resolve();
345
+ backlog = 0;
346
+ pendingBytes = 0;
347
+ totalBytes = 0;
348
+ hub = new EventHub();
349
+ executions = new Map();
350
+ submissions = new Map();
351
+ requests = new Map();
352
+ interactions;
353
+ handlers = new Map();
354
+ handlerCount = 0;
355
+ secrets;
356
+ state;
357
+ /**
358
+ * attach and admitEvent call before recomputing status. Replace native state with a detached clone,
359
+ * preserving earlier metadata patches omitted by the snapshot. Clone failure propagates to attachment
360
+ * or ordered publication; callers still own binding validation and active-execution bookkeeping.
361
+ */
362
+ adoptState(state) {
363
+ this.state = { ...structuredClone(state), ...((this.state.metadata || state.metadata) ? { metadata: { ...this.state.metadata, ...state.metadata } } : {}) };
364
+ }
365
+ /**
366
+ * Recompute the status and active IDs exposed by snapshot and state events after lifecycle changes.
367
+ * Closure, closing and recovery take precedence over execution activity.
368
+ */
369
+ updateStatus() {
370
+ this.state.activeTurnIds = [...this.executions.values()].filter(value => value.started && !value.terminal).map(value => value.id);
371
+ if (this.closed)
372
+ this.state.status = 'closed';
373
+ else if (this.closing)
374
+ this.state.status = 'closing';
375
+ else if (this.recovering)
376
+ this.state.status = 'recoveryRequired';
377
+ else
378
+ this.state.status = this.state.activeTurnIds.length ? 'running' : 'ready';
379
+ }
380
+ /**
381
+ * attach restores active observations here; admitEvent and bindRecord reuse the same execution
382
+ * registry for live events and late associations. Return an existing context or allocate bounded
383
+ * completion/events/results and explicit Turn controls. Reclaim only an eligible terminal snapshot;
384
+ * throw BACKPRESSURE_EXCEEDED if capacity stays pinned. Invalid budgets throw before insertion.
385
+ * Turn owns each observer's cancellation.
386
+ */
387
+ context(id) {
388
+ const known = this.executions.get(id);
389
+ if (known)
390
+ return known;
391
+ const limit = positive(this.options.limits?.maxExecutions, 128);
392
+ if (this.executions.size >= limit)
393
+ this.reclaimExecution();
394
+ if (this.executions.size >= limit)
395
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED', 'Execution observation limit exceeded');
396
+ const completion = deferred();
397
+ const context = {
398
+ id, completion, result: completion.promise, events: new EventHub(), started: false, terminal: false, items: new Map(),
399
+ accumulator: new ResultAccumulator(positive(this.options.limits?.maxResultBytes, 16 * 1024 * 1024), positive(this.options.limits?.maxItems, 2048)),
400
+ /**
401
+ * context installs this port on the shared observation; Turn.steer invokes it explicitly.
402
+ * Normalize input and await request with this execution's atomic target. Admission/dispatch/
403
+ * wait failures reject Turn.steer; caller cancellation leaves the native steer alive.
404
+ */
405
+ steer: async (input, wait) => { await this.request('turn/steer', { ...this.requestIdentity(), submissionId: this.createId(), expectedTurnId: id, input: inputBlocks(input) }, wait); },
406
+ /**
407
+ * context installs this port for explicit Turn.interrupt calls, awaiting request's native
408
+ * control receipt and propagating its failures. Teardown never invokes it; native terminal
409
+ * events still own execution completion after interruption.
410
+ */
411
+ interrupt: async (wait) => { await this.request('turn/interrupt', { ...this.requestIdentity(), turnId: id }, wait); },
412
+ };
413
+ this.executions.set(id, context);
414
+ return context;
415
+ }
416
+ /**
417
+ * context calls under execution-count pressure. Remove at most the oldest eligible terminal
418
+ * execution, subtract its retained bytes and remove its acknowledged submissions. Older unbound
419
+ * inputs pin possible late results; returning without eviction makes context reject capacity instead
420
+ * of silently losing a future association. Existing Turn handles keep their shared context.
421
+ */
422
+ reclaimExecution() {
423
+ for (const [key, execution] of this.executions) {
424
+ if (!execution.terminal)
425
+ continue;
426
+ const terminalSeq = execution.terminalSeq;
427
+ // New inputs cannot need an already-ended execution; older unbound inputs may.
428
+ const pinned = terminalSeq !== undefined && [...this.submissions.values()].some(submission => !submission.definitivelyRejected && !submission.target && submission.createdSeq < terminalSeq);
429
+ if (pinned)
430
+ continue;
431
+ this.totalBytes -= execution.accumulator.size;
432
+ this.executions.delete(key);
433
+ for (const [id, record] of this.submissions)
434
+ if (record.target === execution && record.acknowledged)
435
+ this.submissions.delete(id);
436
+ break;
437
+ }
438
+ }
439
+ /** emit and tryEmit share the configured count budget, which defaults to 2048 queued events. */
440
+ maxQueuedEvents() {
441
+ return this.options.runtime?.queue?.maxEvents ?? 2048;
442
+ }
443
+ /**
444
+ * emit and tryEmit use this shared rule before reserving bytes. Report a frame/pending-byte budget
445
+ * overflow without changing accounting; callers decide whether to reject or return false and fail
446
+ * observation.
447
+ */
448
+ eventExceedsByteCapacity(bytes) {
449
+ return bytes > (this.options.runtime?.queue?.maxFrameBytes ?? 8 * 1024 * 1024) || this.pendingBytes + bytes > (this.options.runtime?.queue?.maxBytes ?? 16 * 1024 * 1024);
450
+ }
451
+ /**
452
+ * emit schedules this synchronous phase on source and observes throws through fail. Validate before
453
+ * admitting state changes, then project private content, retain execution results and publish the
454
+ * public event. Ignore duplicate lifecycle events; settle terminal results before ending their stream.
455
+ * Queued work arriving after observation closes is ignored.
456
+ */
457
+ publish(rawPayload, scope) {
458
+ if (this.closed)
459
+ return;
460
+ const turnId = this.validateEvent(rawPayload, scope);
461
+ const admitted = this.admitEvent(rawPayload, turnId);
462
+ if (!admitted)
463
+ return;
464
+ // Privacy projection preserves protocol identities while redacting content.
465
+ const publicPayload = this.secrets.event(admitted.payload, turnId);
466
+ const event = { ...scope, ...(turnId ? { turnId } : {}), sessionId: this.id, streamId: this.streamId, seq: ++this.seq, timestamp: (this.options.runtime?.now ?? (() => new Date().toISOString()))(), ...publicPayload };
467
+ assertEvent(event);
468
+ if (admitted.execution)
469
+ this.retainEvent(admitted.execution, event);
470
+ this.updateStatus();
471
+ this.hub.publish(event);
472
+ }
473
+ /**
474
+ * publish calls before any lifecycle mutation. Validate envelope/payload identity and extension
475
+ * support, returning the optional execution ID used for admission and retention. Shape, identity or
476
+ * capability errors throw into emit's failure chain without reserving execution/submission state.
477
+ */
478
+ validateEvent(rawPayload, scope) {
479
+ const turnId = scope.turnId ?? (rawPayload.type === 'turn/started' || rawPayload.type === 'turn/completed' ? rawPayload.payload.turnId : undefined);
480
+ if ((rawPayload.type === 'turn/started' || rawPayload.type === 'turn/completed') && scope.turnId && scope.turnId !== rawPayload.payload.turnId)
481
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Conflicting execution identities');
482
+ if (rawPayload.type === 'extension' && !supportsExtension(this.capabilities, rawPayload.payload.name, rawPayload.payload.version))
483
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
484
+ const itemId = payloadItemId(rawPayload);
485
+ if (scope.itemId && itemId && scope.itemId !== itemId)
486
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Conflicting item identities');
487
+ // Invalid shapes must not reserve execution or submission state.
488
+ assertEvent({ ...scope, sessionId: this.id, streamId: this.streamId, seq: this.seq + 1, timestamp: new Date().toISOString(), ...rawPayload });
489
+ return turnId;
490
+ }
491
+ /**
492
+ * publish calls after validateEvent and consumes the returned payload/context for privacy projection
493
+ * and retention. Route submission and session changes separately from execution admission; ignored
494
+ * duplicates return undefined. Unknown/conflicting identity or invalid lifecycle transitions throw
495
+ * into emit's observation-failure chain. This phase neither publishes nor settles terminal results.
496
+ */
497
+ admitEvent(rawPayload, turnId) {
498
+ switch (rawPayload.type) {
499
+ case 'submission/bound': {
500
+ const submission = this.submissions.get(rawPayload.payload.submissionId);
501
+ if (!submission)
502
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Unknown submission binding');
503
+ this.bindRecord(submission, rawPayload.payload.turnId);
504
+ return { payload: rawPayload };
505
+ }
506
+ case 'submission/status':
507
+ return { payload: this.admitSubmissionStatus(rawPayload) };
508
+ case 'session/metadata':
509
+ this.state.metadata = { ...this.state.metadata, ...structuredClone(rawPayload.payload) };
510
+ return { payload: rawPayload };
511
+ case 'session/state':
512
+ if (!matchesBinding(rawPayload.payload.binding, this.binding))
513
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
514
+ this.adoptState(rawPayload.payload);
515
+ this.updateStatus();
516
+ return { payload: { type: 'session/state', payload: this.state } };
517
+ }
518
+ if (!turnId)
519
+ return { payload: rawPayload };
520
+ const execution = this.context(turnId);
521
+ if (!this.admitExecutionEvent(execution, rawPayload))
522
+ return;
523
+ return { payload: rawPayload, execution };
524
+ }
525
+ /**
526
+ * admitEvent routes submission/status here before publish can expose it. Reject an unresolved
527
+ * association for rejected/unknown status, retaining unknown outcomes for late binding, then return
528
+ * projected diagnostics. Unknown IDs or projection failures throw into emit's observation-failure
529
+ * chain; acknowledgement remains owned by dispatch bookkeeping.
530
+ */
531
+ admitSubmissionStatus(event) {
532
+ const submission = this.submissions.get(event.payload.submissionId);
533
+ if (!submission)
534
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Unknown submission status');
535
+ if (event.payload.status === 'rejected' || event.payload.status === 'unknown') {
536
+ submission.definitivelyRejected = event.payload.status === 'rejected';
537
+ const error = event.payload.error;
538
+ submission.executionBinding.reject(error ? new HarnessSdkError(error.code, error.code, error.retryable, error.outcomeUnknown) : new HarnessSdkError('SUBMISSION_REJECTED'));
539
+ }
540
+ return this.projectSubmissionStatus(event);
541
+ }
542
+ /**
543
+ * admitSubmissionStatus calls after any association rejection. Return status/identity unchanged
544
+ * while projecting receipt and error content so private native diagnostics cannot reach observers.
545
+ * Projection errors propagate through admission to emit; public event publication remains with publish.
546
+ */
547
+ projectSubmissionStatus(event) {
548
+ const { receipt, error } = event.payload;
549
+ return { ...event, payload: {
550
+ ...event.payload,
551
+ ...(receipt ? { receipt: this.secrets.json(publicValue(receipt)) } : {}),
552
+ ...(error ? { error: {
553
+ ...error,
554
+ message: this.secrets.text(error.message),
555
+ ...(error.details ? { details: this.secrets.json(publicValue(error.details)) } : {}),
556
+ } } : {}),
557
+ } };
558
+ }
559
+ /**
560
+ * admitEvent calls with the context for a validated execution event. Enforce native turn/item order,
561
+ * mutating execution-local markers only for admitted transitions. Return false to suppress duplicate
562
+ * turn starts/completions or item completions; throw for invalid transitions. retainEvent owns terminal
563
+ * settlement after privacy projection, so this phase does not finish the execution.
564
+ */
565
+ admitExecutionEvent(execution, rawPayload) {
566
+ if (rawPayload.type === 'turn/started') {
567
+ if (execution.started || execution.terminal)
568
+ return false;
569
+ execution.started = true;
570
+ }
571
+ if (rawPayload.type === 'turn/completed') {
572
+ if (execution.terminal)
573
+ return false;
574
+ if (!execution.started)
575
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Turn ended without start');
576
+ }
577
+ if (rawPayload.type.startsWith('item/'))
578
+ return this.admitItemEvent(execution, rawPayload);
579
+ return true;
580
+ }
581
+ /**
582
+ * admitExecutionEvent delegates item transitions here after payload validation. Require an active
583
+ * execution and reject repeated starts or deltas without a live item. Completion snapshots may
584
+ * introduce an item without a start; suppress repeated completions without changing its markers.
585
+ */
586
+ admitItemEvent(execution, payload) {
587
+ if (!execution.started || execution.terminal)
588
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Item outside execution');
589
+ const itemId = payloadItemId(payload);
590
+ if (!itemId)
591
+ return true;
592
+ const previousState = execution.items.get(itemId);
593
+ if ((payload.type === 'item/started' && previousState !== undefined) ||
594
+ (payload.type === 'item/delta' && previousState !== 'started'))
595
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Invalid item lifecycle');
596
+ if (payload.type === 'item/completed' && previousState === 'completed')
597
+ return false;
598
+ if (payload.type !== 'item/delta')
599
+ execution.items.set(itemId, payload.type === 'item/completed' ? 'completed' : 'started');
600
+ return true;
601
+ }
602
+ /**
603
+ * publish passes an admitted public event here before session-wide fanout. Accumulate bounded result
604
+ * content and update shared byte accounting; accumulator/budget errors throw into emit's failure chain.
605
+ * For native completion, record terminal sequence and resolve Turn.result before publishing and ending
606
+ * the execution hub. Session-wide publication and activity recomputation remain with publish.
607
+ */
608
+ retainEvent(execution, event) {
609
+ const previous = execution.accumulator.size;
610
+ execution.accumulator.accept(event);
611
+ this.totalBytes += execution.accumulator.size - previous;
612
+ if (this.totalBytes > positive(this.options.limits?.maxResultBytes, 16 * 1024 * 1024))
613
+ throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
614
+ if (event.type === 'turn/completed') {
615
+ execution.terminal = true;
616
+ execution.terminalSeq = this.seq;
617
+ execution.completion.resolve(execution.accumulator.finish(event.payload));
618
+ }
619
+ execution.events.publish(event);
620
+ if (execution.terminal)
621
+ execution.events.end();
622
+ }
623
+ /**
624
+ * The context control callbacks, submitExecution, interrupt and close allocate fresh host request
625
+ * identity here. Return sessionId/requestId from the configured ID source; generator failures throw.
626
+ * Deduplicated dispatch reuses supplied identity rather than generating another request.
627
+ */
628
+ requestIdentity() { return { sessionId: this.id, requestId: this.createId() }; }
629
+ /**
630
+ * admitEvent calls for an ordered submission/bound event. Reject conflicting or definitively rejected
631
+ * bindings with PROTOCOL_ERROR; otherwise reuse/allocate the execution and resolve association.
632
+ * submitExecution consumes that promise to construct a Turn independently of native acknowledgement;
633
+ * the caller still owns event publication and dispatch bookkeeping.
634
+ */
635
+ bindRecord(record, turnId) {
636
+ if (record.target && record.target.id !== turnId)
637
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Submission rebound');
638
+ if (record.definitivelyRejected)
639
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Rejected submission bound');
640
+ record.target ??= this.context(turnId);
641
+ record.executionBinding.resolve(record.target);
642
+ }
643
+ /**
644
+ * submitExecution uses this on dispatch rejection; onRequestFailed calls after failure-status
645
+ * publication. Reject unresolved association with the original cause and track definitive rejection
646
+ * and acknowledgement. Unknown outcomes stay eligible for late binding; a pending dispatch remains
647
+ * pinned until bookkeeping acknowledges it. A resolved association is never undone.
648
+ */
649
+ rejectSubmission({ record, cause, dispatch }) {
650
+ record.definitivelyRejected = !sdkError(cause).outcomeUnknown;
651
+ record.acknowledged ||= dispatch === 'acknowledged';
652
+ record.executionBinding.reject(cause);
653
+ }
654
+ /**
655
+ * submitExecution allocates before waiting for association; reserveSubmission also uses it for dispatch
656
+ * admission, including raw requests. Return existing bookkeeping or allocate a binding gate, reclaiming
657
+ * at most one acknowledged rejected/terminal entry under pressure. Throw BACKPRESSURE_EXCEEDED rather
658
+ * than evict unresolved work; callers own reservation, acknowledgement and settlement.
659
+ */
660
+ getOrCreateSubmission(id) {
661
+ const existing = this.submissions.get(id);
662
+ if (existing)
663
+ return existing;
664
+ const limit = positive(this.options.limits?.maxSubmissions, 1024);
665
+ if (this.submissions.size >= limit)
666
+ this.reclaimSubmission();
667
+ if (this.submissions.size >= limit)
668
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED', 'Submission limit exceeded');
669
+ const record = { executionBinding: deferred(), createdSeq: this.seq, definitivelyRejected: false, acknowledged: false };
670
+ this.submissions.set(id, record);
671
+ return record;
672
+ }
673
+ /**
674
+ * getOrCreateSubmission calls under submission-count pressure. Evict the oldest acknowledged
675
+ * rejection or terminal association, if any. Unknown unbound submissions remain eligible for
676
+ * late binding; getOrCreateSubmission rejects admission if nothing can be reclaimed.
677
+ */
678
+ reclaimSubmission() {
679
+ for (const [id, record] of this.submissions) {
680
+ if (!record.acknowledged || (!record.definitivelyRejected && !record.target?.terminal))
681
+ continue;
682
+ this.submissions.delete(id);
683
+ return;
684
+ }
685
+ }
686
+ /**
687
+ * submit and review return this association wait. Validate before allocating bookkeeping, dispatch
688
+ * independently, then construct a Turn when binding resolves. Observe dispatch rejection separately
689
+ * so the original error reaches an unresolved association while admitted records stay pinned through
690
+ * status publication. Caller abort/deadline ends only waiting; late binding and native work continue.
691
+ * ID generation/cloning failures throw before dispatch; submit converts those throws to rejection.
692
+ */
693
+ submitExecution({ method, options }, wait) {
694
+ const waitError = requestWaitError(wait);
695
+ if (waitError)
696
+ return Promise.reject(waitError);
697
+ const submissionId = this.createId();
698
+ const params = structuredClone({ ...options, ...this.requestIdentity(), submissionId });
699
+ let record;
700
+ try {
701
+ // Association storage must exist before dispatch can publish a native binding.
702
+ this.validateRequestAdmission(method, params);
703
+ record = this.getOrCreateSubmission(submissionId);
704
+ }
705
+ catch (cause) {
706
+ return Promise.reject(cause);
707
+ }
708
+ // Observe the association independently: no native acceptance receipt is required.
709
+ void this.dispatchOperation(method, params).catch(
710
+ /** submitExecution observes rejection independently of its binding wait; preserve the cause, but leave admitted dispatches pinned until failure-status publication. */
711
+ cause => this.rejectSubmission({ record, cause, dispatch: record.requestId ? 'pending' : 'acknowledged' }));
712
+ return waitForRequest(async () => new Turn(await record.executionBinding.promise), wait, this.options.runtime?.requestTimeoutMs);
713
+ }
714
+ /**
715
+ * submitExecution checks before creating an association gate; dispatchOperation checks every
716
+ * dispatch, including replays. Lifecycle failures take precedence over protocol/capability errors.
717
+ * Callers return these throws as rejected promises before reserving native work.
718
+ */
719
+ validateRequestAdmission(method, params) {
720
+ this.assertCallable(method);
721
+ guardRequest(method, params, this.id, this.capabilities);
722
+ }
723
+ /**
724
+ * validateRequestAdmission checks lifecycle before protocol and capability admission.
725
+ * Throw for closed/closing/recovery-blocked business calls, retaining explicit interruption and
726
+ * interaction controls during close/recovery plus history and close during recovery. Callers expose
727
+ * these failures without native dispatch; this check changes no state.
728
+ */
729
+ assertCallable(method) {
730
+ if (this.closed)
731
+ throw new HarnessSdkError('SESSION_CLOSED');
732
+ const control = method === 'session/interrupt' || method === 'turn/interrupt' || method === 'interaction/respond' || method === 'interaction/cancel';
733
+ if (this.closing && !control)
734
+ throw new HarnessSdkError('SESSION_CLOSING');
735
+ if (this.recovering && !control && method !== 'history/read' && method !== 'session/close')
736
+ throw new HarnessSdkError('RECOVERY_REQUIRED');
737
+ }
738
+ /**
739
+ * request, submitExecution and closeRequest call before applying their own wait ownership. Validate
740
+ * and detach params, replay matching cached outcomes, admit capacity and reserve submission identity,
741
+ * then cache new work. Admission errors reject before native dispatch; request identity conflicts do
742
+ * not replace cached work. Serialization/cloning can throw to the surrounding caller; host deadlines
743
+ * never cancel the cached outcome or its bookkeeping.
744
+ */
745
+ dispatchOperation(method, params) {
746
+ try {
747
+ this.validateRequestAdmission(method, params);
748
+ }
749
+ catch (cause) {
750
+ return Promise.reject(cause);
751
+ }
752
+ if (Buffer.byteLength(JSON.stringify(params)) > (this.options.runtime?.queue?.maxFrameBytes ?? 8 * 1024 * 1024))
753
+ return Promise.reject(new HarnessSdkError('FRAME_TOO_LARGE'));
754
+ params = structuredClone(params);
755
+ const key = JSON.stringify([method, params.requestId]);
756
+ const fingerprint = JSON.stringify(params);
757
+ const previous = this.requests.get(key);
758
+ if (previous) {
759
+ if (previous.fingerprint !== fingerprint)
760
+ return Promise.reject(new HarnessSdkError('REQUEST_ID_CONFLICT'));
761
+ return previous.result;
762
+ }
763
+ if (!this.admitRequestCapacity())
764
+ return Promise.reject(new HarnessSdkError('BACKPRESSURE_EXCEEDED'));
765
+ if (isSubmissionMethod(method)) {
766
+ try {
767
+ this.reserveSubmission(params);
768
+ }
769
+ catch (cause) {
770
+ return Promise.reject(cause);
771
+ }
772
+ }
773
+ return this.cacheRequestOutcome({ method, params, key, fingerprint });
774
+ }
775
+ /**
776
+ * dispatchOperation calls after replay checks and before reserving submission identity. Reclaim at
777
+ * most the oldest settled request, then report whether a new dispatch fits. Unsettled outcomes stay
778
+ * cached even when their host observers have cancelled; a false result becomes BACKPRESSURE_EXCEEDED.
779
+ */
780
+ admitRequestCapacity() {
781
+ const limit = this.options.runtime?.requestCacheSize ?? 1024;
782
+ if (this.requests.size >= limit) {
783
+ for (const [id, value] of this.requests) {
784
+ if (!value.settled)
785
+ continue;
786
+ this.requests.delete(id);
787
+ break;
788
+ }
789
+ }
790
+ return !(this.requests.size >= limit);
791
+ }
792
+ /**
793
+ * dispatchOperation calls only for submission-bearing methods once request-cache capacity is
794
+ * available. Reserve one request ID per submission or throw a conflict/capacity error before native
795
+ * execution. Association alone does not acknowledge dispatch; outcome bookkeeping owns that step.
796
+ */
797
+ reserveSubmission(params) {
798
+ const existing = this.submissions.get(params.submissionId);
799
+ if (existing?.requestId && existing.requestId !== params.requestId)
800
+ throw new HarnessSdkError('SUBMISSION_ID_CONFLICT');
801
+ this.getOrCreateSubmission(params.submissionId).requestId = params.requestId;
802
+ }
803
+ /**
804
+ * dispatchOperation returns this shared outcome after admission. Cache before native work starts
805
+ * in a microtask, race that work against observation closure, and observe bookkeeping separately.
806
+ * Replays receive the same receipt/rejection without awaiting binding/status events; bookkeeping
807
+ * failures end observation through fail rather than replace the cached outcome.
808
+ */
809
+ cacheRequestOutcome({ method, params, key, fingerprint }) {
810
+ const requestOutcome = Promise.race([Promise.resolve().then(() => this.executeRequest(method, params)), this.ended.promise]);
811
+ const entry = { fingerprint, result: requestOutcome, settled: false };
812
+ this.requests.set(key, entry);
813
+ // The cached/returned outcome races the backend result against observation closure.
814
+ // It does not wait for binding or status publication; bookkeeping failures go to fail.
815
+ const outcomeBookkeeping = requestOutcome.then(
816
+ /** cacheRequestOutcome registers this on settlement; release cache capacity before success bookkeeping without changing the cached receipt if publication fails. */
817
+ result => {
818
+ entry.settled = true;
819
+ return this.onRequestSucceeded({ method, params, result });
820
+ },
821
+ /** cacheRequestOutcome registers this on rejection; release cache capacity before recovery/status handling while submission reclamation still waits for failure publication. */
822
+ cause => {
823
+ entry.settled = true;
824
+ return this.onRequestFailed({ method, params, cause });
825
+ });
826
+ void outcomeBookkeeping.catch(cause => { void this.fail(cause); });
827
+ return requestOutcome;
828
+ }
829
+ /**
830
+ * cacheRequestOutcome schedules this once per admitted request. Route interaction controls through
831
+ * the registry or await the backend, validate native identity before projecting content, then update
832
+ * request-owned state and return a frozen validated result. Failures reject the cached outcome unless
833
+ * observation closure wins its race; execution and state effects can still finish after that race.
834
+ * The caller owns outcome bookkeeping and never retries native work automatically.
835
+ */
836
+ async executeRequest(method, params) {
837
+ const nativeResult = await this.invokeRequest(method, params);
838
+ this.validateNativeResult(method, params, nativeResult);
839
+ let result = this.projectRequestResult(method, nativeResult);
840
+ if (method === 'history/read' && this.options.runtime?.redactHistory) {
841
+ result = await this.options.runtime.redactHistory(this.binding, result);
842
+ }
843
+ this.applyRequestResult(method, result);
844
+ assertResult(method, result);
845
+ return immutable(result);
846
+ }
847
+ /**
848
+ * executeRequest awaits one invocation after request admission. Keep manual interaction responses
849
+ * and cancellations in the registry so they share settlement with automatic handlers; dispatch other
850
+ * methods to the attached backend. Throws/rejections become cached request failures without retries.
851
+ */
852
+ invokeRequest(method, params) {
853
+ if (!this.backend)
854
+ throw new HarnessSdkError('NOT_INITIALIZED');
855
+ if (method === 'interaction/respond') {
856
+ const value = params;
857
+ return this.interactions.respond(value.interactionId, value.requestId, value.response);
858
+ }
859
+ if (method === 'interaction/cancel') {
860
+ const value = params;
861
+ return this.interactions.cancel(value.interactionId, value.reason);
862
+ }
863
+ return this.backend.execute(method, params);
864
+ }
865
+ /**
866
+ * executeRequest projects only after native-result validation. Redact history before the optional
867
+ * host hook, preserve submission/close receipts, and project other method results. Projection failures
868
+ * reject the cached outcome; executeRequest still owns hook invocation and final result validation.
869
+ */
870
+ projectRequestResult(method, result) {
871
+ if (method === 'history/read')
872
+ return this.secrets.history(result);
873
+ if (method === 'input/submit' || method === 'review/start' || method === 'session/close')
874
+ return result;
875
+ return this.secrets.result(method, result);
876
+ }
877
+ /**
878
+ * executeRequest commits projected config, revert-reference and goal results before freezing the
879
+ * returned receipt. Cloning failures reject that request; caller cancellation does not roll back
880
+ * these updates. Other methods leave request-owned state unchanged; events own execution activity.
881
+ */
882
+ applyRequestResult(method, result) {
883
+ if (method === 'session/config/set') {
884
+ this.state = structuredClone(result);
885
+ this.updateStatus();
886
+ }
887
+ if (method === 'session/revert') {
888
+ const nativeRef = result.nativeRef;
889
+ if (nativeRef)
890
+ this.state.binding = { ...this.binding, nativeRef: structuredClone(nativeRef) };
891
+ }
892
+ if (method === 'goal/set' || method === 'goal/clear')
893
+ this.state.goal = structuredClone(result);
894
+ }
895
+ /**
896
+ * executeRequest calls before privacy projection or request-owned state changes. Validate protocol
897
+ * shape and submission/steer identities; malformed results call rejectNativeResult, which throws an
898
+ * unknown-outcome protocol error and starts observation teardown. On success the assertion narrows
899
+ * the native value for projection.
900
+ */
901
+ validateNativeResult(method, params, result) {
902
+ try {
903
+ assertResult(method, result);
904
+ }
905
+ catch {
906
+ this.rejectNativeResult('Invalid native result');
907
+ }
908
+ if (isSubmissionMethod(method) && result.submissionId !== params.submissionId) {
909
+ this.rejectNativeResult('Conflicting submission identity');
910
+ }
911
+ if (method === 'turn/steer' && result.turnId !== params.expectedTurnId) {
912
+ this.rejectNativeResult('Conflicting steer target');
913
+ }
914
+ }
915
+ /**
916
+ * validateNativeResult calls for malformed shape or conflicting identity. Start fail without waiting
917
+ * for cleanup, then throw the same unknown-outcome protocol error into executeRequest's rejection.
918
+ * Native effects cannot be inferred safely, so caller-visible failure must not claim definite rejection.
919
+ */
920
+ rejectNativeResult(message) {
921
+ const cause = new HarnessSdkError('PROTOCOL_ERROR', message, false, true);
922
+ void this.fail(cause);
923
+ throw cause;
924
+ }
925
+ /**
926
+ * cacheRequestOutcome registers this success continuation separately from its returned receipt.
927
+ * Acknowledge a retained submission, await receipt-based binding before status, and publish accepted/
928
+ * queued/dispatched disposition while observation remains open. A publication failure goes to the
929
+ * cache's fail observer without replacing the receipt; non-submission outcomes need no bookkeeping.
930
+ */
931
+ async onRequestSucceeded({ method, params, result }) {
932
+ if (!isSubmissionMethod(method))
933
+ return;
934
+ const response = result;
935
+ const record = this.submissions.get(response.submissionId);
936
+ if (!record)
937
+ return;
938
+ record.acknowledged = true;
939
+ if ('turnId' in response) {
940
+ if (method === 'turn/steer' && response.turnId !== params.expectedTurnId)
941
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Wrong steer target');
942
+ await this.bindSubmission(response.submissionId, response.turnId);
943
+ }
944
+ const disposition = 'disposition' in response ? response.disposition : undefined;
945
+ if (!this.closed)
946
+ await this.emit({ type: 'submission/status', payload: {
947
+ submissionId: response.submissionId,
948
+ status: disposition === 'dispatched' || disposition === 'queued' ? disposition : 'accepted',
949
+ ...('receipt' in response && response.receipt ? { receipt: response.receipt } : {}),
950
+ } });
951
+ }
952
+ /**
953
+ * cacheRequestOutcome registers this rejection continuation separately from its returned failure.
954
+ * Mark eligible unknown outcomes as requiring recovery, then publish submission failure before
955
+ * acknowledging it with rejectSubmission. Publication failure goes to the cache's fail observer;
956
+ * unknown outcomes remain available for late binding. Definite submission rejection alone leaves
957
+ * unrelated executions usable.
958
+ */
959
+ async onRequestFailed({ method, params, cause }) {
960
+ const error = sdkError(cause);
961
+ if (error.outcomeUnknown && method !== 'history/read' && method !== 'turn/interrupt' && method !== 'session/interrupt') {
962
+ this.recovering = true;
963
+ this.updateStatus();
964
+ }
965
+ if (!isSubmissionMethod(method))
966
+ return;
967
+ const id = params.submissionId, record = this.submissions.get(id);
968
+ if (record && !this.closed)
969
+ await this.emit({ type: 'submission/status', payload: { submissionId: id, status: error.outcomeUnknown ? 'unknown' : 'rejected', error: { code: error.code, message: error.code, retryable: error.retryable, outcomeUnknown: error.outcomeUnknown } } });
970
+ if (record)
971
+ this.rejectSubmission({ record, cause: error, dispatch: 'acknowledged' });
972
+ }
973
+ /**
974
+ * requestInteraction calls only after requested-event publication. Invoke the supplied host handler
975
+ * with a cloned native request and abort signal, then send its answer through InteractionRegistry.
976
+ * Skip aborted invocation/delivery; release capacity only when host work settles, even after native
977
+ * settlement. Handler/delivery failure cancels an actionable interaction; secondary cancellation
978
+ * errors are observed and suppressed. This scheduling does not return a handler wait to the adapter.
979
+ */
980
+ startInteractionHandler({ request, handler, controller }) {
981
+ // Keep handler invocation and answer delivery in separate promise stages; a sentinel
982
+ // distinguishes a skipped invocation without forcing an optional response value.
983
+ const skipped = Symbol('aborted_handler');
984
+ void Promise.resolve().then(() => controller.signal.aborted ? skipped : handler(structuredClone(request), { signal: controller.signal })).then(async (response) => {
985
+ if (!controller.signal.aborted && response !== skipped)
986
+ await this.interactions.respond(request.interactionId, this.createId(), response);
987
+ }).catch(
988
+ /** startInteractionHandler registers this failure continuation; cancel only actionable requests and consume secondary cancellation errors because teardown owns aborted ones. */
989
+ async () => {
990
+ if (!controller.signal.aborted)
991
+ await this.interactions.cancel(request.interactionId, 'handler_failed').catch(() => { });
992
+ }).finally(
993
+ /** startInteractionHandler registers this finalizer to release capacity after host work settles; native settlement may already have aborted its signal. */
994
+ () => {
995
+ this.handlerCount--;
996
+ this.handlers.delete(request.interactionId);
997
+ });
998
+ }
999
+ /**
1000
+ * fail, finishClose and dispose call at their respective teardown stages. Mark closed, reject the
1001
+ * closure race and unresolved associations/results, abort handler signals and end unfinished streams.
1002
+ * Completed execution snapshots/streams remain intact. Callers own interaction cancellation, backend
1003
+ * release and any native close; rejecting observation does not assert execution stopped.
1004
+ */
1005
+ endObservation(cause) {
1006
+ this.observationFailure ??= cause;
1007
+ this.closed = true;
1008
+ this.updateStatus();
1009
+ this.ended.reject(cause);
1010
+ for (const value of this.submissions.values())
1011
+ value.executionBinding.reject(cause);
1012
+ for (const execution of this.executions.values())
1013
+ if (!execution.terminal) {
1014
+ execution.completion.reject(cause);
1015
+ execution.events.end(cause);
1016
+ }
1017
+ for (const controller of this.handlers.values())
1018
+ controller.abort();
1019
+ this.handlers.clear();
1020
+ this.hub.end(cause);
1021
+ }
1022
+ /**
1023
+ * close and request(session/close) return this caller wait around shared close work or, once
1024
+ * observation is closed, backend release. Invalid or already-aborted waits reject before either
1025
+ * operation starts. Later abort/deadline affects only the caller's wait.
1026
+ */
1027
+ closeRequest(params, wait) {
1028
+ const waitError = requestWaitError(wait);
1029
+ if (waitError)
1030
+ return Promise.reject(waitError);
1031
+ if (this.closed)
1032
+ return waitForRequest(() => this.closedReceipt(), wait, this.options.runtime?.requestTimeoutMs);
1033
+ const closing = this.closing ?? this.startClose(params);
1034
+ return waitForRequest(() => closing, wait, this.options.runtime?.requestTimeoutMs);
1035
+ }
1036
+ /**
1037
+ * closeRequest asks for a receipt on an already-closed session. Await backend release and
1038
+ * propagate its failure before returning the stored native receipt or an unconfirmed stop.
1039
+ */
1040
+ async closedReceipt() {
1041
+ await this.disposeBackend();
1042
+ return this.closeReceipt ?? { closed: true, observationClosed: true, executionStopped: 'unconfirmed' };
1043
+ }
1044
+ /**
1045
+ * closeRequest starts this once per close attempt. Dispatch before marking the session closing
1046
+ * so close itself passes the lifecycle guard. A successful receipt proceeds through finishClose.
1047
+ * Dispatch rejection clears the close gate, marks recovery when the outcome is unknown and
1048
+ * rejects shared close work with the original cause.
1049
+ */
1050
+ startClose(params) {
1051
+ const native = this.dispatchOperation('session/close', params);
1052
+ this.closing = native.then(result => this.finishClose(result), cause => {
1053
+ this.closing = undefined;
1054
+ if (sdkError(cause).outcomeUnknown)
1055
+ this.recovering = true;
1056
+ this.updateStatus();
1057
+ throw cause;
1058
+ });
1059
+ void this.closing.catch(() => { });
1060
+ this.updateStatus();
1061
+ return this.closing;
1062
+ }
1063
+ /**
1064
+ * startClose registers this on native success and shares its result or cleanup rejection with host
1065
+ * waiters. Store the receipt, attempt interaction cancellation then backend release in order, and end
1066
+ * observation even if cleanup failed. Throw a safe combined error only after both attempts; native stop
1067
+ * confirmation changes unfinished-observation diagnostics without inventing terminal Turn results.
1068
+ */
1069
+ async finishClose(result) {
1070
+ this.closeReceipt = result;
1071
+ const causes = [];
1072
+ try {
1073
+ await this.interactions.cancelAll('session_closed');
1074
+ }
1075
+ catch (cause) {
1076
+ causes.push(cause);
1077
+ }
1078
+ try {
1079
+ await this.disposeBackend();
1080
+ }
1081
+ catch (cause) {
1082
+ causes.push(cause);
1083
+ }
1084
+ this.endObservation(observationClosed({ executionStopped: result.executionStopped }));
1085
+ if (causes.length)
1086
+ throw combined(causes);
1087
+ return result;
1088
+ }
1089
+ /**
1090
+ * fail, closedReceipt, finishClose and dispose share the backend's returned release promise and its
1091
+ * rejection; never infer native stop. A synchronous disposal throw escapes before promise caching.
1092
+ * Before attachment the no-op promise does not own a backend arriving later: attach rejects/releases
1093
+ * that backend when observation has already closed.
1094
+ */
1095
+ disposeBackend() { return this.backendDisposal ??= this.backend?.dispose() ?? Promise.resolve(); }
1096
+ }
1097
+ /**
1098
+ * request, submitExecution and closeRequest reject with this failure before starting their waits.
1099
+ * An invalid explicit timeout takes precedence over an already-aborted signal.
1100
+ */
1101
+ function requestWaitError(wait) {
1102
+ if (invalidWait(wait))
1103
+ return new HarnessSdkError('INVALID_PARAMS');
1104
+ if (wait.signal?.aborted)
1105
+ return new HarnessSdkError('REQUEST_ABORTED');
1106
+ return undefined;
1107
+ }
1108
+ /**
1109
+ * dispatchOperation uses this guard to reserve submission identity; validateNativeResult and the
1110
+ * success/failure continuations use the same set to check and acknowledge outcomes. Return whether
1111
+ * a method carries submission identity without changing session state or dispatching work.
1112
+ */
1113
+ function isSubmissionMethod(method) {
1114
+ return method === 'input/submit' || method === 'review/start' || method === 'turn/steer';
1115
+ }
1116
+ /**
1117
+ * attach checks backend/snapshot bindings here; admitEvent checks native session/state before
1118
+ * adoption. Compare session, adapter and configuration identity, allowing nativeRef to change on
1119
+ * resume/revert. False makes the caller reject mismatched state; this predicate performs no cleanup.
1120
+ */
1121
+ function matchesBinding(candidate, expected) {
1122
+ return candidate.sessionId === expected.sessionId && candidate.adapterId === expected.adapterId && candidate.runtimeConfigId === expected.runtimeConfigId;
1123
+ }
1124
+ /**
1125
+ * validateEvent compares this payload identity with scope.itemId before admission;
1126
+ * admitItemEvent uses it for execution-local item ordering. Return the identity shared by item
1127
+ * snapshots/deltas, or undefined for other payloads; callers retain shape and lifecycle validation.
1128
+ */
1129
+ function payloadItemId(event) {
1130
+ if (event.type === 'item/delta')
1131
+ return event.payload.itemId;
1132
+ if (event.type === 'item/started' || event.type === 'item/completed')
1133
+ return event.payload.item.id;
1134
+ return undefined;
1135
+ }