@anureo/sdk 0.2.0 → 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 (90) hide show
  1. package/README.md +162 -65
  2. package/package.json +29 -24
  3. package/src/adapter/identity.d.ts +41 -0
  4. package/src/adapter/identity.js +71 -0
  5. package/src/client-options.d.ts +71 -0
  6. package/src/client-options.js +0 -0
  7. package/src/client.d.ts +106 -0
  8. package/src/client.js +254 -0
  9. package/src/diagnostics/errors.d.ts +15 -0
  10. package/src/diagnostics/errors.js +59 -0
  11. package/src/diagnostics/events.d.ts +46 -0
  12. package/src/diagnostics/events.js +47 -0
  13. package/src/diagnostics.d.ts +20 -0
  14. package/src/diagnostics.js +56 -0
  15. package/src/errors.d.ts +48 -0
  16. package/src/errors.js +63 -0
  17. package/src/index.d.ts +27 -0
  18. package/src/index.js +23 -0
  19. package/src/lifecycle/approval.d.ts +16 -0
  20. package/src/lifecycle/approval.js +18 -0
  21. package/src/lifecycle/deferred.d.ts +10 -0
  22. package/src/lifecycle/deferred.js +12 -0
  23. package/src/lifecycle/errors.d.ts +18 -0
  24. package/src/lifecycle/errors.js +45 -0
  25. package/src/lifecycle/events.d.ts +35 -0
  26. package/src/lifecycle/events.js +141 -0
  27. package/src/lifecycle/execution-observation.d.ts +33 -0
  28. package/src/lifecycle/execution-observation.js +0 -0
  29. package/src/lifecycle/immutable.d.ts +12 -0
  30. package/src/lifecycle/immutable.js +28 -0
  31. package/src/lifecycle/input.d.ts +9 -0
  32. package/src/lifecycle/input.js +19 -0
  33. package/src/lifecycle/interactions.d.ts +129 -0
  34. package/src/lifecycle/interactions.js +282 -0
  35. package/src/lifecycle/limits.d.ts +7 -0
  36. package/src/lifecycle/limits.js +13 -0
  37. package/src/lifecycle/result.d.ts +77 -0
  38. package/src/lifecycle/result.js +213 -0
  39. package/src/lifecycle/value.d.ts +4 -0
  40. package/src/lifecycle/value.js +5 -0
  41. package/src/ports.d.ts +224 -0
  42. package/src/ports.js +0 -0
  43. package/src/privacy/file-change-limits.d.ts +20 -0
  44. package/src/privacy/file-change-limits.js +124 -0
  45. package/src/privacy/secrets.d.ts +205 -0
  46. package/src/privacy/secrets.js +564 -0
  47. package/src/process.d.ts +92 -0
  48. package/src/process.js +331 -0
  49. package/src/queue.d.ts +143 -0
  50. package/src/queue.js +266 -0
  51. package/src/request.d.ts +28 -0
  52. package/src/request.js +81 -0
  53. package/src/requests/guard.d.ts +15 -0
  54. package/src/requests/guard.js +93 -0
  55. package/src/session.d.ts +459 -0
  56. package/src/session.js +1135 -0
  57. package/src/structured-output/adapter.d.ts +8 -0
  58. package/src/structured-output/adapter.js +54 -0
  59. package/src/structured-output/bridge.d.ts +81 -0
  60. package/src/structured-output/bridge.js +274 -0
  61. package/src/structured-output/errors.d.ts +17 -0
  62. package/src/structured-output/errors.js +31 -0
  63. package/src/structured-output/limits.d.ts +19 -0
  64. package/src/structured-output/limits.js +0 -0
  65. package/src/structured-output/schema.d.ts +20 -0
  66. package/src/structured-output/schema.js +122 -0
  67. package/src/structured-output/session.d.ts +329 -0
  68. package/src/structured-output/session.js +846 -0
  69. package/src/structured-output/stdio.mjs +83 -0
  70. package/src/structured-output/tool.d.ts +38 -0
  71. package/src/structured-output/tool.js +34 -0
  72. package/src/turn.d.ts +75 -0
  73. package/src/turn.js +95 -0
  74. package/src/types.d.ts +62 -0
  75. package/src/types.js +0 -0
  76. package/dist/diagnostics.d.ts +0 -15
  77. package/dist/diagnostics.js +0 -34
  78. package/dist/errors.d.ts +0 -15
  79. package/dist/file-change-limits.d.ts +0 -5
  80. package/dist/identity.d.ts +0 -13
  81. package/dist/index.d.ts +0 -8
  82. package/dist/index.js +0 -1474
  83. package/dist/interactions.d.ts +0 -20
  84. package/dist/ports.d.ts +0 -66
  85. package/dist/process.d.ts +0 -34
  86. package/dist/process.js +0 -191
  87. package/dist/queue.d.ts +0 -36
  88. package/dist/queue.js +0 -166
  89. package/dist/runtime.d.ts +0 -85
  90. package/dist/secrets.d.ts +0 -28
