@modelprofile.com/flexharness 11.0.1 → 11.1.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 (69) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.d.ts +12 -4
  3. package/dist_ts/classes.codesandbox.js +119 -287
  4. package/dist_ts/classes.codesandbox.worker.d.ts +52 -0
  5. package/dist_ts/classes.codesandbox.worker.js +310 -0
  6. package/dist_ts/classes.flexharness.callbacks.d.ts +37 -1
  7. package/dist_ts/classes.flexharness.callbacks.js +162 -53
  8. package/dist_ts/classes.flexharness.core.d.ts +2 -1
  9. package/dist_ts/classes.flexharness.core.js +22 -18
  10. package/dist_ts/classes.flexharness.results.js +4 -4
  11. package/dist_ts/classes.flexharness.reversions.d.ts +7 -0
  12. package/dist_ts/classes.flexharness.reversions.js +40 -39
  13. package/dist_ts/classes.flexharness.runs.js +7 -3
  14. package/dist_ts/classes.flexharness.tooldispatch.d.ts +2 -1
  15. package/dist_ts/classes.flexharness.tooldispatch.js +27 -9
  16. package/dist_ts/classes.stores.js +15 -13
  17. package/dist_ts/errors.d.ts +5 -0
  18. package/dist_ts/errors.js +10 -1
  19. package/dist_ts/index.d.ts +2 -1
  20. package/dist_ts/index.js +3 -2
  21. package/dist_ts/interfaces.d.ts +31 -9
  22. package/dist_ts/interfaces.flexharness.d.ts +14 -0
  23. package/dist_ts/interfaces.js +3 -3
  24. package/dist_ts/plugins.d.ts +6 -4
  25. package/dist_ts/plugins.js +10 -11
  26. package/dist_ts/utils.harnessoptions.d.ts +3 -1
  27. package/dist_ts/utils.harnessoptions.js +15 -2
  28. package/dist_ts/utils.json.d.ts +9 -1
  29. package/dist_ts/utils.json.js +92 -33
  30. package/dist_ts/utils.storecodecs.d.ts +1 -1
  31. package/dist_ts/utils.storecodecs.js +4 -4
  32. package/dist_ts/utils.storerules.d.ts +3 -3
  33. package/dist_ts/utils.storerules.js +1 -14
  34. package/dist_ts_mcp/index.d.ts +3 -1
  35. package/dist_ts_mcp/index.js +17 -2
  36. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +18 -6
  37. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -16
  38. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +33 -3
  39. package/dist_ts_stores_nosqldb/classes.recordstore.js +78 -18
  40. package/dist_ts_stores_nosqldb/index.d.ts +2 -2
  41. package/dist_ts_stores_nosqldb/index.js +2 -2
  42. package/package.json +3 -3
  43. package/readme.md +8 -2
  44. package/ts/00_commitinfo_data.ts +1 -1
  45. package/ts/classes.codesandbox.ts +132 -276
  46. package/ts/classes.codesandbox.worker.ts +345 -0
  47. package/ts/classes.flexharness.callbacks.ts +207 -55
  48. package/ts/classes.flexharness.core.ts +22 -16
  49. package/ts/classes.flexharness.results.ts +3 -3
  50. package/ts/classes.flexharness.reversions.ts +43 -32
  51. package/ts/classes.flexharness.runs.ts +6 -2
  52. package/ts/classes.flexharness.tooldispatch.ts +23 -7
  53. package/ts/classes.stores.ts +21 -12
  54. package/ts/errors.ts +14 -0
  55. package/ts/index.ts +7 -1
  56. package/ts/interfaces.flexharness.ts +15 -0
  57. package/ts/interfaces.ts +32 -9
  58. package/ts/plugins.ts +9 -12
  59. package/ts/readme.md +8 -2
  60. package/ts/utils.harnessoptions.ts +22 -0
  61. package/ts/utils.json.ts +112 -35
  62. package/ts/utils.storecodecs.ts +3 -3
  63. package/ts/utils.storerules.ts +3 -24
  64. package/ts_mcp/index.ts +17 -1
  65. package/ts_mcp/readme.md +3 -1
  66. package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -15
  67. package/ts_stores_nosqldb/classes.recordstore.ts +105 -13
  68. package/ts_stores_nosqldb/index.ts +2 -0
  69. package/ts_stores_nosqldb/readme.md +10 -2
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The QuickJS side of a `run_code` program. `FlexCodeSandbox` runs every program in a worker thread of
3
+ * its own with this module, and ends it by terminating the worker, which frees its WebAssembly instance
4
+ * too: a native operation that never reaches QuickJS's interrupt handler cannot outlast its limits, and
5
+ * a program never blocks the host's event loop.
6
+ *
7
+ * Written in erasable TypeScript that imports nothing of the package at run time, so the module loads
8
+ * uncompiled as well.
9
+ */
10
+ import { parentPort } from 'node:worker_threads';
11
+ import type * as plugins from './plugins.js';
12
+
13
+ /** Evaluating the prelude, the program's text, or running the program. */
14
+ export type TCodeWorkerPhase = 'prelude' | 'parse' | 'program';
15
+
16
+ /** The limits of a program that its worker enforces itself. */
17
+ export interface ICodeWorkerLimits {
18
+ cpuMs: number;
19
+ memoryBytes: number;
20
+ stackBytes: number;
21
+ maxLogBytes: number;
22
+ }
23
+
24
+ /** What the host sends a program's worker. */
25
+ export type TCodeWorkerRequest =
26
+ | { kind: 'start'; program: string; limits: ICodeWorkerLimits }
27
+ | { kind: 'answer'; id: number; text: string };
28
+
29
+ /**
30
+ * What a program's worker reports. `ready` comes once QuickJS is loaded; every `start` and `answer`
31
+ * ends in `idle`, with the milliseconds computed so far, unless the program stopped. `done` counts the
32
+ * calls the program had no answer to when it returned.
33
+ */
34
+ export type TCodeWorkerReport =
35
+ | { kind: 'ready' }
36
+ | { kind: 'call'; id: number; method: string; params: string }
37
+ | { kind: 'log'; text: string }
38
+ | { kind: 'idle'; cpuMs: number }
39
+ | { kind: 'done'; text: string; unawaited: number }
40
+ | { kind: 'failed'; phase: TCodeWorkerPhase; name?: string; message?: string; stack?: string; dumped?: string }
41
+ | { kind: 'stopped'; limit: 'cpu' | 'stack' };
42
+
43
+ /** The file name the program's text is evaluated as: the host reads a failure's line in it from the stack. */
44
+ const programFile = 'program.ts';
45
+
46
+ /**
47
+ * Runs in the context before the program: it takes the host functions off the global object and
48
+ * defines `api`, `ApiError` and `console`. Only strings cross the boundary.
49
+ */
50
+ const prelude = `'use strict';
51
+ (() => {
52
+ const call = globalThis.__flexCall;
53
+ const log = globalThis.__flexLog;
54
+ const done = globalThis.__flexDone;
55
+ delete globalThis.__flexCall;
56
+ delete globalThis.__flexLog;
57
+ delete globalThis.__flexDone;
58
+ class ApiError extends Error {
59
+ constructor(details) {
60
+ const message = details && typeof details.message === 'string' ? details.message : JSON.stringify(details);
61
+ super(message);
62
+ this.name = 'ApiError';
63
+ this.details = details;
64
+ }
65
+ }
66
+ const invoke = (path, args) => {
67
+ if (!path) return Promise.reject(new TypeError('Call a method as api.<namespace>.<method>(params).'));
68
+ let params;
69
+ try {
70
+ // api.results.get(handle, { page, path }) reads a stored result.
71
+ const argument = path === 'results.get' && typeof args[0] === 'string'
72
+ ? Object.assign({}, args[1], { handle: args[0] })
73
+ : args[0];
74
+ params = JSON.stringify(args.length === 0 || argument === undefined ? {} : argument);
75
+ } catch (error) {
76
+ return Promise.reject(new TypeError('The params of ' + path + ' are not JSON: ' + error.message));
77
+ }
78
+ return call(path, params === undefined ? 'null' : params).then((text) => {
79
+ const outcome = JSON.parse(text);
80
+ if (outcome.ok) return outcome.value;
81
+ throw new ApiError(outcome.error);
82
+ });
83
+ };
84
+ const target = (path) => new Proxy(function () {}, {
85
+ get: (_target, key) => typeof key === 'string' && key !== 'then' ? target(path ? path + '.' + key : key) : undefined,
86
+ apply: (_target, _this, args) => invoke(path, args),
87
+ set: () => false,
88
+ defineProperty: () => false,
89
+ deleteProperty: () => false,
90
+ });
91
+ const format = (value) => {
92
+ if (typeof value === 'string') return value;
93
+ try {
94
+ const text = JSON.stringify(value);
95
+ return text === undefined ? String(value) : text;
96
+ } catch {
97
+ return String(value);
98
+ }
99
+ };
100
+ const writer = (level) => (...values) => { log(level, values.map(format).join(' ')); };
101
+ const fixed = (value) => ({ value, enumerable: false, writable: false, configurable: false });
102
+ Object.defineProperty(globalThis, 'api', fixed(target('')));
103
+ Object.defineProperty(globalThis, 'ApiError', fixed(ApiError));
104
+ Object.defineProperty(globalThis, 'console', fixed(Object.freeze({
105
+ log: writer('log'), info: writer('info'), warn: writer('warn'), error: writer('error'), debug: writer('debug'),
106
+ })));
107
+ const settle = (value) => {
108
+ try {
109
+ const text = JSON.stringify({ ok: true, value: value === undefined ? null : value });
110
+ return text === undefined ? JSON.stringify({ ok: true, value: null }) : text;
111
+ } catch (error) {
112
+ return JSON.stringify({ ok: false, name: 'TypeError', message: 'The return value is not JSON: ' + error.message });
113
+ }
114
+ };
115
+ const fail = (error) => JSON.stringify({
116
+ ok: false,
117
+ name: error && typeof error.name === 'string' ? error.name : 'Error',
118
+ message: error && typeof error.message === 'string' ? error.message : String(error),
119
+ stack: error && typeof error.stack === 'string' ? error.stack : '',
120
+ ...(error instanceof ApiError ? { details: error.details } : {}),
121
+ });
122
+ Object.defineProperty(globalThis, '__flexStart', {
123
+ configurable: true,
124
+ value: () => {
125
+ const main = globalThis.__flexMain;
126
+ delete globalThis.__flexStart;
127
+ delete globalThis.__flexMain;
128
+ main().then(settle, fail).then(done);
129
+ },
130
+ });
131
+ })();
132
+ `;
133
+
134
+ /**
135
+ * One program in its QuickJS runtime. It enforces the limits QuickJS can see: memory, stack, console
136
+ * output, and computing time at every interrupt point. The host enforces the rest, and ends the worker
137
+ * when the program computes past its time between two interrupt points.
138
+ */
139
+ class CodeWorkerRun {
140
+ private readonly report: (report: TCodeWorkerReport) => void;
141
+ private readonly limits: ICodeWorkerLimits;
142
+ private readonly runtime: plugins.IQuickJsRuntime;
143
+ private readonly vm: plugins.IQuickJsContext;
144
+ private readonly deferreds = new Map<number, plugins.IQuickJsDeferredPromise>();
145
+ private nextCallId = 0;
146
+ private cpuMs = 0;
147
+ private enteredAt?: number;
148
+ private stopped?: 'cpu' | 'stack';
149
+ private ended = false;
150
+ private logBytes = 0;
151
+ private logsCut = false;
152
+
153
+ constructor(module: plugins.IQuickJsModule, limits: ICodeWorkerLimits, report: (report: TCodeWorkerReport) => void) {
154
+ this.limits = limits;
155
+ this.report = report;
156
+ this.runtime = module.newRuntime();
157
+ this.runtime.setMemoryLimit(limits.memoryBytes);
158
+ this.runtime.setMaxStackSize(limits.stackBytes);
159
+ this.runtime.setInterruptHandler(() => this.interrupted());
160
+ this.vm = this.runtime.newContext();
161
+ this.define('__flexCall', (pathHandle, paramsHandle) => this.call(this.vm.getString(pathHandle), this.vm.getString(paramsHandle)));
162
+ this.define('__flexLog', (levelHandle, textHandle) => {
163
+ this.log(this.vm.getString(levelHandle), this.vm.getString(textHandle));
164
+ });
165
+ this.define('__flexDone', (textHandle) => {
166
+ this.ended = true;
167
+ this.report({ kind: 'done', text: this.vm.getString(textHandle), unawaited: this.deferreds.size });
168
+ });
169
+ }
170
+
171
+ public start(program: string): void {
172
+ if (this.evaluate(prelude, 'prelude.js', 'prelude')
173
+ && this.evaluate(program, programFile, 'parse')
174
+ && this.evaluate('__flexStart();', 'start.js', 'program')) {
175
+ this.pump();
176
+ }
177
+ this.idle();
178
+ }
179
+
180
+ /** Settles a call of the program with the host's outcome, and runs what it unblocked. */
181
+ public answer(id: number, text: string): void {
182
+ const deferred = this.deferreds.get(id);
183
+ if (!deferred || this.ended) return;
184
+ this.deferreds.delete(id);
185
+ const value = this.vm.newString(text);
186
+ deferred.resolve(value);
187
+ value.dispose();
188
+ this.pump();
189
+ this.idle();
190
+ }
191
+
192
+ private define(name: string, implementation: (...args: plugins.IQuickJsHandle[]) => plugins.IQuickJsHandle | void): void {
193
+ const handle = this.vm.newFunction(name, implementation);
194
+ this.vm.setProp(this.vm.global, name, handle);
195
+ handle.dispose();
196
+ }
197
+
198
+ private call(method: string, params: string): plugins.IQuickJsHandle {
199
+ const deferred = this.vm.newPromise();
200
+ const id = this.nextCallId++;
201
+ this.deferreds.set(id, deferred);
202
+ this.report({ kind: 'call', id, method, params });
203
+ return deferred.handle;
204
+ }
205
+
206
+ private log(level: string, text: string): void {
207
+ if (this.logsCut) return;
208
+ const line = `${level === 'log' ? '' : `[${level}] `}${text}\n`;
209
+ const bytes = Buffer.byteLength(line, 'utf8');
210
+ if (this.logBytes + bytes > this.limits.maxLogBytes) {
211
+ this.logsCut = true;
212
+ this.report({ kind: 'log', text: '[console output cut off]\n' });
213
+ return;
214
+ }
215
+ this.logBytes += bytes;
216
+ this.report({ kind: 'log', text: line });
217
+ }
218
+
219
+ private evaluate(code: string, file: string, phase: TCodeWorkerPhase): boolean {
220
+ if (this.over()) return false;
221
+ let result: plugins.TQuickJsResult;
222
+ this.enter();
223
+ try {
224
+ result = this.vm.evalCode(code, file);
225
+ } catch (error) {
226
+ this.hostThrow(error);
227
+ return false;
228
+ } finally {
229
+ this.leave();
230
+ }
231
+ if (result.error) {
232
+ this.failWith(result.error, phase);
233
+ return false;
234
+ }
235
+ result.value.dispose();
236
+ return true;
237
+ }
238
+
239
+ /** Runs the program's pending jobs after a call settled or the program started. */
240
+ private pump(): void {
241
+ if (this.over()) return;
242
+ let result: { error?: plugins.IQuickJsHandle };
243
+ this.enter();
244
+ try {
245
+ result = this.runtime.executePendingJobs();
246
+ } catch (error) {
247
+ this.hostThrow(error);
248
+ return;
249
+ } finally {
250
+ this.leave();
251
+ }
252
+ if (result.error) this.failWith(result.error, 'program');
253
+ }
254
+
255
+ /** The worker's own stack ran out under deep recursion; anything else is a failure of the worker. */
256
+ private hostThrow(error: unknown): void {
257
+ if (!(error instanceof RangeError)) throw error;
258
+ this.stop('stack');
259
+ }
260
+
261
+ private failWith(handle: plugins.IQuickJsHandle, phase: TCodeWorkerPhase): void {
262
+ let dumped: unknown;
263
+ // Reading the thrown value can run program code, such as a toJSON: it counts as computing.
264
+ this.enter();
265
+ try {
266
+ dumped = this.vm.dump(handle);
267
+ } catch {
268
+ dumped = undefined;
269
+ } finally {
270
+ this.leave();
271
+ }
272
+ if (this.stopped) {
273
+ this.stop(this.stopped);
274
+ return;
275
+ }
276
+ this.ended = true;
277
+ const error = (dumped && typeof dumped === 'object' ? dumped : { message: String(dumped) }) as {
278
+ name?: unknown; message?: unknown; stack?: unknown;
279
+ };
280
+ this.report({
281
+ kind: 'failed',
282
+ phase,
283
+ ...(typeof error.name === 'string' ? { name: error.name } : {}),
284
+ ...(typeof error.message === 'string' ? { message: error.message } : {}),
285
+ ...(typeof error.stack === 'string' ? { stack: error.stack } : {}),
286
+ ...(phase === 'prelude' ? { dumped: JSON.stringify(dumped) } : {}),
287
+ });
288
+ }
289
+
290
+ private stop(limit: 'cpu' | 'stack'): void {
291
+ this.stopped = limit;
292
+ if (this.ended) return;
293
+ this.ended = true;
294
+ this.report({ kind: 'stopped', limit });
295
+ }
296
+
297
+ private over(): boolean {
298
+ return this.ended || this.stopped !== undefined;
299
+ }
300
+
301
+ private idle(): void {
302
+ if (!this.over()) this.report({ kind: 'idle', cpuMs: this.cpuMs });
303
+ }
304
+
305
+ private enter(): void {
306
+ this.enteredAt = performance.now();
307
+ }
308
+
309
+ private leave(): void {
310
+ if (this.enteredAt === undefined) return;
311
+ this.cpuMs += performance.now() - this.enteredAt;
312
+ this.enteredAt = undefined;
313
+ }
314
+
315
+ private interrupted(): boolean {
316
+ if (this.stopped) return true;
317
+ if (this.enteredAt !== undefined && this.cpuMs + performance.now() - this.enteredAt > this.limits.cpuMs) {
318
+ this.stopped = 'cpu';
319
+ return true;
320
+ }
321
+ return false;
322
+ }
323
+ }
324
+
325
+ const port = parentPort;
326
+ if (port) {
327
+ // The variant's typings resolve as CommonJS; at run time the default export is the variant.
328
+ const [core, variant] = await Promise.all([
329
+ import('quickjs-emscripten-core'),
330
+ import('@jitl/quickjs-ng-wasmfile-release-sync'),
331
+ ]);
332
+ const quickjsVariant = (variant as { default: unknown }).default as Parameters<typeof core.newQuickJSWASMModuleFromVariant>[0];
333
+ const module = await core.newQuickJSWASMModuleFromVariant(quickjsVariant) as unknown as plugins.IQuickJsModule;
334
+ const report = (message: TCodeWorkerReport): void => port.postMessage(message);
335
+ let run: CodeWorkerRun | undefined;
336
+ port.on('message', (request: TCodeWorkerRequest) => {
337
+ if (request.kind === 'start') {
338
+ run = new CodeWorkerRun(module, request.limits, report);
339
+ run.start(request.program);
340
+ } else {
341
+ run?.answer(request.id, request.text);
342
+ }
343
+ });
344
+ report({ kind: 'ready' });
345
+ }
@@ -8,17 +8,29 @@ import {
8
8
  type TFlexAgentToolCallUpdateEvent,
9
9
  type TFlexAgentToolCallFinishEvent,
10
10
  type TFlexMessagePart,
11
+ type TJsonValue,
11
12
  } from './interfaces.js';
