@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.
- package/LICENSE +201 -0
- package/README.md +185 -2
- package/package.json +66 -4
- package/src/adapter/identity.d.ts +41 -0
- package/src/adapter/identity.js +71 -0
- package/src/client-options.d.ts +71 -0
- package/src/client-options.js +0 -0
- package/src/client.d.ts +106 -0
- package/src/client.js +254 -0
- package/src/diagnostics/errors.d.ts +15 -0
- package/src/diagnostics/errors.js +59 -0
- package/src/diagnostics/events.d.ts +46 -0
- package/src/diagnostics/events.js +47 -0
- package/src/diagnostics.d.ts +20 -0
- package/src/diagnostics.js +56 -0
- package/src/errors.d.ts +48 -0
- package/src/errors.js +63 -0
- package/src/index.d.ts +27 -0
- package/src/index.js +23 -0
- package/src/lifecycle/approval.d.ts +16 -0
- package/src/lifecycle/approval.js +18 -0
- package/src/lifecycle/deferred.d.ts +10 -0
- package/src/lifecycle/deferred.js +12 -0
- package/src/lifecycle/errors.d.ts +18 -0
- package/src/lifecycle/errors.js +45 -0
- package/src/lifecycle/events.d.ts +35 -0
- package/src/lifecycle/events.js +141 -0
- package/src/lifecycle/execution-observation.d.ts +33 -0
- package/src/lifecycle/execution-observation.js +0 -0
- package/src/lifecycle/immutable.d.ts +12 -0
- package/src/lifecycle/immutable.js +28 -0
- package/src/lifecycle/input.d.ts +9 -0
- package/src/lifecycle/input.js +19 -0
- package/src/lifecycle/interactions.d.ts +129 -0
- package/src/lifecycle/interactions.js +282 -0
- package/src/lifecycle/limits.d.ts +7 -0
- package/src/lifecycle/limits.js +13 -0
- package/src/lifecycle/result.d.ts +77 -0
- package/src/lifecycle/result.js +213 -0
- package/src/lifecycle/value.d.ts +4 -0
- package/src/lifecycle/value.js +5 -0
- package/src/ports.d.ts +224 -0
- package/src/ports.js +0 -0
- package/src/privacy/file-change-limits.d.ts +20 -0
- package/src/privacy/file-change-limits.js +124 -0
- package/src/privacy/secrets.d.ts +205 -0
- package/src/privacy/secrets.js +564 -0
- package/src/process.d.ts +92 -0
- package/src/process.js +331 -0
- package/src/queue.d.ts +143 -0
- package/src/queue.js +266 -0
- package/src/request.d.ts +28 -0
- package/src/request.js +81 -0
- package/src/requests/guard.d.ts +15 -0
- package/src/requests/guard.js +93 -0
- package/src/session.d.ts +459 -0
- package/src/session.js +1135 -0
- package/src/structured-output/adapter.d.ts +8 -0
- package/src/structured-output/adapter.js +54 -0
- package/src/structured-output/bridge.d.ts +81 -0
- package/src/structured-output/bridge.js +274 -0
- package/src/structured-output/errors.d.ts +17 -0
- package/src/structured-output/errors.js +31 -0
- package/src/structured-output/limits.d.ts +19 -0
- package/src/structured-output/limits.js +0 -0
- package/src/structured-output/schema.d.ts +20 -0
- package/src/structured-output/schema.js +122 -0
- package/src/structured-output/session.d.ts +329 -0
- package/src/structured-output/session.js +846 -0
- package/src/structured-output/stdio.mjs +83 -0
- package/src/structured-output/tool.d.ts +38 -0
- package/src/structured-output/tool.js +34 -0
- package/src/turn.d.ts +75 -0
- package/src/turn.js +95 -0
- package/src/types.d.ts +62 -0
- package/src/types.js +0 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
// Owns per-session output admission, execution association, publication and revocation.
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { assertEvent } from '@anureo/protocol';
|
|
5
|
+
import { HarnessSdkError, sdkError } from "../errors.js";
|
|
6
|
+
import { guardRequest } from "../requests/guard.js";
|
|
7
|
+
import { OUTPUT_SERVER_NAME, toolError } from "./tool.js";
|
|
8
|
+
import { compileOutputSchema } from "./schema.js";
|
|
9
|
+
import { checkRelease, failure } from "./errors.js";
|
|
10
|
+
/**
|
|
11
|
+
* Internal BackendSession implementation constructed and tracked by withStructuredOutput's
|
|
12
|
+
* openSession port. Client.attachSession gives this wrapper to Session, which reads attachment state,
|
|
13
|
+
* dispatches through execute and releases it through disposeBackend. Owns one native session's output
|
|
14
|
+
* associations, fallback lane and serialized commitment. Hosts observe receipts independently of
|
|
15
|
+
* final events, and caller wait cancellation leaves native work and lane ownership alive.
|
|
16
|
+
*/
|
|
17
|
+
export class OutputSession {
|
|
18
|
+
/**
|
|
19
|
+
* withStructuredOutput's openSession port constructs this wrapper before tracking and opening it.
|
|
20
|
+
* Capture dependencies without I/O. The wrapper must await open before attachment and dispose on
|
|
21
|
+
* setup failure. dispose invokes forget after release attempts so adapter ownership can be removed.
|
|
22
|
+
*/
|
|
23
|
+
constructor(input) {
|
|
24
|
+
this.params = input.params;
|
|
25
|
+
this.sink = input.sink;
|
|
26
|
+
this.manager = input.manager;
|
|
27
|
+
this.limits = input.limits;
|
|
28
|
+
this.forget = input.forget;
|
|
29
|
+
}
|
|
30
|
+
/** Session.attach reads the native binding after open. validateEnvelope uses the requested identity until the native backend is available. */
|
|
31
|
+
get binding() { return this.backend?.binding ?? this.params.binding; }
|
|
32
|
+
/** Session.attach adopts this capability projection. The native tool or MCP lease can supply schema support without mutating backend capabilities. */
|
|
33
|
+
get capabilities() { return this.projectCapabilities(this.backend?.capabilities ?? {}); }
|
|
34
|
+
/** Session.attach restores this optional projected native state. Return a credential-redacted snapshot with the fallback capability revision applied. */
|
|
35
|
+
get state() { return this.backend?.state ? this.redact(this.projectState(this.backend.state)) : undefined; }
|
|
36
|
+
/**
|
|
37
|
+
* withStructuredOutput's openSession port awaits this before Client.attachSession receives the
|
|
38
|
+
* wrapper. Prepare an optional bridge before native open, then adopt the backend and its active
|
|
39
|
+
* turns. A reserved bridge name, lease cleanup failure or warning publication failure rejects
|
|
40
|
+
* setup. Other bridge preparation failures warn and continue. If disposal wins the open race,
|
|
41
|
+
* await native release and reject with its failure or SESSION_CLOSED.
|
|
42
|
+
* nativeOutput is the initialization negotiation result, used only to decide bridge preparation;
|
|
43
|
+
* per-session capabilities decide submission mode. Native-open and release failures reject to the
|
|
44
|
+
* adapter wrapper, which owns setup cleanup.
|
|
45
|
+
*/
|
|
46
|
+
async open({ adapter, nativeOutput }) {
|
|
47
|
+
// The optional port is an execution-location attestation, not a provider-name heuristic.
|
|
48
|
+
const transport = nativeOutput ? undefined : adapter.outputBridgeTransport?.(this.params);
|
|
49
|
+
// Avoid a promise turn before native dispatch when no bridge needs preparation.
|
|
50
|
+
const params = transport ? await this.prepareBridge(transport) : this.params;
|
|
51
|
+
const backend = await adapter.openSession(structuredClone(params), this.nativeSink());
|
|
52
|
+
this.backend = backend;
|
|
53
|
+
if (this.disposed) {
|
|
54
|
+
await backend.dispose();
|
|
55
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
56
|
+
}
|
|
57
|
+
backend.state?.activeTurnIds.forEach(id => this.active.add(id));
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Session.invokeRequest invokes this BackendSession port; Session.executeRequest validates and
|
|
62
|
+
* projects its result. Prepare output policy, check the transformed request against native
|
|
63
|
+
* capabilities, and dispatch once. Bind any receipt turn before returning. A native close
|
|
64
|
+
* receipt revokes output admission before the wrapper returns to Session. Failures
|
|
65
|
+
* reclaim only definitely rejected, unbound submissions and preserve safe cleanup diagnostics.
|
|
66
|
+
* Session owns request replay, caller wait cancellation and native-result validation. These waits
|
|
67
|
+
* never release a live output lane.
|
|
68
|
+
*/
|
|
69
|
+
async execute(method, original) {
|
|
70
|
+
if (this.disposed || !this.backend)
|
|
71
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
72
|
+
const params = structuredClone(original);
|
|
73
|
+
if (this.locked && ['input/submit', 'review/start', 'compact/start', 'session/config/set', 'session/revert'].includes(method))
|
|
74
|
+
throw new HarnessSdkError('STRUCTURED_OUTPUT_BUSY');
|
|
75
|
+
let submissionId;
|
|
76
|
+
try {
|
|
77
|
+
submissionId = requestSubmissionId(method, params);
|
|
78
|
+
const prepared = await this.prepareSubmission(method, params, submissionId);
|
|
79
|
+
const result = await this.dispatch(method, prepared, this.backend);
|
|
80
|
+
if (submissionId && result && typeof result === 'object' && 'turnId' in result && typeof result.turnId === 'string') {
|
|
81
|
+
const boundSubmissionId = submissionId;
|
|
82
|
+
await this.enqueue(() => this.bind(boundSubmissionId, result.turnId));
|
|
83
|
+
}
|
|
84
|
+
if (method === 'session/config/set')
|
|
85
|
+
return this.redact(this.projectState(result));
|
|
86
|
+
if (method === 'history/read')
|
|
87
|
+
return this.redact(result);
|
|
88
|
+
if (method === 'session/close')
|
|
89
|
+
await this.release();
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
return this.rejectDispatch(cause, submissionId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* withStructuredOutput's setup-failure/dispose paths and Session.disposeBackend await this shared
|
|
98
|
+
* cleanup promise. Close admission immediately, release output resources, then attempt backend
|
|
99
|
+
* disposal even after release rejection. Remove adapter ownership after both stages and propagate
|
|
100
|
+
* classified cleanup failures. open separately releases a backend that arrives after this teardown.
|
|
101
|
+
*/
|
|
102
|
+
dispose() {
|
|
103
|
+
return this.disposal ??= (async () => {
|
|
104
|
+
this.disposed = true;
|
|
105
|
+
const results = await Promise.allSettled([this.release()]);
|
|
106
|
+
results.push(...await Promise.allSettled([this.backend?.dispose()]));
|
|
107
|
+
this.forget();
|
|
108
|
+
checkRelease(results);
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
backend;
|
|
112
|
+
lease;
|
|
113
|
+
disposed = false;
|
|
114
|
+
disposal;
|
|
115
|
+
chain = Promise.resolve();
|
|
116
|
+
queuedBytes = 0;
|
|
117
|
+
queuedEvents = 0;
|
|
118
|
+
heldBytes = 0;
|
|
119
|
+
heldCount = 0;
|
|
120
|
+
outputBytes = 0;
|
|
121
|
+
pending = new Map();
|
|
122
|
+
outputs = new Map();
|
|
123
|
+
byTurn = new Map();
|
|
124
|
+
unresolved = new Set();
|
|
125
|
+
active = new Set();
|
|
126
|
+
terminalVersions = new Map();
|
|
127
|
+
terminalVersion = 0;
|
|
128
|
+
locked;
|
|
129
|
+
privateStrings = new Set();
|
|
130
|
+
params;
|
|
131
|
+
sink;
|
|
132
|
+
manager;
|
|
133
|
+
limits;
|
|
134
|
+
forget;
|
|
135
|
+
/**
|
|
136
|
+
* execute calls this after preparing the request. Validate its frame size, protocol shape and
|
|
137
|
+
* native binding and capabilities before dispatch. Return the native promise directly so the close
|
|
138
|
+
* continuation can revoke output admission as soon as its receipt arrives.
|
|
139
|
+
*/
|
|
140
|
+
dispatch(method, params, backend) {
|
|
141
|
+
if (Buffer.byteLength(JSON.stringify(params)) > (this.limits.queue?.maxFrameBytes ?? 8 * 1024 * 1024))
|
|
142
|
+
throw new HarnessSdkError('FRAME_TOO_LARGE');
|
|
143
|
+
guardRequest(method, params, this.binding.sessionId, backend.capabilities);
|
|
144
|
+
return backend.execute(method, params);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* open awaits this only for an attested MCP transport. Retain a lease and its private credentials
|
|
148
|
+
* before returning augmented native-open params. A reserved name rejects before allocation; other
|
|
149
|
+
* setup failures release any lease and publish a warning before returning the original params.
|
|
150
|
+
* Cleanup or warning-publication failures reject open, whose adapter caller owns session disposal.
|
|
151
|
+
*/
|
|
152
|
+
async prepareBridge(transport) {
|
|
153
|
+
if (this.params.mcpServers?.some(server => server.name === OUTPUT_SERVER_NAME))
|
|
154
|
+
throw new HarnessSdkError('INVALID_PARAMS', 'Reserved MCP server name');
|
|
155
|
+
try {
|
|
156
|
+
this.lease = await this.manager.create(transport, args => this.enqueue(async () => this.submitValue(args)));
|
|
157
|
+
const config = this.lease.config;
|
|
158
|
+
if (config.type === 'http') {
|
|
159
|
+
this.privateStrings.add(config.url);
|
|
160
|
+
Object.values(config.headers ?? {}).forEach(value => this.privateStrings.add(value));
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
Object.values(config.env ?? {}).forEach(value => this.privateStrings.add(value));
|
|
164
|
+
return { ...this.params, mcpServers: [...(this.params.mcpServers ?? []), config] };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
await this.lease?.dispose();
|
|
168
|
+
this.lease = undefined;
|
|
169
|
+
await this.sink.emit({ type: 'notice', payload: { severity: 'warning', code: 'STRUCTURED_OUTPUT_BRIDGE_UNAVAILABLE', message: 'Structured output bridge could not be prepared' } });
|
|
170
|
+
return this.params;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* open supplies these callbacks to the native adapter, which may invoke them before attachment.
|
|
175
|
+
* Route events and bindings through serialized output policy. Session handles interactions and
|
|
176
|
+
* direct failure reports. tryEmit returns false when admission throws and forwards both immediate
|
|
177
|
+
* and asynchronous failures to Session.fail.
|
|
178
|
+
*/
|
|
179
|
+
nativeSink() {
|
|
180
|
+
return {
|
|
181
|
+
bindSubmission: (submissionId, turnId) => this.emit({ type: 'submission/bound', payload: { submissionId, turnId } }),
|
|
182
|
+
emit: (event, scope = {}) => this.emit(event, scope),
|
|
183
|
+
tryEmit: (event, scope = {}) => {
|
|
184
|
+
try {
|
|
185
|
+
const operation = this.emit(event, scope);
|
|
186
|
+
void operation.catch(cause => this.sink.fail(cause));
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
void this.sink.fail(cause);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
requestInteraction: (request, responder) => this.sink.requestInteraction(request, responder),
|
|
195
|
+
resolveInteraction: result => this.sink.resolveInteraction(result),
|
|
196
|
+
fail: cause => this.sink.fail(cause),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* execute returns this rejection after preparation, dispatch or result projection fails. Reclaim
|
|
201
|
+
* only definite submission failures; reject leaves already-bound work alive until its terminal.
|
|
202
|
+
* Preserve the original cause unless cleanup fails, then attach safe cleanup diagnostics without
|
|
203
|
+
* replacing the dispatch error code or outcome. Unknown acceptance retains its output lane.
|
|
204
|
+
*/
|
|
205
|
+
async rejectDispatch(cause, submissionId) {
|
|
206
|
+
if (submissionId && !sdkError(cause).outcomeUnknown) {
|
|
207
|
+
try {
|
|
208
|
+
await this.reject(submissionId);
|
|
209
|
+
}
|
|
210
|
+
catch (cleanup) {
|
|
211
|
+
const error = sdkError(cause), release = sdkError(cleanup, 'RESOURCE_RELEASE_FAILED', true);
|
|
212
|
+
throw new HarnessSdkError(error.code, error.message, error.retryable, error.outcomeUnknown, { ...error.details, cleanupErrors: [{ code: release.code, outcomeUnknown: release.outcomeUnknown }] });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw cause;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The capabilities getter and projectState return this projection to Session attachment
|
|
219
|
+
* and state observers. Prefer the native tool extension over the MCP extension when both exist,
|
|
220
|
+
* retaining native schema support and suppressing fallback advertisement after disposal.
|
|
221
|
+
*/
|
|
222
|
+
projectCapabilities(raw) {
|
|
223
|
+
const extension = this.backend?.structuredOutputTool ? { name: 'x-anureo/structured-output-tool', version: '1' } : this.lease ? EXTENSION : undefined;
|
|
224
|
+
if (raw.turn?.outputSchema || !extension || this.disposed)
|
|
225
|
+
return structuredClone(raw);
|
|
226
|
+
return { ...structuredClone(raw), turn: { ...raw.turn, outputSchema: true }, extensions: [...(raw.extensions ?? []).filter(value => value.name !== extension.name), extension] };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The state getter, routeEarlyEvent and execute call this before exposing native state to
|
|
230
|
+
* Session. Return a new state with projected capabilities and the fallback revision offset so
|
|
231
|
+
* observers can distinguish synthetic support. Other state fields remain shared until redaction.
|
|
232
|
+
*/
|
|
233
|
+
projectState(state) {
|
|
234
|
+
return { ...state, capabilities: this.projectCapabilities(state.capabilities), capabilityRevision: state.capabilityRevision + (this.lease || this.backend?.structuredOutputTool ? 1 : 0) };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* emit queues acceptance here, prepareBridge and prepareOutput register callbacks that queue
|
|
238
|
+
* candidates here, and execute queues receipt binding. Run work after earlier queued work,
|
|
239
|
+
* returning its result/rejection while preserving the chain for later operations. Callers still own
|
|
240
|
+
* failure publication and cleanup.
|
|
241
|
+
*/
|
|
242
|
+
enqueue(work) {
|
|
243
|
+
const operation = this.chain.then(work);
|
|
244
|
+
this.chain = operation.then(() => { }, () => { });
|
|
245
|
+
return operation;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* nativeSink's emit, tryEmit and bindSubmission callbacks invoke this for native events.
|
|
249
|
+
* Reserve queue capacity and clone the envelope before serialized accept. Serialization, cloning
|
|
250
|
+
* and capacity failures throw synchronously. Processing failures await Session.fail before rejecting,
|
|
251
|
+
* and settlement releases queue counters. Adapters await the returned promise for publication order.
|
|
252
|
+
*/
|
|
253
|
+
emit(event, scope = {}) {
|
|
254
|
+
const bytes = Buffer.byteLength(JSON.stringify(event));
|
|
255
|
+
if (bytes > (this.limits.queue?.maxFrameBytes ?? 8 * 1024 * 1024)
|
|
256
|
+
|| this.queuedBytes + this.heldBytes + bytes > (this.limits.queue?.maxBytes ?? 16 * 1024 * 1024)
|
|
257
|
+
|| this.queuedEvents + this.heldCount >= (this.limits.queue?.maxEvents ?? 2048))
|
|
258
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
259
|
+
const copy = structuredClone({ event, scope });
|
|
260
|
+
this.queuedBytes += bytes;
|
|
261
|
+
this.queuedEvents++;
|
|
262
|
+
const operation = this.enqueue(() => this.accept(copy));
|
|
263
|
+
return operation.catch(async (cause) => {
|
|
264
|
+
await this.sink.fail(cause);
|
|
265
|
+
throw cause;
|
|
266
|
+
}).finally(() => {
|
|
267
|
+
this.queuedBytes -= bytes;
|
|
268
|
+
this.queuedEvents--;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* routeEarlyEvent awaits this after publishing submission/bound, while execute queues it
|
|
273
|
+
* for a receipt containing turnId. Associate the output and replay held events in order before the
|
|
274
|
+
* caller advances. Conflicting/earlier-execution bindings throw PROTOCOL_ERROR. Replay failures
|
|
275
|
+
* reject to emit or execute, whose failure paths still own observation teardown and cleanup.
|
|
276
|
+
*/
|
|
277
|
+
async bind(submissionId, turnId) {
|
|
278
|
+
this.unresolved.delete(submissionId);
|
|
279
|
+
const output = this.outputs.get(submissionId);
|
|
280
|
+
if (output && this.associateOutput(output, turnId) === 'already-ended')
|
|
281
|
+
return;
|
|
282
|
+
// Receipt-only backends can bind after emitting all their execution events.
|
|
283
|
+
const held = this.pending.get(turnId);
|
|
284
|
+
if (!held)
|
|
285
|
+
return;
|
|
286
|
+
this.pending.delete(turnId);
|
|
287
|
+
for (const envelope of held) {
|
|
288
|
+
this.heldCount--;
|
|
289
|
+
this.heldBytes -= Buffer.byteLength(JSON.stringify(envelope.event));
|
|
290
|
+
await this.accept(envelope);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* bind uses this decision before replaying held events. A late binding from an input admitted
|
|
295
|
+
* before the terminal can share that ended execution and is reclaimed immediately. Binding a
|
|
296
|
+
* newer input to an ended execution or changing an existing binding throws PROTOCOL_ERROR.
|
|
297
|
+
* Record competing schemas on the first output so a native success reports the conflict.
|
|
298
|
+
*/
|
|
299
|
+
associateOutput(output, turnId) {
|
|
300
|
+
if (output.turnId && output.turnId !== turnId)
|
|
301
|
+
throw new HarnessSdkError('PROTOCOL_ERROR', 'Conflicting structured output binding');
|
|
302
|
+
const endedAt = this.terminalVersions.get(turnId);
|
|
303
|
+
if (endedAt !== undefined) {
|
|
304
|
+
if (output.admittedVersion >= endedAt)
|
|
305
|
+
throw new HarnessSdkError('PROTOCOL_ERROR', 'Structured submission bound to an earlier execution');
|
|
306
|
+
this.drop(output.submissionId);
|
|
307
|
+
return 'already-ended';
|
|
308
|
+
}
|
|
309
|
+
output.turnId = turnId;
|
|
310
|
+
const previous = this.byTurn.get(turnId);
|
|
311
|
+
if (previous && !isDeepStrictEqual(previous.schema.schema, output.schema.schema))
|
|
312
|
+
previous.error = failure('STRUCTURED_OUTPUT_BINDING_CONFLICT');
|
|
313
|
+
else
|
|
314
|
+
this.byTurn.set(turnId, previous ?? output);
|
|
315
|
+
return 'associated';
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The state getter, accept, routeEarlyEvent, publishOutput and execute project values
|
|
319
|
+
* here before exposing them to Session. Recursively replace output IDs and retained bridge secrets
|
|
320
|
+
* in string values, preserving object keys and leaving retained source data untouched. Session
|
|
321
|
+
* still owns projection of marked user secrets and any host history policy.
|
|
322
|
+
*/
|
|
323
|
+
redact(value) {
|
|
324
|
+
/** Rebuild JSON-shaped values so bridge redaction leaves retained source data untouched. */
|
|
325
|
+
const visit = (node) => {
|
|
326
|
+
if (typeof node === 'string') {
|
|
327
|
+
let text = node.replace(/anureo_output_[a-f0-9]{48}/g, '[redacted]');
|
|
328
|
+
for (const secret of this.privateStrings)
|
|
329
|
+
text = text.replaceAll(secret, '[redacted]');
|
|
330
|
+
return text;
|
|
331
|
+
}
|
|
332
|
+
if (Array.isArray(node))
|
|
333
|
+
return node.map(visit);
|
|
334
|
+
if (node && typeof node === 'object')
|
|
335
|
+
return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, visit(child)]));
|
|
336
|
+
return node;
|
|
337
|
+
};
|
|
338
|
+
return visit(value);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* rememberText, projectItemSnapshot, submitCandidate and admitOutput reserve or replace retained
|
|
342
|
+
* data here. Apply a signed byte delta to session/output accounting or throw RESULT_BUDGET_EXCEEDED
|
|
343
|
+
* before mutation. Callers commit the corresponding value only after this succeeds and drop/release
|
|
344
|
+
* later reclaim its accounting.
|
|
345
|
+
*/
|
|
346
|
+
retain(output, bytes) {
|
|
347
|
+
if (this.outputBytes + bytes > (this.limits.maxResultBytes ?? 16 * 1024 * 1024))
|
|
348
|
+
throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
|
|
349
|
+
this.outputBytes += bytes;
|
|
350
|
+
output.retainedBytes += bytes;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* projectItemSnapshot and projectItemDelta store text here for outputValue's terminal assembly.
|
|
354
|
+
* Replace one item's text with signed byte accounting. Distinct-item or byte overflow throws
|
|
355
|
+
* RESULT_BUDGET_EXCEEDED before replacing text, propagating through accept to observation failure.
|
|
356
|
+
*/
|
|
357
|
+
rememberText(output, id, text) {
|
|
358
|
+
if (!output.text.has(id) && output.text.size >= (this.limits.maxItems ?? 2048))
|
|
359
|
+
throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
|
|
360
|
+
this.retain(output, Buffer.byteLength(text) - Buffer.byteLength(output.text.get(id) ?? ''));
|
|
361
|
+
output.text.set(id, text);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* bind reclaims late terminal associations here, and completeTurn/reject reclaim finished or
|
|
365
|
+
* rejected submissions. Remove the submission and subtract its retained bytes. These callers still
|
|
366
|
+
* own turn associations, unresolved IDs and exclusive-lane release.
|
|
367
|
+
*/
|
|
368
|
+
drop(submissionId) {
|
|
369
|
+
const output = this.outputs.get(submissionId);
|
|
370
|
+
if (output)
|
|
371
|
+
this.outputBytes -= output.retainedBytes;
|
|
372
|
+
this.outputs.delete(submissionId);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* emit queues this work and bind replays held envelopes through it. Validate and route an envelope,
|
|
376
|
+
* then project its items and commit a terminal before publishing it to Session. A held or suppressed
|
|
377
|
+
* event resolves without publication. Protocol, budget, cleanup and sink failures reject the caller,
|
|
378
|
+
* while candidate-validation failures become failed terminals through publishOutput.
|
|
379
|
+
*/
|
|
380
|
+
async accept({ event, scope }) {
|
|
381
|
+
if (this.disposed)
|
|
382
|
+
throw new HarnessSdkError('STREAM_CLOSED');
|
|
383
|
+
this.validateEnvelope(event, scope);
|
|
384
|
+
if (await this.routeEarlyEvent(event, scope))
|
|
385
|
+
return;
|
|
386
|
+
const turnId = this.eventTurnId(event, scope);
|
|
387
|
+
if (this.isLateTerminal(event, turnId)) {
|
|
388
|
+
await this.sink.emit(this.redact(event), scope);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (event.type === 'turn/started')
|
|
392
|
+
this.active.add(event.payload.turnId);
|
|
393
|
+
const output = turnId ? this.byTurn.get(turnId) : undefined;
|
|
394
|
+
if (this.holdForBinding(event, scope, turnId, output))
|
|
395
|
+
return;
|
|
396
|
+
const projected = output ? this.projectItemEvent(event, output) : event;
|
|
397
|
+
if (!projected)
|
|
398
|
+
return;
|
|
399
|
+
event = projected;
|
|
400
|
+
if (event.type === 'turn/completed')
|
|
401
|
+
event = await this.completeTurn(event, output, turnId);
|
|
402
|
+
await this.sink.emit(this.redact(event), scope);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* accept calls this before routing or retaining an envelope. Assert its protocol shape using the
|
|
406
|
+
* current binding and validation-only stream metadata. Assertion failures reject acceptance before
|
|
407
|
+
* association state changes, including for envelopes that would otherwise be held.
|
|
408
|
+
*/
|
|
409
|
+
validateEnvelope(event, scope) {
|
|
410
|
+
assertEvent({ ...scope, sessionId: this.binding.sessionId, streamId: 'output-validation', seq: 1, timestamp: new Date().toISOString(), ...event });
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* accept awaits this before execution projection. Publish binding before replaying held events,
|
|
414
|
+
* reclaim definite rejections, and publish projected session state. Return true only when publication
|
|
415
|
+
* is complete here. Binding, cleanup or sink failures reject accept. Rejected-status events continue
|
|
416
|
+
* through accept for their normal publication after reclamation.
|
|
417
|
+
*/
|
|
418
|
+
async routeEarlyEvent(event, scope) {
|
|
419
|
+
if (event.type === 'submission/bound') {
|
|
420
|
+
await this.sink.emit(event, scope);
|
|
421
|
+
await this.bind(event.payload.submissionId, event.payload.turnId);
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
if (event.type === 'submission/status' && event.payload.status === 'rejected')
|
|
425
|
+
await this.reject(event.payload.submissionId);
|
|
426
|
+
if (event.type === 'session/state') {
|
|
427
|
+
await this.sink.emit(this.redact({ ...event, payload: this.projectState(event.payload) }), scope);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* accept uses this pure lookup for output association, buffering and terminal projection. Prefer
|
|
434
|
+
* scope.turnId, then lifecycle payload identity, returning undefined for unscoped non-lifecycle events.
|
|
435
|
+
*/
|
|
436
|
+
eventTurnId(event, scope) {
|
|
437
|
+
return scope.turnId ?? (event.type === 'turn/started' || event.type === 'turn/completed' ? event.payload.turnId : undefined);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* accept uses this predicate to forward lifecycle events for already-terminal turns directly to
|
|
441
|
+
* Session. Return whether the event bypasses output bookkeeping so duplicate starts/completions
|
|
442
|
+
* cannot reopen completed output state. Session still validates its own lifecycle observations.
|
|
443
|
+
*/
|
|
444
|
+
isLateTerminal(event, turnId) {
|
|
445
|
+
return Boolean(turnId && this.terminalVersions.has(turnId) && (event.type === 'turn/started' || event.type === 'turn/completed'));
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* accept returns early when this retains an item/terminal envelope for a possible late binding.
|
|
449
|
+
* Reserve held counters and preserve per-turn order while any output is unbound. bind later replays
|
|
450
|
+
* these envelopes and releases counters. emit already checked combined queued/held capacity.
|
|
451
|
+
*/
|
|
452
|
+
holdForBinding(event, scope, turnId, output) {
|
|
453
|
+
const needsBinding = !output && [...this.outputs.values()].some(value => !value.turnId);
|
|
454
|
+
if (turnId && needsBinding && (event.type.startsWith('item/') || event.type === 'turn/completed')) {
|
|
455
|
+
const held = this.pending.get(turnId) ?? [];
|
|
456
|
+
held.push({ event, scope });
|
|
457
|
+
this.pending.set(turnId, held);
|
|
458
|
+
this.heldCount++;
|
|
459
|
+
this.heldBytes += Buffer.byteLength(JSON.stringify(event));
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* accept routes output-associated events through this projection before publication. Retain
|
|
466
|
+
* snapshots/deltas under the output budget and return other events unchanged. undefined means
|
|
467
|
+
* a native final was withheld until terminal validation. Retention failures reject accept.
|
|
468
|
+
*/
|
|
469
|
+
projectItemEvent(event, output) {
|
|
470
|
+
if (event.type === 'item/started' || event.type === 'item/completed')
|
|
471
|
+
return this.projectItemSnapshot(event, output);
|
|
472
|
+
if (event.type === 'item/delta')
|
|
473
|
+
return this.projectItemDelta(event, output);
|
|
474
|
+
return event;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* projectItemEvent retains message snapshots and downgrades visible messages to commentary.
|
|
478
|
+
* Withhold completed native finals until terminal validation so accepted output can preserve the
|
|
479
|
+
* identity used by authoritative history. Budget failures throw before publication to accept.
|
|
480
|
+
*/
|
|
481
|
+
projectItemSnapshot(event, output) {
|
|
482
|
+
const item = event.payload.item;
|
|
483
|
+
if (item.type !== 'agentMessage')
|
|
484
|
+
return event;
|
|
485
|
+
this.rememberText(output, item.id, item.text);
|
|
486
|
+
if (item.phase === 'final')
|
|
487
|
+
output.finalIds.add(item.id);
|
|
488
|
+
if (output.mode === 'native' && event.type === 'item/completed' && item.phase === 'final') {
|
|
489
|
+
const previous = output.finalItems.get(item.id);
|
|
490
|
+
this.retain(output, Buffer.byteLength(JSON.stringify(item)) - (previous ? Buffer.byteLength(JSON.stringify(previous)) : 0));
|
|
491
|
+
output.finalItems.set(item.id, item);
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
return { ...event, payload: { item: { ...item, phase: 'commentary' } } };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* projectItemEvent assembles streamed text and agent-message patches here for terminal parsing.
|
|
498
|
+
* Preserve final identities but expose patches as commentary until commitment. Budget failures
|
|
499
|
+
* throw through accept; unrelated deltas pass through unchanged.
|
|
500
|
+
*/
|
|
501
|
+
projectItemDelta(event, output) {
|
|
502
|
+
if (event.payload.kind === 'text' && event.payload.text !== undefined) {
|
|
503
|
+
const text = (output.text.get(event.payload.itemId) ?? '') + event.payload.text;
|
|
504
|
+
if (Buffer.byteLength(text) > (this.limits.maxResultBytes ?? 16 * 1024 * 1024))
|
|
505
|
+
throw new HarnessSdkError('RESULT_BUDGET_EXCEEDED');
|
|
506
|
+
this.rememberText(output, event.payload.itemId, text);
|
|
507
|
+
}
|
|
508
|
+
const value = event.payload.value;
|
|
509
|
+
if (event.payload.kind === 'patch' && value && typeof value === 'object' && !Array.isArray(value) && value.type === 'agentMessage') {
|
|
510
|
+
this.rememberText(output, event.payload.itemId, String(value.text ?? ''));
|
|
511
|
+
if (value.phase === 'final')
|
|
512
|
+
output.finalIds.add(event.payload.itemId);
|
|
513
|
+
return { ...event, payload: { ...event.payload, value: { ...value, phase: 'commentary' } } };
|
|
514
|
+
}
|
|
515
|
+
return event;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* accept awaits this for a real turn/completed event and then publishes the returned terminal.
|
|
519
|
+
* Close candidate admission, disable the direct tool, publish accepted output or withheld commentary,
|
|
520
|
+
* then reclaim associations and release the fallback lane. Validation can replace success with a
|
|
521
|
+
* failed terminal. Tool/sink failures reject before remaining cleanup, leaving disposal to reclaim
|
|
522
|
+
* resources. Host result timeouts never enter this terminal path.
|
|
523
|
+
*/
|
|
524
|
+
async completeTurn(event, output, turnId) {
|
|
525
|
+
if (output) {
|
|
526
|
+
output.finished = true;
|
|
527
|
+
if (output.mode === 'tool')
|
|
528
|
+
await this.backend.structuredOutputTool.configure();
|
|
529
|
+
}
|
|
530
|
+
if (output && event.payload.status === 'completed')
|
|
531
|
+
event = await this.publishOutput(event, output, turnId);
|
|
532
|
+
if (output && event.payload.status !== 'completed')
|
|
533
|
+
await this.publishCommentaryFinals(output, turnId);
|
|
534
|
+
this.recordTerminal({ terminalTurnId: event.payload.turnId, associatedTurnId: turnId, output });
|
|
535
|
+
return event;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* completeTurn records the native terminal after tool cleanup and item publication succeed.
|
|
539
|
+
* Bound the terminal markers used by late binding and reclaim the associated submissions and lane.
|
|
540
|
+
* Use the payload ID for active, terminal and turn-index records. Match retained candidates and
|
|
541
|
+
* the lane using the envelope's resolved association ID.
|
|
542
|
+
*/
|
|
543
|
+
recordTerminal({ terminalTurnId, associatedTurnId, output }) {
|
|
544
|
+
this.active.delete(terminalTurnId);
|
|
545
|
+
this.terminalVersions.set(terminalTurnId, ++this.terminalVersion);
|
|
546
|
+
if (this.terminalVersions.size > (this.limits.maxSubmissions ?? 1024)) {
|
|
547
|
+
const oldest = this.terminalVersions.keys().next();
|
|
548
|
+
if (!oldest.done)
|
|
549
|
+
this.terminalVersions.delete(oldest.value);
|
|
550
|
+
}
|
|
551
|
+
if (!output)
|
|
552
|
+
return;
|
|
553
|
+
for (const [id, value] of this.outputs)
|
|
554
|
+
if (value.turnId === associatedTurnId)
|
|
555
|
+
this.drop(id);
|
|
556
|
+
this.byTurn.delete(terminalTurnId);
|
|
557
|
+
if (this.locked?.turnId === associatedTurnId)
|
|
558
|
+
this.locked = undefined;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* completeTurn awaits this only for native success with output state. Convert validation failures
|
|
562
|
+
* into a failed terminal, or publish commentary followed by one accepted final before returning
|
|
563
|
+
* success. Reuse the sole completed native final's identity when available. Sink publication failures
|
|
564
|
+
* reject completeTurn directly, preserving their infrastructure-failure semantics.
|
|
565
|
+
*/
|
|
566
|
+
async publishOutput(event, output, turnId) {
|
|
567
|
+
let text;
|
|
568
|
+
try {
|
|
569
|
+
if (output.error)
|
|
570
|
+
throw new HarnessSdkError(output.error.code);
|
|
571
|
+
const value = this.outputValue(output);
|
|
572
|
+
output.schema.check(value);
|
|
573
|
+
const json = JSON.stringify(value);
|
|
574
|
+
text = this.sink.projectOutput?.(this.redact(json)) ?? this.redact(json);
|
|
575
|
+
try {
|
|
576
|
+
output.schema.parse(text);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
throw new HarnessSdkError('STRUCTURED_OUTPUT_REDACTED');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (cause) {
|
|
583
|
+
const error = sdkError(cause, 'STRUCTURED_OUTPUT_INVALID');
|
|
584
|
+
return { ...event, payload: { ...event.payload, status: 'failed', error: failure(error.code, error.details) } };
|
|
585
|
+
}
|
|
586
|
+
if (text !== undefined) {
|
|
587
|
+
const original = output.mode === 'native' && output.finalItems.size === 1 ? [...output.finalItems.values()][0] : undefined;
|
|
588
|
+
// Skip an await when there is no commentary to publish before the final.
|
|
589
|
+
for (const item of output.finalItems.values()) {
|
|
590
|
+
if (item.id !== original?.id)
|
|
591
|
+
await this.emitCommentaryFinal(item, turnId);
|
|
592
|
+
}
|
|
593
|
+
const item = { ...(original ?? { id: `anureo-result:${randomBytes(24).toString('hex')}`, type: 'agentMessage' }), phase: 'final', text, status: 'completed' };
|
|
594
|
+
await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId });
|
|
595
|
+
}
|
|
596
|
+
return event;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* publishOutput obtains its candidate here before final schema/privacy checks. Parse the
|
|
600
|
+
* joined native final texts, or return the first retained tool/MCP value. Missing values throw
|
|
601
|
+
* STRUCTURED_OUTPUT_MISSING or STRUCTURED_OUTPUT_INVALID according to prior schema attempts.
|
|
602
|
+
*/
|
|
603
|
+
outputValue(output) {
|
|
604
|
+
if (output.mode === 'native')
|
|
605
|
+
return output.schema.parse([...output.finalIds].map(id => output.text.get(id) ?? '').join('\n'));
|
|
606
|
+
if (output.hasValue)
|
|
607
|
+
return output.value;
|
|
608
|
+
throw new HarnessSdkError(output.attempts ? 'STRUCTURED_OUTPUT_INVALID' : 'STRUCTURED_OUTPUT_MISSING');
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* completeTurn awaits this for non-success terminals, including failures from final validation.
|
|
612
|
+
* Emit retained native items as commentary in insertion order. Publication failures reject before
|
|
613
|
+
* completeTurn records the terminal and reclaims its associations.
|
|
614
|
+
*/
|
|
615
|
+
async publishCommentaryFinals(output, turnId) {
|
|
616
|
+
for (const item of output.finalItems.values())
|
|
617
|
+
await this.emitCommentaryFinal(item, turnId);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* publishOutput and publishCommentaryFinals await Session.emit directly. Preserve the native
|
|
621
|
+
* item identity while emitting commentary, and let publication failure halt the terminal path.
|
|
622
|
+
*/
|
|
623
|
+
emitCommentaryFinal(item, turnId) {
|
|
624
|
+
return this.sink.emit({ type: 'item/completed', payload: { item: { ...item, phase: 'commentary' } } }, { turnId });
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* prepareBridge registers a queued handler with OutputBridgeManager.create. The manager's MCP
|
|
628
|
+
* CallTool handler awaits this result. Check the active MCP output, envelope and exact outputId
|
|
629
|
+
* before submitCandidate, returning an MCP acceptance or safe retry guidance. Envelope/ID failures
|
|
630
|
+
* consume no schema attempts. Infrastructure failures reject the registered MCP handler.
|
|
631
|
+
*/
|
|
632
|
+
submitValue(args) {
|
|
633
|
+
const output = this.locked;
|
|
634
|
+
if (this.disposed || !output || output.mode !== 'mcp' || output.finished)
|
|
635
|
+
return toolError('STRUCTURED_OUTPUT_EXPIRED');
|
|
636
|
+
const invalidArguments = () => toolError('STRUCTURED_OUTPUT_INVALID_ARGUMENTS', undefined, 'Pass exactly two top-level arguments: outputId (a nonempty string copied exactly from the current task output contract) and value (the final JSON result). Do not nest the argument envelope inside value. Correct the arguments and call this tool again within this execution.');
|
|
637
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
638
|
+
return invalidArguments();
|
|
639
|
+
const value = args;
|
|
640
|
+
if (typeof value.outputId !== 'string' || !value.outputId || !Object.hasOwn(value, 'value') || Object.keys(value).some(key => key !== 'outputId' && key !== 'value'))
|
|
641
|
+
return invalidArguments();
|
|
642
|
+
if (output.outputId !== value.outputId)
|
|
643
|
+
return toolError('STRUCTURED_OUTPUT_ID_MISMATCH', undefined, 'The supplied outputId does not match the current task. This does not mean the task has expired. Copy outputId exactly from the current task output contract, preserving every character, and call this tool again within this execution. Do not reuse an ID from another task.');
|
|
644
|
+
const result = this.submitCandidate(output, value.value);
|
|
645
|
+
return result.accepted ? { content: [{ type: 'text', text: '{"accepted":true}' }] } : toolError(result.error.code, result.error.details?.issues);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* submitValue formats this decision for MCP. prepareOutput supplies the native tool port with a
|
|
649
|
+
* queued callback capturing the exact Output. Pi's createOutputTool awaits it and translates
|
|
650
|
+
* acceptance into a termination request or returns an error for correction. A structurally equal
|
|
651
|
+
* retry is accepted without storing a second value. The third schema mismatch before acceptance
|
|
652
|
+
* exhausts retries. Return policy failures as decisions, while clone failure rejects the callback.
|
|
653
|
+
* Acceptance remains provisional until publishOutput revalidates the projected result.
|
|
654
|
+
*/
|
|
655
|
+
submitCandidate(output, value) {
|
|
656
|
+
const rejected = (code, details) => ({ accepted: false, error: failure(code, details) });
|
|
657
|
+
if (this.disposed || output.finished || this.locked !== output)
|
|
658
|
+
return rejected('STRUCTURED_OUTPUT_EXPIRED');
|
|
659
|
+
if (output.error)
|
|
660
|
+
return { accepted: false, error: output.error };
|
|
661
|
+
if (output.hasValue)
|
|
662
|
+
return isDeepStrictEqual(output.value, value) ? { accepted: true } : rejected('STRUCTURED_OUTPUT_ALREADY_SUBMITTED');
|
|
663
|
+
let bytes;
|
|
664
|
+
try {
|
|
665
|
+
bytes = Buffer.byteLength(JSON.stringify(value));
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return rejected('STRUCTURED_OUTPUT_INVALID');
|
|
669
|
+
}
|
|
670
|
+
if (bytes > (this.limits.maxResultBytes ?? 16 * 1024 * 1024))
|
|
671
|
+
return this.rejectCandidateBudget(output);
|
|
672
|
+
try {
|
|
673
|
+
output.schema.check(value);
|
|
674
|
+
}
|
|
675
|
+
catch (cause) {
|
|
676
|
+
if (++output.attempts >= 3)
|
|
677
|
+
output.error = failure('STRUCTURED_OUTPUT_RETRIES_EXCEEDED');
|
|
678
|
+
return rejected(output.error?.code ?? 'STRUCTURED_OUTPUT_INVALID', sdkError(cause).details);
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
this.retain(output, bytes);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return this.rejectCandidateBudget(output);
|
|
685
|
+
}
|
|
686
|
+
output.value = structuredClone(value);
|
|
687
|
+
output.hasValue = true;
|
|
688
|
+
return { accepted: true };
|
|
689
|
+
}
|
|
690
|
+
/** submitCandidate reports either byte-budget failure immediately and retains it for later calls and terminal validation. */
|
|
691
|
+
rejectCandidateBudget(output) {
|
|
692
|
+
output.error = failure('RESULT_BUDGET_EXCEEDED');
|
|
693
|
+
return { accepted: false, error: output.error };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* routeEarlyEvent calls this for rejected status, and rejectDispatch calls after a definite request
|
|
697
|
+
* failure. Remove unresolved admission, then finish/reclaim an unbound output and release its lane.
|
|
698
|
+
* Bound execution remains owned until its terminal. Tool-disable failures reject before reclamation,
|
|
699
|
+
* allowing the caller to report cleanup failure and later disposal to attempt release.
|
|
700
|
+
*/
|
|
701
|
+
async reject(submissionId) {
|
|
702
|
+
this.unresolved.delete(submissionId);
|
|
703
|
+
const output = this.outputs.get(submissionId);
|
|
704
|
+
if (output?.turnId)
|
|
705
|
+
return; // A rejection cannot undo an execution already observed.
|
|
706
|
+
if (output)
|
|
707
|
+
output.finished = true;
|
|
708
|
+
if (output?.mode === 'tool')
|
|
709
|
+
await this.backend.structuredOutputTool.configure();
|
|
710
|
+
this.drop(submissionId);
|
|
711
|
+
if (this.locked === output)
|
|
712
|
+
this.locked = undefined;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* execute awaits this after checking backend attachment and before native frame/capability checks.
|
|
716
|
+
* Transform schema-bearing input and record the captured input/review ID as unresolved only
|
|
717
|
+
* after preparation succeeds. Return params for native validation and dispatch. Setup
|
|
718
|
+
* failures reject execute, which still knows the original submission ID for definite-failure cleanup.
|
|
719
|
+
*/
|
|
720
|
+
async prepareSubmission(method, params, submissionId) {
|
|
721
|
+
if (method === 'input/submit') {
|
|
722
|
+
const input = params;
|
|
723
|
+
if (input.outputSchema !== undefined)
|
|
724
|
+
params = await this.prepareOutput(input, input.outputSchema);
|
|
725
|
+
}
|
|
726
|
+
if (submissionId !== undefined)
|
|
727
|
+
this.unresolved.add(submissionId);
|
|
728
|
+
return params;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* prepareSubmission awaits this for schema-bearing input using the attached backend. Compile the
|
|
732
|
+
* schema, enforce mode-specific admission and reserve retention, then return native params or
|
|
733
|
+
* configure the locked fallback before returning transformed input. The native tool callback is registered here
|
|
734
|
+
* and captures this exact Output. Capacity, schema, exclusivity and registration failures reject to
|
|
735
|
+
* execute's recovery path, which reclaims definite failures. Dispatch and result observation remain
|
|
736
|
+
* with execute and Session.
|
|
737
|
+
*/
|
|
738
|
+
async prepareOutput(input, schemaSource) {
|
|
739
|
+
if (this.outputs.size >= (this.limits.maxSubmissions ?? 1024))
|
|
740
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
741
|
+
const schema = compileOutputSchema(schemaSource);
|
|
742
|
+
const output = this.admitOutput(input.submissionId, schema);
|
|
743
|
+
if (output.mode === 'native')
|
|
744
|
+
return input;
|
|
745
|
+
this.locked = output;
|
|
746
|
+
const prepared = fallbackInput(input, output);
|
|
747
|
+
if (output.mode === 'tool')
|
|
748
|
+
await this.backend.structuredOutputTool.configure({
|
|
749
|
+
name: 'anureo_submit_result',
|
|
750
|
+
description: 'Submit the final result for the current task. Correct validation errors and retry. Acceptance ends this execution.',
|
|
751
|
+
schema: structuredClone(schema.schema),
|
|
752
|
+
execute: value => this.enqueue(async () => this.submitCandidate(output, value)),
|
|
753
|
+
});
|
|
754
|
+
return prepared;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* prepareOutput calls this after capacity checking and schema compilation, before fallback setup.
|
|
758
|
+
* Prefer native schemas, then a direct tool, then MCP; reject unavailable or conflicting lanes
|
|
759
|
+
* before reserving schema bytes and storing the submission. execute has established backend
|
|
760
|
+
* attachment. Its definite-failure path owns reclaiming state if later setup or dispatch rejects.
|
|
761
|
+
*/
|
|
762
|
+
admitOutput(submissionId, schema) {
|
|
763
|
+
const backend = this.backend;
|
|
764
|
+
const mode = backend.capabilities.turn?.outputSchema ? 'native' : backend.structuredOutputTool ? 'tool' : 'mcp';
|
|
765
|
+
if (mode === 'mcp' && !this.lease)
|
|
766
|
+
throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
|
|
767
|
+
if (mode !== 'native' && (this.active.size || this.unresolved.size))
|
|
768
|
+
throw new HarnessSdkError('STRUCTURED_OUTPUT_BUSY');
|
|
769
|
+
if (mode === 'native' && [...this.outputs.values()].some(output => !isDeepStrictEqual(output.schema.schema, schema.schema)))
|
|
770
|
+
throw new HarnessSdkError('SESSION_BUSY');
|
|
771
|
+
const output = {
|
|
772
|
+
submissionId,
|
|
773
|
+
outputId: `anureo_output_${randomBytes(24).toString('hex')}`,
|
|
774
|
+
mode,
|
|
775
|
+
schema,
|
|
776
|
+
attempts: 0,
|
|
777
|
+
hasValue: false,
|
|
778
|
+
text: new Map(),
|
|
779
|
+
finalIds: new Set(),
|
|
780
|
+
finalItems: new Map(),
|
|
781
|
+
retainedBytes: 0,
|
|
782
|
+
admittedVersion: this.terminalVersion,
|
|
783
|
+
};
|
|
784
|
+
this.retain(output, Buffer.byteLength(JSON.stringify(schema.schema)));
|
|
785
|
+
this.outputs.set(submissionId, output);
|
|
786
|
+
return output;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* execute awaits this after a native close receipt and dispose awaits it before backend
|
|
790
|
+
* disposal. Clear retention, associations and held-event accounting, detach the lease, then attempt
|
|
791
|
+
* lease disposal and native tool disabling together. Clear the lane before invoking either so
|
|
792
|
+
* queued candidates expire at receipt time. Classify promise rejections after both settle.
|
|
793
|
+
* Session still owns observation closure, and OutputSession.dispose owns backend release.
|
|
794
|
+
*/
|
|
795
|
+
async release() {
|
|
796
|
+
this.outputs.clear();
|
|
797
|
+
this.byTurn.clear();
|
|
798
|
+
this.pending.clear();
|
|
799
|
+
this.unresolved.clear();
|
|
800
|
+
this.active.clear();
|
|
801
|
+
this.locked = undefined;
|
|
802
|
+
this.terminalVersions.clear();
|
|
803
|
+
this.outputBytes = 0;
|
|
804
|
+
this.heldBytes = 0;
|
|
805
|
+
this.heldCount = 0;
|
|
806
|
+
const lease = this.lease;
|
|
807
|
+
this.lease = undefined;
|
|
808
|
+
checkRelease(await Promise.allSettled([lease?.dispose(), this.backend?.structuredOutputTool?.configure()]));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* projectCapabilities adds this extension for Session attachment and projected state when an MCP
|
|
813
|
+
* lease supplies fallback support. The native schema capability takes precedence over this marker.
|
|
814
|
+
*/
|
|
815
|
+
const EXTENSION = { name: 'x-anureo/structured-output-mcp', version: '1' };
|
|
816
|
+
/**
|
|
817
|
+
* execute captures input/review identity once for unresolved admission, receipt binding and
|
|
818
|
+
* definite-failure cleanup. Other methods have no submission tracked by the output wrapper.
|
|
819
|
+
*/
|
|
820
|
+
function requestSubmissionId(method, params) {
|
|
821
|
+
if (method === 'input/submit' || method === 'review/start')
|
|
822
|
+
return params.submissionId;
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* prepareOutput strips the unsupported native schema field and appends the selected fallback
|
|
827
|
+
* contract before dispatch. Preserve the original blocks and opaque output identity; the caller
|
|
828
|
+
* owns tool registration, locking and cleanup. Only fallback modes are passed here.
|
|
829
|
+
*/
|
|
830
|
+
function fallbackInput(input, output) {
|
|
831
|
+
const { outputSchema: _, ...native } = input;
|
|
832
|
+
const contract = output.mode === 'tool' ? [
|
|
833
|
+
'After completing the task, you MUST call anureo_submit_result with your final result matching its parameter schema.',
|
|
834
|
+
'Correct any validation errors and call it again within this execution. A text answer does not complete the task.',
|
|
835
|
+
].join('\n') : [
|
|
836
|
+
'Required Anureo final-output protocol:',
|
|
837
|
+
`After completing the task, you MUST invoke the ${OUTPUT_SERVER_NAME} MCP submit_result tool. Its exposed name may have an editor/server prefix and ends with submit_result.`,
|
|
838
|
+
'Pass the outputId below and your final JSON value as the value argument. A plain text or JSON answer does not complete this task.',
|
|
839
|
+
'Copy outputId exactly, preserving every character. Pass outputId and value as separate top-level arguments; do not put outputId inside value.',
|
|
840
|
+
'If the tool reports validation errors, correct the value and call it again within this execution. Once it acknowledges accepted:true, end the task.',
|
|
841
|
+
'Do not substitute files or shell commands for submitting through this tool.',
|
|
842
|
+
`outputId: ${output.outputId}`,
|
|
843
|
+
`JSON Schema: ${JSON.stringify(output.schema.schema)}`,
|
|
844
|
+
].join('\n');
|
|
845
|
+
return { ...native, input: [...input.input, { type: 'text', text: contract }] };
|
|
846
|
+
}
|