@@ -0,0 +1,282 @@
1
+ // Owns native interaction capacity, expiry, settlement and response replay.
2
+ import { createHash } from "node:crypto";
3
+ import { assertInteractionResponse } from "@anureo/protocol";
4
+ import { HarnessSdkError, sdkError } from "../errors.js";
5
+ import { waitForRequest } from "../request.js";
6
+ /**
7
+ * Track native requests, settle their answers and retain bounded response replay state.
8
+ * Session uses this SDK implementation helper and supplies publication and privacy hooks.
9
+ * Direct consumers own those integrations and should await cancelAll with a teardown reason
10
+ * when releasing a session. The registry owns request expiry and native response wait timers.
11
+ */
12
+ export class InteractionRegistry {
13
+ publish;
14
+ limits;
15
+ beforeResponse;
16
+ /**
17
+ * Configure settlement publication and synchronous pre-response checks without starting native
18
+ * work. Session supplies a publisher that aborts the host handler and awaits publication of
19
+ * interaction/resolved while observation is open. It also supplies SecretRedactor.rememberResponse
20
+ * to register secrets before delivery. respond runs the check for every validated attempt, including
21
+ * replays, and propagates a throw before delivery. Settlement awaits publication without a deadline.
22
+ */
23
+ constructor(publish, limits, beforeResponse) {
24
+ this.publish = publish;
25
+ this.limits = limits;
26
+ this.beforeResponse = beforeResponse;
27
+ }
28
+ /**
29
+ * Count requests that consume admission capacity in begin, including native delivery and
30
+ * cancellation in flight. Capacity becomes available when a request is marked resolved.
31
+ */
32
+ get pendingCount() {
33
+ return [...this.records.values()].filter((record) => record.state !== "resolved").length;
34
+ }
35
+ /**
36
+ * List detached copies of actionable requests for manual answering. Cloning failures throw
37
+ * without changing registry state. These copies retain native fields, so callers own projection
38
+ * before exposing them to observers.
39
+ */
40
+ pending() {
41
+ return [...this.records.values()]
42
+ .filter((record) => record.state === "pending")
43
+ .map((record) => structuredClone(record.request));
44
+ }
45
+ /**
46
+ * Register a unique unexpired request and an unrefed expiry timer. Duplicate IDs, exhausted
47
+ * capacity and invalid/elapsed lifetimes throw synchronously. A request-cloning failure rejects
48
+ * the returned promise after the expiry timer is scheduled. On success, the promise resolves
49
+ * with the answer after native delivery and result publication.
50
+ * Cancellation and peer settlement reject it after publication is attempted. If native delivery
51
+ * fails and publishing its unknown outcome also fails, this waiter stays unresolved; respond
52
+ * rejects the publication failure instead.
53
+ * Session.requestInteraction adopts this promise after publishing interaction/requested and
54
+ * scheduling any host handler. Adapters supply the responder through that sink method and own
55
+ * the native resources it uses.
56
+ */
57
+ begin(request, responder) {
58
+ if (this.records.has(request.interactionId))
59
+ throw new HarnessSdkError("INTERACTION_ALREADY_EXISTS");
60
+ if (this.pendingCount >= this.limits.maxPending)
61
+ throw new HarnessSdkError("BACKPRESSURE_EXCEEDED", "Pending interaction limit exceeded");
62
+ const ttl = interactionTtl(request, this.limits.timeoutMs);
63
+ if (!Number.isFinite(ttl) || ttl <= 0)
64
+ throw new HarnessSdkError("INTERACTION_EXPIRED");
65
+ this.evictOldestResolvedRecord();
66
+ const result = new Promise((resolve, reject) => {
67
+ // Expiry has no caller to receive cancellation failure. Settlement owns the request waiter.
68
+ const timer = setTimeout(() => { void this.cancel(request.interactionId, "timeout", "expired").catch(() => { }); }, ttl);
69
+ timer.unref?.();
70
+ this.records.set(request.interactionId, { request: structuredClone(request), responder, state: "pending", timer, resolve, reject });
71
+ });
72
+ // An adapter can still attach its await after the event publication; retain rejection observation meanwhile.
73
+ void result.catch(() => { });
74
+ return result;
75
+ }
76
+ /**
77
+ * Deliver an answer or replay the retained operation for the same request ID and serialized
78
+ * response. Validation and the synchronous pre-response hook run before any replay or delivery.
79
+ * Missing IDs reject with INTERACTION_EXPIRED. Conflicting answers to settling or resolved
80
+ * requests reject with INTERACTION_ALREADY_RESOLVED. The operation clears expiry, retains
81
+ * capacity during native delivery, and settles after the publication it initiates. If another
82
+ * settlement wins during delivery, late native success rejects with RESULT_UNKNOWN.
83
+ * Session.invokeRequest returns the promise for manual requests. Session.startInteractionHandler
84
+ * awaits it for automatic answers and attempts handler_failed cancellation on rejection while
85
+ * its signal remains active.
86
+ */
87
+ async respond(id, requestId, response) {
88
+ const record = this.records.get(id);
89
+ if (!record)
90
+ throw new HarnessSdkError("INTERACTION_EXPIRED");
91
+ assertInteractionResponse(record.request, response);
92
+ this.beforeResponse?.(record.request, response);
93
+ const fingerprint = responseFingerprint(response);
94
+ const replay = record.responseReplay;
95
+ if (replay && replay.requestId === requestId && replay.fingerprint === fingerprint) {
96
+ return replay.operation;
97
+ }
98
+ if (record.state !== "pending")
99
+ throw new HarnessSdkError("INTERACTION_ALREADY_RESOLVED");
100
+ // The responder can reenter synchronously. Mark the request settling before invoking it.
101
+ startSettlement(record);
102
+ const operation = this.deliverResponse({ id, record, response });
103
+ record.responseReplay = { requestId, fingerprint, operation };
104
+ return operation;
105
+ }
106
+ /**
107
+ * Cancel or expire an unresolved request, notifying native once when pending. The reason and
108
+ * outcome both default to "cancelled". Missing IDs reject with INTERACTION_EXPIRED. Resolved IDs
109
+ * and in-flight settlement with reason "cancelled" reject with INTERACTION_ALREADY_RESOLVED.
110
+ * Other reasons mark in-flight settlement unknown without another native call. Native notification
111
+ * failure or timeout also changes the outcome to unknown.
112
+ * Await publication, reject begin's waiter with INTERACTION_CANCELLED or RESULT_UNKNOWN, and
113
+ * return the outcome. If publication fails, the cancellation caller receives that failure and
114
+ * the original waiter still receives its lifecycle error. Session.invokeRequest returns this
115
+ * promise for manual cancellation. Session.startInteractionHandler and the expiry timer observe
116
+ * and suppress its rejection. cancelAll collects its outcome during teardown.
117
+ */
118
+ async cancel(id, reason = "cancelled", outcome = "cancelled") {
119
+ const record = this.records.get(id);
120
+ if (!record)
121
+ throw new HarnessSdkError("INTERACTION_EXPIRED");
122
+ if (record.state === "resolved")
123
+ throw new HarnessSdkError("INTERACTION_ALREADY_RESOLVED");
124
+ if (record.state === "pending")
125
+ return this.cancelPending({ id, record, reason, outcome });
126
+ if (reason === "cancelled")
127
+ throw new HarnessSdkError("INTERACTION_ALREADY_RESOLVED");
128
+ record.state = "resolved";
129
+ clearTimeout(record.timer);
130
+ const result = { interactionId: id, outcome: "unknown", reason };
131
+ await this.publishRejectedSettlement({ record, result, error: endedInFlight() });
132
+ return result;
133
+ }
134
+ /**
135
+ * Attempt concurrent cancellation of unresolved interactions, optionally limited to turnId; await
136
+ * every attempt and suppress individual failures. Session.fail, Session.finishClose and
137
+ * Session.dispose await this before releasing the backend. Use a reason other than "cancelled"
138
+ * to settle in-flight requests. The literal reason "connection_lost" requests an unknown outcome.
139
+ */
140
+ async cancelAll(reason, turnId) {
141
+ const ids = [...this.records]
142
+ .filter(([, record]) => record.state !== "resolved" && (turnId === undefined || record.request.turnId === turnId))
143
+ .map(([id]) => id);
144
+ const outcome = reason === "connection_lost" ? "unknown" : "cancelled";
145
+ await Promise.allSettled(ids.map((id) => this.cancel(id, reason, outcome)));
146
+ }
147
+ /**
148
+ * Publish native-side settlement without calling the responder; ignore unknown/resolved IDs
149
+ * and reject the waiter even for responded because no value is supplied. Clear expiry and release
150
+ * capacity before publication; publication failure rejects this call but still rejects the waiter.
151
+ * An unknown outcome rejects the waiter with RESULT_UNKNOWN; other outcomes use INTERACTION_ALREADY_RESOLVED.
152
+ * Session.resolveInteraction returns this promise to adapters reporting an already-settled native request.
153
+ */
154
+ async resolveExternally(result) {
155
+ const record = this.records.get(result.interactionId);
156
+ if (!record || record.state === "resolved")
157
+ return;
158
+ record.state = "resolved";
159
+ clearTimeout(record.timer);
160
+ await this.publishRejectedSettlement({
161
+ record, result,
162
+ error: new HarnessSdkError(result.outcome === "unknown" ? "RESULT_UNKNOWN" : "INTERACTION_ALREADY_RESOLVED", "Interaction was resolved by the native peer", false, result.outcome === "unknown"),
163
+ });
164
+ }
165
+ records = new Map();
166
+ /**
167
+ * begin calls after admission checks, before inserting a new request. At the replay bound, evict
168
+ * only the oldest resolved record so unresolved native work and its capacity remain accounted for.
169
+ * Duplicate checks run first: a retained ID cannot be reused by evicting its own replay guard.
170
+ */
171
+ evictOldestResolvedRecord() {
172
+ const atReplayLimit = this.records.size >= this.limits.maxPending + 1024;
173
+ if (!atReplayLimit)
174
+ return;
175
+ for (const [id, record] of this.records) {
176
+ if (record.state === "resolved") {
177
+ this.records.delete(id);
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * respond caches this operation after admission. Bound native delivery without retrying it, then
184
+ * publish before resolving begin's waiter. If delivery succeeds after another settlement resolves
185
+ * the record, reject with RESULT_UNKNOWN without publishing again. Normalize delivery/publication
186
+ * failures for both callers. If publishing the unknown outcome itself fails, that failure escapes
187
+ * before the original waiter is rejected. Cancellation uses publishRejectedSettlement's finally instead.
188
+ */
189
+ async deliverResponse({ id, record, response }) {
190
+ try {
191
+ await this.notifyNative(record, response);
192
+ if (record.state === "resolved")
193
+ throw endedInFlight();
194
+ const result = { interactionId: id, outcome: "responded" };
195
+ // Release capacity before publication, but never release the waiter before publication finishes.
196
+ record.state = "resolved";
197
+ await this.publish(result);
198
+ record.resolve(response);
199
+ return result;
200
+ }
201
+ catch (cause) {
202
+ // This also catches publication failures, not just responder failures.
203
+ const error = sdkError(cause, "RESULT_UNKNOWN", true);
204
+ if (record.state !== "resolved") {
205
+ record.state = "resolved";
206
+ await this.publish({ interactionId: id, outcome: "unknown", reason: error.code });
207
+ }
208
+ record.reject(error);
209
+ throw error;
210
+ }
211
+ }
212
+ /**
213
+ * cancel calls only for an actionable record. Hold capacity and clear expiry before notifying native;
214
+ * timeout or notification failure changes the requested outcome to unknown. Release capacity before
215
+ * publication, then reject begin's waiter even if publication fails. Once started, this attempt
216
+ * still publishes after its native wait, even if teardown or peer settlement has already published.
217
+ */
218
+ async cancelPending({ id, record, reason, outcome }) {
219
+ startSettlement(record);
220
+ try {
221
+ await this.notifyNative(record, null, reason);
222
+ }
223
+ catch {
224
+ outcome = "unknown";
225
+ }
226
+ record.state = "resolved";
227
+ const result = { interactionId: id, outcome, reason };
228
+ const outcomeUnknown = outcome === "unknown";
229
+ await this.publishRejectedSettlement({
230
+ record, result,
231
+ error: new HarnessSdkError(outcomeUnknown ? "RESULT_UNKNOWN" : "INTERACTION_CANCELLED", "Interaction is no longer actionable", false, outcomeUnknown),
232
+ });
233
+ return result;
234
+ }
235
+ /**
236
+ * Wait for publication before rejecting begin's waiter with its lifecycle error. A publication
237
+ * failure still reaches cancel, cancelPending or resolveExternally. Those callers clear expiry
238
+ * and release capacity before entering this step.
239
+ */
240
+ async publishRejectedSettlement({ record, result, error }) {
241
+ try {
242
+ await this.publish(result);
243
+ }
244
+ finally {
245
+ record.reject(error);
246
+ }
247
+ }
248
+ /**
249
+ * Bound one invocation of the adapter's registered callback. deliverResponse supplies the answer
250
+ * and cancelPending supplies null and a reason. Both await and classify native errors or timeout.
251
+ * A timeout ends the registry's wait and keeps observing late rejection. The adapter still owns
252
+ * completion and native resource cleanup.
253
+ */
254
+ notifyNative(record, ...args) {
255
+ return waitForRequest(() => record.responder(...args), { timeoutMs: this.limits.responseTimeoutMs ?? 30_000 });
256
+ }
257
+ }
258
+ /** Hold admission capacity and clear expiry before respond or cancelPending invokes the adapter. */
259
+ function startSettlement(record) {
260
+ record.state = "settling";
261
+ clearTimeout(record.timer);
262
+ }
263
+ /** Share the unknown-outcome error between in-flight teardown and a superseded response delivery. */
264
+ function endedInFlight() {
265
+ return new HarnessSdkError("RESULT_UNKNOWN", "Interaction ended while response was in flight", false, true);
266
+ }
267
+ /**
268
+ * InteractionRegistry.begin uses the returned duration before allocating its expiry timer. Clamp a
269
+ * nonempty request expiry to the configured interaction lifetime; omitted/empty expiry uses the full
270
+ * lifetime. begin rejects a non-finite or non-positive result before allocating its timer.
271
+ */
272
+ function interactionTtl(request, timeoutMs) {
273
+ return request.expiresAt ? Math.min(timeoutMs, new Date(request.expiresAt).getTime() - Date.now()) : timeoutMs;
274
+ }
275
+ /**
276
+ * InteractionRegistry.respond stores and compares this fingerprint before deciding whether to replay
277
+ * the response promise. Hash the serialized payload for replay detection; the request ID alone
278
+ * cannot distinguish conflicting retries.
279
+ */
280
+ function responseFingerprint(response) {
281
+ return createHash("sha256").update(JSON.stringify(response)).digest("hex");
282
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Validate the selected retention budget as a positive safe integer, using the caller's fallback
3
+ * for an omitted value. Throw INVALID_PARAMS on failure. assertClientOptions checks configured
4
+ * limits at construction. Session.context, getOrCreateSubmission and retainEvent use the returned
5
+ * budget for allocation or accounting in units chosen at each call site.
6
+ */
7
+ export declare function positive(value: number | undefined, fallback: number): number;
@@ -0,0 +1,13 @@
1
+ import { HarnessSdkError } from "../errors.js";
2
+ /**
3
+ * Validate the selected retention budget as a positive safe integer, using the caller's fallback
4
+ * for an omitted value. Throw INVALID_PARAMS on failure. assertClientOptions checks configured
5
+ * limits at construction. Session.context, getOrCreateSubmission and retainEvent use the returned
6
+ * budget for allocation or accounting in units chosen at each call site.
7
+ */
8
+ export function positive(value, fallback) {
9
+ const result = value ?? fallback;
10
+ if (!Number.isSafeInteger(result) || result <= 0)
11
+ throw new HarnessSdkError('INVALID_PARAMS');
12
+ return result;
13
+ }
@@ -0,0 +1,77 @@
1
+ import type { AhpEvent } from '@anureo/protocol';
2
+ import type { TurnResult } from "../types.js";
3
+ /**
4
+ * Session.context creates one accumulator per execution. Session.retainEvent supplies privacy-projected
5
+ * events in publication order and uses the terminal snapshot to resolve the promise read by Turn.result.
6
+ * Session owns event validation, publication, shared budgets and reclamation.
7
+ */
8
+ export declare class ResultAccumulator {
9
+ private readonly maxBytes;
10
+ private readonly maxItems;
11
+ /**
12
+ * Session.context supplies positive safe integers for serialized bytes and distinct items.
13
+ * Omitted session limits default to 16 MiB and 2,048 items before construction.
14
+ */
15
+ constructor(maxBytes: number, maxItems: number);
16
+ /**
17
+ * Return the UTF-8 JSON byte total of the individual items as if buffered text were materialized.
18
+ * Session.retainEvent uses the difference across an update for its shared budget, and
19
+ * Session.reclaimExecution subtracts the total on eviction. Usage and envelope bytes are excluded.
20
+ */
21
+ get size(): number;
22
+ /**
23
+ * Retain detached turn usage and item content before Session.retainEvent publishes the event.
24
+ * Snapshots replace buffered content and completed items ignore subsequent deltas. A conflicting
25
+ * patch throws PROTOCOL_ERROR. Exceeding either limit throws RESULT_BUDGET_EXCEEDED. These throws
26
+ * reject Session.emit and reach Session.fail, which rejects unfinished Turn.result waits.
27
+ * Serialization and clone failures also propagate to Session.emit.
28
+ */
29
+ accept(event: AhpEvent): void;
30
+ /**
31
+ * Build a detached, frozen snapshot from the current retention. Turn-scoped usage in the terminal
32
+ * event takes precedence over the latest retained turn usage. Final messages are joined with
33
+ * newlines in first-seen order. Failed results without native error details get TURN_FAILED.
34
+ * Session.retainEvent resolves the promise read by Turn.result with this snapshot for every native
35
+ * terminal status, then publishes the terminal event and ends the execution stream. If cloning or
36
+ * freezing throws, Session.emit rejects before the snapshot or event is delivered.
37
+ */
38
+ finish(payload: Extract<AhpEvent, {
39
+ type: 'turn/completed';
40
+ }>['payload']): TurnResult;
41
+ /**
42
+ * Reset retained items, completion markers, usage and bytes for reuse. Previously returned snapshots
43
+ * stay independent.
44
+ */
45
+ clear(): void;
46
+ private readonly items;
47
+ private readonly completed;
48
+ private bytes;
49
+ private usage?;
50
+ /**
51
+ * Apply a delta for an item without a completion marker. Snapshot patches take the replacement
52
+ * path. Text deltas buffer only their matching field on a retained item. Retention failures
53
+ * propagate to accept.
54
+ */
55
+ private acceptDelta;
56
+ /**
57
+ * acceptDelta passes patch values here after Session.publish has projected them.
58
+ * Only a patch object with a type discriminator can replace a snapshot. SecretRedactor.deltaValue
59
+ * validates its shape upstream. Throw PROTOCOL_ERROR on a missing base item or a mismatch in ID
60
+ * or family. A valid patch replaces the snapshot and discards its text buffer.
61
+ */
62
+ private applySnapshotPatch;
63
+ /**
64
+ * acceptDelta supplies a matching field and its snapshot value. Check the exact JSON byte change
65
+ * before updating the buffer or accounting. Throw RESULT_BUDGET_EXCEEDED without retaining the
66
+ * chunk on overflow. An empty chunk can establish an absent or non-string field. Preserve a
67
+ * buffered surrogate tail across empty chunks so a later chunk can complete the pair.
68
+ */
69
+ private append;
70
+ /**
71
+ * accept and applySnapshotPatch store detached snapshots here. Check capacity and clone before
72
+ * mutation. Budget failure throws RESULT_BUDGET_EXCEEDED and serialization or clone failures
73
+ * propagate. Replacing an existing entry discards its provisional buffer while preserving its
74
+ * first-seen position for finish.
75
+ */
76
+ private retainSnapshot;
77
+ }
@@ -0,0 +1,213 @@
1
+ import { HarnessSdkError } from "../errors.js";
2
+ import { immutable } from "./immutable.js";
3
+ /**
4
+ * Session.context creates one accumulator per execution. Session.retainEvent supplies privacy-projected
5
+ * events in publication order and uses the terminal snapshot to resolve the promise read by Turn.result.
6
+ * Session owns event validation, publication, shared budgets and reclamation.
7
+ */
8
+ export class ResultAccumulator {
9
+ maxBytes;
10
+ maxItems;
11
+ /**
12
+ * Session.context supplies positive safe integers for serialized bytes and distinct items.
13
+ * Omitted session limits default to 16 MiB and 2,048 items before construction.
14
+ */
15
+ constructor(maxBytes, maxItems) {
16
+ this.maxBytes = maxBytes;
17
+ this.maxItems = maxItems;
18
+ }
19
+ /**
20
+ * Return the UTF-8 JSON byte total of the individual items as if buffered text were materialized.
21
+ * Session.retainEvent uses the difference across an update for its shared budget, and
22
+ * Session.reclaimExecution subtracts the total on eviction. Usage and envelope bytes are excluded.
23
+ */
24
+ get size() { return this.bytes; }
25
+ /**
26
+ * Retain detached turn usage and item content before Session.retainEvent publishes the event.
27
+ * Snapshots replace buffered content and completed items ignore subsequent deltas. A conflicting
28
+ * patch throws PROTOCOL_ERROR. Exceeding either limit throws RESULT_BUDGET_EXCEEDED. These throws
29
+ * reject Session.emit and reach Session.fail, which rejects unfinished Turn.result waits.
30
+ * Serialization and clone failures also propagate to Session.emit.
31
+ */
32
+ accept(event) {
33
+ switch (event.type) {
34
+ case 'usage/updated':
35
+ if (event.payload.scope === 'turn')
36
+ this.usage = structuredClone(event.payload);
37
+ break;
38
+ case 'item/started':
39
+ case 'item/completed':
40
+ this.retainSnapshot(event.payload.item);
41
+ // Mark completion only after retention succeeds, so a rejected snapshot cannot suppress deltas.
42
+ if (event.type === 'item/completed')
43
+ this.completed.add(event.payload.item.id);
44
+ break;
45
+ case 'item/delta':
46
+ if (!this.completed.has(event.payload.itemId))
47
+ this.acceptDelta(event.payload);
48
+ break;
49
+ }
50
+ }
51
+ /**
52
+ * Build a detached, frozen snapshot from the current retention. Turn-scoped usage in the terminal
53
+ * event takes precedence over the latest retained turn usage. Final messages are joined with
54
+ * newlines in first-seen order. Failed results without native error details get TURN_FAILED.
55
+ * Session.retainEvent resolves the promise read by Turn.result with this snapshot for every native
56
+ * terminal status, then publishes the terminal event and ends the execution stream. If cloning or
57
+ * freezing throws, Session.emit rejects before the snapshot or event is delivered.
58
+ */
59
+ finish(payload) {
60
+ const items = [...this.items.values()].map(materializeItem);
61
+ const usage = payload.usage?.scope === 'turn' ? payload.usage : this.usage;
62
+ const common = { text: finalMessageText(items), items, ...(usage ? { usage } : {}) };
63
+ if (payload.status === 'failed') {
64
+ const error = payload.error ?? {
65
+ code: 'TURN_FAILED',
66
+ message: 'Turn failed',
67
+ retryable: false,
68
+ outcomeUnknown: false,
69
+ };
70
+ return immutable({ ...common, status: 'failed', error });
71
+ }
72
+ if (payload.status === 'interrupted') {
73
+ return immutable({ ...common, status: 'interrupted', ...(payload.error ? { error: payload.error } : {}) });
74
+ }
75
+ return immutable({ ...common, status: payload.status });
76
+ }
77
+ /**
78
+ * Reset retained items, completion markers, usage and bytes for reuse. Previously returned snapshots
79
+ * stay independent.
80
+ */
81
+ clear() {
82
+ this.items.clear();
83
+ this.completed.clear();
84
+ this.usage = undefined;
85
+ this.bytes = 0;
86
+ }
87
+ items = new Map();
88
+ completed = new Set();
89
+ bytes = 0;
90
+ usage;
91
+ /**
92
+ * Apply a delta for an item without a completion marker. Snapshot patches take the replacement
93
+ * path. Text deltas buffer only their matching field on a retained item. Retention failures
94
+ * propagate to accept.
95
+ */
96
+ acceptDelta({ itemId, kind, value, text }) {
97
+ const entry = this.items.get(itemId);
98
+ if (kind === 'patch') {
99
+ this.applySnapshotPatch(entry?.snapshot, value);
100
+ return;
101
+ }
102
+ if (!entry || text === undefined)
103
+ return;
104
+ const item = entry.snapshot;
105
+ if (item.type === 'agentMessage' && kind === 'text')
106
+ this.append(entry, 'text', item.text, text);
107
+ else if (item.type === 'reasoning' && kind === 'reasoningSummary')
108
+ this.append(entry, 'summary', item.summary, text);
109
+ else if (item.type === 'toolCall' && kind === 'output')
110
+ this.append(entry, 'output', item.output, text);
111
+ }
112
+ /**
113
+ * acceptDelta passes patch values here after Session.publish has projected them.
114
+ * Only a patch object with a type discriminator can replace a snapshot. SecretRedactor.deltaValue
115
+ * validates its shape upstream. Throw PROTOCOL_ERROR on a missing base item or a mismatch in ID
116
+ * or family. A valid patch replaces the snapshot and discards its text buffer.
117
+ */
118
+ applySnapshotPatch(current, value) {
119
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !('type' in value))
120
+ return;
121
+ if (!current || value.id !== current.id || value.type !== current.type) {
122
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'Conflicting item snapshot');
123
+ }
124
+ this.retainSnapshot(value);
125
+ }
126
+ /**
127
+ * acceptDelta supplies a matching field and its snapshot value. Check the exact JSON byte change
128
+ * before updating the buffer or accounting. Throw RESULT_BUDGET_EXCEEDED without retaining the
129
+ * chunk on overflow. An empty chunk can establish an absent or non-string field. Preserve a
130
+ * buffered surrogate tail across empty chunks so a later chunk can complete the pair.
131
+ */
132
+ append(entry, field, previous, chunk) {
133
+ const pending = entry.bufferedText;
134
+ if (!chunk.length && (pending || typeof previous === 'string'))
135
+ return;
136
+ const serializedByteDelta = serializedTextAppendByteDelta({ field, previous, chunk, bufferedText: pending });
137
+ if (this.bytes + serializedByteDelta > this.maxBytes)
138
+ throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
139
+ const bufferedText = pending ?? {
140
+ field,
141
+ chunks: [typeof previous === 'string' ? previous : ''],
142
+ };
143
+ bufferedText.chunks.push(chunk);
144
+ if (chunk.length)
145
+ bufferedText.lastCodeUnit = chunk.charCodeAt(chunk.length - 1);
146
+ entry.bufferedText = bufferedText;
147
+ entry.serializedBytes += serializedByteDelta;
148
+ this.bytes += serializedByteDelta;
149
+ }
150
+ /**
151
+ * accept and applySnapshotPatch store detached snapshots here. Check capacity and clone before
152
+ * mutation. Budget failure throws RESULT_BUDGET_EXCEEDED and serialization or clone failures
153
+ * propagate. Replacing an existing entry discards its provisional buffer while preserving its
154
+ * first-seen position for finish.
155
+ */
156
+ retainSnapshot(item) {
157
+ const size = Buffer.byteLength(JSON.stringify(item));
158
+ const previous = this.items.get(item.id);
159
+ const bytes = this.bytes - (previous?.serializedBytes ?? 0) + size;
160
+ if (bytes > this.maxBytes || (!previous && this.items.size >= this.maxItems))
161
+ throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
162
+ this.items.set(item.id, { snapshot: structuredClone(item), serializedBytes: size });
163
+ this.bytes = bytes;
164
+ }
165
+ }
166
+ /**
167
+ * Return the JSON byte change that append uses to check capacity, including the structural cost of
168
+ * establishing a text field and any surrogate pair formed with the end of the existing text.
169
+ */
170
+ function serializedTextAppendByteDelta({ field, previous, chunk, bufferedText }) {
171
+ const prefix = typeof previous === 'string' ? previous : '';
172
+ const lastCodeUnit = bufferedText ? bufferedText.lastCodeUnit : prefix.charCodeAt(prefix.length - 1);
173
+ const chunkContentBytes = Buffer.byteLength(JSON.stringify(chunk)) - 2; // Exclude string quotes.
174
+ const surrogatePairByteDelta = splitSurrogateByteAdjustment(lastCodeUnit, chunk);
175
+ let fieldStructureByteDelta = 0;
176
+ if (!bufferedText && typeof previous !== 'string') {
177
+ fieldStructureByteDelta = previous === undefined
178
+ ? Buffer.byteLength(JSON.stringify(field)) + 4 // Comma, colon and empty string quotes.
179
+ : 2 - Buffer.byteLength(JSON.stringify(previous)); // Replace the previous value with empty string quotes.
180
+ }
181
+ return chunkContentBytes + surrogatePairByteDelta + fieldStructureByteDelta;
182
+ }
183
+ /**
184
+ * ResultAccumulator.finish maps retained entries through this callback in first-seen item order.
185
+ * Overlay provisional text without consuming its buffer; finish still owns detaching/freezing the
186
+ * result, since entries without buffers return their retained snapshot directly.
187
+ */
188
+ function materializeItem({ snapshot, bufferedText }) {
189
+ return bufferedText
190
+ ? { ...snapshot, [bufferedText.field]: bufferedText.chunks.join('') }
191
+ : snapshot;
192
+ }
193
+ /**
194
+ * ResultAccumulator.finish derives host-visible text from materialized items. Keep final messages in
195
+ * retained order, including empty messages and their separators; interim messages remain in items.
196
+ */
197
+ function finalMessageText(items) {
198
+ return items
199
+ .filter((item) => item.type === 'agentMessage' && item.phase === 'final')
200
+ .map(item => item.text)
201
+ .join('\n');
202
+ }
203
+ /**
204
+ * Correct append accounting when a chunk joins a trailing high surrogate to a leading low surrogate.
205
+ * JSON replaces two isolated escapes (6 bytes each) with one UTF-8 pair (4 bytes); empty chunks and
206
+ * non-pair boundaries need no adjustment.
207
+ */
208
+ function splitSurrogateByteAdjustment(previousCodeUnit, chunk) {
209
+ const endsWithHighSurrogate = previousCodeUnit !== undefined && previousCodeUnit >= 0xd800 && previousCodeUnit <= 0xdbff;
210
+ const firstCodeUnit = chunk.charCodeAt(0);
211
+ const startsWithLowSurrogate = firstCodeUnit >= 0xdc00 && firstCodeUnit <= 0xdfff;
212
+ return endsWithHighSurrogate && startsWithLowSurrogate ? 4 - 2 * 6 : 0;
213
+ }
@@ -0,0 +1,4 @@
1
+ export { immutable } from "./immutable.js";
2
+ export { inputBlocks } from "./input.js";
3
+ export { positive } from "./limits.js";
4
+ export { deferred } from "./deferred.js";
@@ -0,0 +1,5 @@
1
+ // Compatibility re-exports for internal lifecycle helper imports.
2
+ export { immutable } from "./immutable.js";
3
+ export { inputBlocks } from "./input.js";
4
+ export { positive } from "./limits.js";
5
+ export { deferred } from "./deferred.js";