12
- import { normalizeJsonValue } from './utils.json.js';
13
- import { jsonBytes } from './utils.transfer.js';
13
+ import { MIN_MAX_BYTES, normalizeJsonValue } from './utils.json.js';
14
+ import { jsonBytes, truncateUtf8 } from './utils.transfer.js';
14
15
  import {
15
16
  observeSessionMetricsCall,
16
17
  settleSessionMetricsOperation,
17
18
  type ISessionMetricsOperation,
18
19
  } from './utils.sessionmetrics.js';
19
- import type { IStoredSessionState, IStorageState, IActiveRun } from './interfaces.flexharness.js';
20
+ import type {
21
+ IStoredSessionState,
22
+ IStorageState,
23
+ IActiveRun,
24
+ IProgramCallbackShare,
25
+ } from './interfaces.flexharness.js';
20
26
  import type { FlexHarnessCore } from './classes.flexharness.core.js';
21
27
 
28
+ /** How a tool call ended, as its part records it. */
29
+ type TToolPartOutcome = { output: TJsonValue } | { error: string };
30
+
31
+ const outcomeBytes = (outcome: TToolPartOutcome): number =>
32
+ 'output' in outcome ? jsonBytes(outcome.output) : Buffer.byteLength(outcome.error);
33
+
22
34
  /** The model stream callbacks of a run: text, reasoning, tool, retry and usage events. */
23
35
  export class FlexHarnessRunCallbacks<TScope> {
24
36
  constructor(private readonly core: FlexHarnessCore<TScope>) {}
@@ -27,10 +39,11 @@ export class FlexHarnessRunCallbacks<TScope> {
27
39
  const run = this.callbackRun(state, sessionId);
28
40
  if (!run || !delta) return;
29
41
  const lastPart = run.callbackParts.at(-1);
30
- const created = lastPart?.type !== 'text';
31
- const part = lastPart?.type === 'text'
42
+ const openPart = lastPart?.type === 'text' && !run.completedTextPartIds.has(lastPart.partId)
32
43
  ? lastPart
33
- : { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: '' };
44
+ : undefined;
45
+ const created = !openPart;
46
+ const part = openPart ?? { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: '' };
34
47
  const accounting = this.textAppendAccounting(run, part, delta, created);
35
48
  if (!accounting) return;
36
49
  if (!this.reserveCallbackCapacity(
@@ -105,6 +118,22 @@ export class FlexHarnessRunCallbacks<TScope> {
105
118
  this.core.emitPartEvent(run, 'part.completed', part);
106
119
  }
107
120
 
121
+ /**
122
+ * Completes the text parts of a committed model step: their text is final and saved. A text part
123
+ * completes once, so the terminal projection completes only the text parts of a step that did
124
+ * not commit. Like the terminal's completions, these events are outside the callback limits:
125
+ * their number is bounded by the run's parts.
126
+ */
127
+ public onStepCommitted(state: IStorageState, sessionId: string): void {
128
+ const run = this.callbackRun(state, sessionId);
129
+ if (!run) return;
130
+ for (const part of run.callbackParts) {
131
+ if (part.type !== 'text' || run.completedTextPartIds.has(part.partId)) continue;
132
+ run.completedTextPartIds.add(part.partId);
133
+ this.core.emitPartEvent(run, 'part.completed', part);
134
+ }
135
+ }
136
+
108
137
  private textAppendAccounting(
109
138
  run: IActiveRun,
110
139
  part: Extract<TFlexMessagePart, { type: 'text' | 'reasoning' }>,
@@ -147,21 +176,7 @@ export class FlexHarnessRunCallbacks<TScope> {
147
176
  : undefined;
148
177
  const input = normalizeJsonValue(target ? target.params : event.input, this.core.toolOutputLimits);
149
178
  if (!this.reserveCallbackCapacity(run, 1, jsonBytes(input), 1)) return;
150
- const part: IFlexToolMessagePart = {
151
- partId: plugins.crypto.randomUUID(),
152
- type: 'tool',
153
- toolCallId: this.core.boundedIdentifier(event.toolCallId, 'toolCallId'),
154
- toolName: this.core.boundedIdentifier(target ? target.method : event.toolName, 'toolName'),
155
- status: 'running',
156
- input,
157
- ...(event.parentToolCallId === undefined
158
- ? {}
159
- : { parentToolCallId: this.core.boundedIdentifier(event.parentToolCallId, 'parentToolCallId') }),
160
- startedAt: new Date().toISOString(),
161
- };
162
- run.toolPartIds.set(event.toolCallId, part.partId);
163
- run.callbackParts.push(part);
164
- this.core.emitPartEvent(run, 'part.started', part);
179
+ this.startToolPart(run, { ...event, toolName: target ? target.method : event.toolName }, input);
165
180
  } catch (error) {
166
181
  this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
167
182
  }
@@ -175,37 +190,164 @@ export class FlexHarnessRunCallbacks<TScope> {
175
190
  const run = this.callbackRun(state, sessionId);
176
191
  if (!run) return;
177
192
  if (!run.toolPartIds.has(event.toolCallId)) this.onToolStart(state, sessionId, event);
178
- const partId = run.toolPartIds.get(event.toolCallId);
179
- const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
180
- if (!part || part.type !== 'tool' || part.status !== 'running') return;
193
+ const part = this.runningToolPart(run, event.toolCallId);
194
+ if (!part) return;
181
195
  try {
182
- const trustedExecutionError = run.trustedToolExecutionErrors.get(event.toolCallId);
183
- run.trustedToolExecutionErrors.delete(event.toolCallId);
184
- const output = event.success ? normalizeJsonValue(event.output, this.core.toolOutputLimits) : undefined;
185
- const projectedError = event.success
186
- ? undefined
187
- : this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback');
188
- const bytes = event.success ? jsonBytes(output) : Buffer.byteLength(projectedError!.message);
189
- if (!this.reserveCallbackCapacity(run, 1, bytes - (part.output === undefined ? 0 : jsonBytes(part.output)), 0)) return;
190
- if (event.success) {
191
- part.status = 'completed';
192
- part.output = output!;
193
- } else {
194
- part.status = 'failed';
195
- delete part.output;
196
- part.error = projectedError!.message;
197
- }
198
- const original = run.deduplicatedToolCalls.get(event.toolCallId);
199
- if (original !== undefined) {
200
- part.deduplicatedFrom = this.core.boundedIdentifier(original, 'deduplicatedFrom');
201
- }
202
- part.completedAt = new Date().toISOString();
203
- this.core.emitPartEvent(run, 'part.completed', part);
196
+ const outcome = this.toolOutcome(run, event, this.core.toolOutputLimits.maxBytes);
197
+ if (!this.reserveCallbackCapacity(
198
+ run,
199
+ 1,
200
+ outcomeBytes(outcome) - (part.output === undefined ? 0 : jsonBytes(part.output)),
201
+ 0,
202
+ )) return;
203
+ this.completeToolPart(run, part, event.toolCallId, outcome);
204
+ } catch (error) {
205
+ this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The room a `run_code` program's calls get in the run's callback limits: half of what the run
211
+ * has left once its program's answer is set aside. The rest stays for the model's next steps.
212
+ */
213
+ public programCallbackShare(state: IStorageState, sessionId: string): IProgramCallbackShare {
214
+ const run = this.callbackRun(state, sessionId);
215
+ if (!run) return { events: 0, outputBytes: 0, parts: 0 };
216
+ const limits = this.core.callbackLimits;
217
+ const half = (room: number): number => Math.max(0, Math.floor(room / 2));
218
+ return {
219
+ events: half(limits.maxEvents - run.callbackEventCount - 1),
220
+ outputBytes: half(limits.maxOutputBytes - run.callbackOutputBytes - this.core.toolOutputLimits.maxBytes),
221
+ parts: half(limits.maxParts - run.callbackParts.length),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Records the start of a call a program made, in the program's share. The call takes the room of
227
+ * its input, its two events and at least a truncated output now. A call the share or the run
228
+ * cannot hold is not recorded, and the answer is false: the program stops, the run goes on.
229
+ */
230
+ public onProgramCallStart(
231
+ state: IStorageState,
232
+ sessionId: string,
233
+ share: IProgramCallbackShare,
234
+ event: { toolCallId: string; toolName: string; input: unknown; parentToolCallId: string },
235
+ ): boolean {
236
+ const run = this.callbackRun(state, sessionId);
237
+ if (!run) return false;
238
+ try {
239
+ const input = normalizeJsonValue(event.input, this.core.toolOutputLimits);
240
+ const bytes = jsonBytes(input) + MIN_MAX_BYTES;
241
+ if (
242
+ share.events < 2
243
+ || share.outputBytes < bytes
244
+ || share.parts < 1
245
+ || !this.tryReserveCallbackCapacity(run, 2, bytes, 1)
246
+ ) return false;
247
+ share.events -= 2;
248
+ share.outputBytes -= bytes;
249
+ share.parts -= 1;
250
+ this.startToolPart(run, event, input);
251
+ return true;
204
252
  } catch (error) {
205
253
  this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
254
+ return false;
206
255
  }
207
256
  }
208
257
 
258
+ /**
259
+ * Records how a call `onProgramCallStart` recorded ended. Its output or error is recorded in what
260
+ * is left of the share, cut to fit: the program gets the call's answer whole all the same.
261
+ */
262
+ public onProgramCallFinish(
263
+ state: IStorageState,
264
+ sessionId: string,
265
+ share: IProgramCallbackShare,
266
+ event: TFlexAgentToolCallFinishEvent,
267
+ ): void {
268
+ const run = this.callbackRun(state, sessionId);
269
+ if (!run) return;
270
+ const part = this.runningToolPart(run, event.toolCallId);
271
+ if (!part) return;
272
+ try {
273
+ const room = Math.min(share.outputBytes, this.core.callbackLimits.maxOutputBytes - run.callbackOutputBytes);
274
+ const allowance = MIN_MAX_BYTES + Math.max(0, room);
275
+ const finished = this.toolOutcome(run, event, Math.min(this.core.toolOutputLimits.maxBytes, allowance));
276
+ const outcome = 'error' in finished ? { error: truncateUtf8(finished.error, allowance) } : finished;
277
+ // The start reserved the call's events and its least output.
278
+ const bytes = outcomeBytes(outcome) - MIN_MAX_BYTES;
279
+ if (!this.reserveCallbackCapacity(run, 0, bytes, 0)) return;
280
+ share.outputBytes -= bytes;
281
+ this.completeToolPart(run, part, event.toolCallId, outcome);
282
+ } catch (error) {
283
+ this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
284
+ }
285
+ }
286
+
287
+ private startToolPart(
288
+ run: IActiveRun,
289
+ event: { toolCallId: string; toolName: string; parentToolCallId?: string },
290
+ input: IFlexToolMessagePart['input'],
291
+ ): void {
292
+ const part: IFlexToolMessagePart = {
293
+ partId: plugins.crypto.randomUUID(),
294
+ type: 'tool',
295
+ toolCallId: this.core.boundedIdentifier(event.toolCallId, 'toolCallId'),
296
+ toolName: this.core.boundedIdentifier(event.toolName, 'toolName'),
297
+ status: 'running',
298
+ input,
299
+ ...(event.parentToolCallId === undefined
300
+ ? {}
301
+ : { parentToolCallId: this.core.boundedIdentifier(event.parentToolCallId, 'parentToolCallId') }),
302
+ startedAt: new Date().toISOString(),
303
+ };
304
+ run.toolPartIds.set(event.toolCallId, part.partId);
305
+ run.callbackParts.push(part);
306
+ this.core.emitPartEvent(run, 'part.started', part);
307
+ }
308
+
309
+ private runningToolPart(run: IActiveRun, toolCallId: string): IFlexToolMessagePart | undefined {
310
+ const partId = run.toolPartIds.get(toolCallId);
311
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
312
+ return part?.type === 'tool' && part.status === 'running' ? part : undefined;
313
+ }
314
+
315
+ /** A finished call's output in at most `maxOutputBytes`, or its projected error. */
316
+ private toolOutcome(
317
+ run: IActiveRun,
318
+ event: TFlexAgentToolCallFinishEvent,
319
+ maxOutputBytes: number,
320
+ ): TToolPartOutcome {
321
+ const trustedExecutionError = run.trustedToolExecutionErrors.get(event.toolCallId);
322
+ run.trustedToolExecutionErrors.delete(event.toolCallId);
323
+ if (event.success) {
324
+ return { output: normalizeJsonValue(event.output, { ...this.core.toolOutputLimits, maxBytes: maxOutputBytes }) };
325
+ }
326
+ return { error: this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback').message };
327
+ }
328
+
329
+ private completeToolPart(
330
+ run: IActiveRun,
331
+ part: IFlexToolMessagePart,
332
+ toolCallId: string,
333
+ outcome: TToolPartOutcome,
334
+ ): void {
335
+ if ('output' in outcome) {
336
+ part.status = 'completed';
337
+ part.output = outcome.output;
338
+ } else {
339
+ part.status = 'failed';
340
+ delete part.output;
341
+ part.error = outcome.error;
342
+ }
343
+ const original = run.deduplicatedToolCalls.get(toolCallId);
344
+ if (original !== undefined) {
345
+ part.deduplicatedFrom = this.core.boundedIdentifier(original, 'deduplicatedFrom');
346
+ }
347
+ part.completedAt = new Date().toISOString();
348
+ this.core.emitPartEvent(run, 'part.completed', part);
349
+ }
350
+
209
351
  public onToolUpdate(
210
352
  state: IStorageState,
211
353
  sessionId: string,
@@ -218,9 +360,8 @@ export class FlexHarnessRunCallbacks<TScope> {
218
360
  run = this.callbackRun(state, sessionId);
219
361
  if (!run) return;
220
362
  }
221
- const partId = run.toolPartIds.get(event.toolCallId);
222
- const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
223
- if (!part || part.type !== 'tool' || part.status !== 'running') return;
363
+ const part = this.runningToolPart(run, event.toolCallId);
364
+ if (!part) return;
224
365
  try {
225
366
  const output = normalizeJsonValue(event.output, this.core.toolOutputLimits);
226
367
  if (!this.reserveCallbackCapacity(run, 1, jsonBytes(output) - (part.output === undefined ? 0 : jsonBytes(part.output)), 0)) return;
@@ -318,11 +459,27 @@ export class FlexHarnessRunCallbacks<TScope> {
318
459
  return !run || run.callbacksClosed || run.callbackError ? undefined : run;
319
460
  }
320
461
 
462
+ /** Reserves room in the run's callback limits; a reservation past them fails the run. */
321
463
  public reserveCallbackCapacity(
322
464
  run: IActiveRun,
323
465
  eventCount: number,
324
466
  outputBytes: number,
325
467
  partCount: number,
468
+ ): boolean {
469
+ if (this.tryReserveCallbackCapacity(run, eventCount, outputBytes, partCount)) return true;
470
+ this.failCallback(run, this.core.trustInternalError(new FlexHarnessCallbackOverflowError(
471
+ `Callback buffer exceeded its limit (${run.callbackEventCount + eventCount} events, `
472
+ + `${run.callbackOutputBytes + outputBytes} bytes, ${run.callbackParts.length + partCount} parts).`,
473
+ )));
474
+ return false;
475
+ }
476
+
477
+ /** Reserves room in the run's callback limits; false, reserving nothing, when it does not fit. */
478
+ private tryReserveCallbackCapacity(
479
+ run: IActiveRun,
480
+ eventCount: number,
481
+ outputBytes: number,
482
+ partCount: number,
326
483
  ): boolean {
327
484
  const nextEvents = run.callbackEventCount + eventCount;
328
485
  const nextOutputBytes = run.callbackOutputBytes + outputBytes;
@@ -334,12 +491,7 @@ export class FlexHarnessRunCallbacks<TScope> {
334
491
  || nextEvents > this.core.callbackLimits.maxEvents
335
492
  || nextOutputBytes > this.core.callbackLimits.maxOutputBytes
336
493
  || nextParts > this.core.callbackLimits.maxParts
337
- ) {
338
- this.failCallback(run, this.core.trustInternalError(new FlexHarnessCallbackOverflowError(
339
- `Callback buffer exceeded its limit (${nextEvents} events, ${nextOutputBytes} bytes, ${nextParts} parts).`,
340
- )));
341
- return false;
342
- }
494
+ ) return false;
343
495
  run.callbackEventCount = nextEvents;
344
496
  run.callbackOutputBytes = nextOutputBytes;
345
497
  return true;