@modelprofile.com/flexharness 10.13.0 → 10.14.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.
@@ -28,6 +28,7 @@ import {
28
28
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
29
29
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
30
30
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
31
+ FLEX_CODE_MODE_TOOL_NAME,
31
32
  FLEX_SESSION_IMPORT_MAX_DEPTH,
32
33
  FLEX_TOOL_CATALOG_TOOL_NAMES,
33
34
  } from './interfaces.js';
@@ -36,6 +37,7 @@ import {
36
37
  assertNoCatalogCollisions,
37
38
  catalogToolInputSchema,
38
39
  } from './classes.toolcatalog.js';
40
+ import { FlexCodeSandbox, type IFlexCodeHost } from './classes.codesandbox.js';
39
41
  import type {
40
42
  IFlexAgentContextCompactionOptions,
41
43
  IFlexAgentContextInvocation,
@@ -119,6 +121,7 @@ import type {
119
121
  IFlexSubagentProvenance,
120
122
  IFlexTerminalProjection,
121
123
  IFlexCatalogDescribeInput,
124
+ IFlexCatalogProposal,
122
125
  IFlexCatalogSearchInput,
123
126
  IFlexToolCatalogOptions,
124
127
  IFlexToolHandle,
@@ -222,7 +225,7 @@ import {
222
225
  validateSessionImportHandle,
223
226
  type INormalizedSessionImportOptions,
224
227
  } from './utils.sessionimport.js';
225
- import { wrapToolSet } from './utils.tooloutput.js';
228
+ import { settleToolOutput, wrapToolSet } from './utils.tooloutput.js';
226
229
  import {
227
230
  validateCompactSessionOptions,
228
231
  validateCreateProjectTaskInput,
@@ -445,6 +448,8 @@ interface IActiveRun {
445
448
  toolProviderContext?: Readonly<IFlexToolProviderContext<unknown>>;
446
449
  /** The catalog methods the run offers through search_tools, describe_tools and execute. */
447
450
  toolCatalog?: FlexToolCatalog<unknown>;
451
+ /** The sandbox `run_code` programs run in, loaded when the catalog has code mode. */
452
+ codeSandbox?: FlexCodeSandbox;
448
453
  subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
449
454
  allowedTools?: readonly string[];
450
455
  agentAllowedTools?: readonly string[];
@@ -865,7 +870,7 @@ export class FlexHarness<TScope = unknown> {
865
870
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
866
871
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
867
872
  private readonly builtInTools: INormalizedBuiltInTools;
868
- private readonly toolCatalogOptions: Readonly<Required<IFlexToolCatalogOptions>>;
873
+ private readonly toolCatalogOptions: Readonly<IFlexToolCatalogOptions>;
869
874
  private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
870
875
  private readonly maxSubagentDepth: number;
871
876
  private readonly maxSubagentCallsPerRun: number;
@@ -952,7 +957,11 @@ export class FlexHarness<TScope = unknown> {
952
957
  this.externalErrorProjector = options.externalErrorProjector;
953
958
  this.subagents = normalizeSubagents(options.subagents);
954
959
  this.builtInTools = normalizeBuiltInTools(options.builtInTools);
955
- this.toolCatalogOptions = normalizeToolCatalogOptions(options.toolCatalog);
960
+ const toolCatalogOptions = normalizeToolCatalogOptions(options.toolCatalog);
961
+ this.toolCatalogOptions = Object.freeze({
962
+ declarations: toolCatalogOptions.declarations,
963
+ ...(toolCatalogOptions.codeMode ? { codeMode: { limits: toolCatalogOptions.codeMode } } : {}),
964
+ });
956
965
  this.slashCommands = normalizeSlashCommands(options.slashCommands);
957
966
  this.maxSubagentDepth = resolveBoundedPositiveInteger(
958
967
  options.maxSubagentDepth,
@@ -5844,6 +5853,7 @@ export class FlexHarness<TScope = unknown> {
5844
5853
  if (toolHandle?.catalog) {
5845
5854
  try {
5846
5855
  catalog = await FlexToolCatalog.create(toolHandle.catalog, this.toolCatalogOptions);
5856
+ if (catalog.codeMode) run.codeSandbox = await FlexCodeSandbox.load();
5847
5857
  } catch (error) {
5848
5858
  throw this.projectExternalError(run, error, 'toolProvider');
5849
5859
  }
@@ -5984,20 +5994,141 @@ export class FlexHarness<TScope = unknown> {
5984
5994
  input: { method: string; params?: Record<string, unknown> },
5985
5995
  options?: { toolCallId?: string; abortSignal?: AbortSignal },
5986
5996
  ) => {
5997
+ const toolCallId = options?.toolCallId ?? '';
5998
+ const abortSignal = options?.abortSignal ?? run.controller.signal;
5987
5999
  const call = await catalog.prepareCall(input.method, input.params);
5988
- if (!call.success) {
5989
- const error = this.trustInternalError(new FlexHarnessValidationError(JSON.stringify(call.error)));
5990
- if (options?.toolCallId) run.trustedToolExecutionErrors.set(options.toolCallId, error);
5991
- throw error;
6000
+ if (!call.success) throw this.catalogCallError(run, toolCallId, call.error);
6001
+ const context = { method: call.method, toolCallId, abortSignal, provider: run.toolProviderContext! };
6002
+ const decision = await call.decide(context);
6003
+ if (decision === 'run') return call.run(context);
6004
+ if (decision === 'propose') {
6005
+ return catalog.propose(
6006
+ [{ id: toolCallId, method: call.method, params: call.params }],
6007
+ { toolCallId, abortSignal, provider: run.toolProviderContext! },
6008
+ );
5992
6009
  }
5993
- return call.run({
5994
- method: call.method,
5995
- toolCallId: options?.toolCallId ?? '',
5996
- abortSignal: options?.abortSignal ?? run.controller.signal,
5997
- provider: run.toolProviderContext!,
5998
- });
6010
+ throw this.catalogCallError(run, toolCallId, { error: 'denied', method: call.method, reason: decision.deny });
5999
6011
  },
6000
6012
  }),
6013
+ ...(catalog.codeMode && run.codeSandbox ? {
6014
+ [FLEX_CODE_MODE_TOOL_NAME]: plugins.tool({
6015
+ description: definitions[FLEX_CODE_MODE_TOOL_NAME].description,
6016
+ strict: false,
6017
+ inputSchema: catalogToolInputSchema<{ code: string }>(definitions[FLEX_CODE_MODE_TOOL_NAME]),
6018
+ execute: async (input: { code: string }, options?: { toolCallId?: string; abortSignal?: AbortSignal }) =>
6019
+ this.runCatalogProgram(run, catalog, input.code, options?.toolCallId ?? '', options?.abortSignal ?? run.controller.signal),
6020
+ }),
6021
+ } : {}),
6022
+ };
6023
+ }
6024
+
6025
+ /** A refused catalog call as the trusted error the model and the tool part show. */
6026
+ private catalogCallError(run: IActiveRun, toolCallId: string, payload: unknown): Error {
6027
+ const error = this.trustInternalError(new FlexHarnessValidationError(JSON.stringify(payload)));
6028
+ if (toolCallId) run.trustedToolExecutionErrors.set(toolCallId, error);
6029
+ return error;
6030
+ }
6031
+
6032
+ /**
6033
+ * Runs a `run_code` program. Every catalog call it makes is dispatched like an `execute`
6034
+ * (validation, `onCall`, the method's run with its permission requests, the output limits, error
6035
+ * projection) and recorded as a tool part of its own, named by the method, with the `run_code`
6036
+ * call as its parent. Proposals go to `onProposals` as one batch once the program finished; a
6037
+ * program that fails proposes nothing.
6038
+ */
6039
+ private async runCatalogProgram(
6040
+ run: IActiveRun,
6041
+ catalog: FlexToolCatalog<unknown>,
6042
+ code: string,
6043
+ toolCallId: string,
6044
+ abortSignal: AbortSignal,
6045
+ ): Promise<unknown> {
6046
+ const proposals: IFlexCatalogProposal[] = [];
6047
+ const base = run.toolProviderContext!;
6048
+ // Waiting for a permission decision is the user's time, not the program's.
6049
+ let waitedMs = 0;
6050
+ let waiting = 0;
6051
+ let waitingSince = 0;
6052
+ /** The provider context of one call: its permission waits are timed, and end when the program stops. */
6053
+ const providerFor = (signal: AbortSignal): Readonly<IFlexToolProviderContext<unknown>> => Object.freeze({
6054
+ ...base,
6055
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
6056
+ signal.throwIfAborted();
6057
+ if (waiting++ === 0) waitingSince = performance.now();
6058
+ let onAbort: (() => void) | undefined;
6059
+ try {
6060
+ await Promise.race([
6061
+ base.requestPermission(request),
6062
+ new Promise<never>((_resolve, reject) => {
6063
+ onAbort = () => reject(signal.reason);
6064
+ signal.addEventListener('abort', onAbort, { once: true });
6065
+ }),
6066
+ ]);
6067
+ } finally {
6068
+ if (onAbort) signal.removeEventListener('abort', onAbort);
6069
+ if (--waiting === 0) waitedMs += performance.now() - waitingSince;
6070
+ }
6071
+ },
6072
+ });
6073
+ const stopped = (method: string) => ({ ok: false as const, error: { error: 'aborted', method } });
6074
+ let callNumber = 0;
6075
+ const finish = (id: string, method: string, input: unknown, outcome: { output: unknown } | { error: Error }) => {
6076
+ if ('error' in outcome) run.trustedToolExecutionErrors.set(id, outcome.error);
6077
+ this.onToolFinish(run.state, run.sessionId, 'error' in outcome
6078
+ ? { toolCallId: id, toolName: method, input, success: false, error: outcome.error.message }
6079
+ : { toolCallId: id, toolName: method, input, success: true, output: outcome.output });
6080
+ };
6081
+ const host: IFlexCodeHost = {
6082
+ waitedMs: () => waitedMs + (waiting > 0 ? performance.now() - waitingSince : 0),
6083
+ call: async (method, params, signal) => {
6084
+ if (signal.aborted) return stopped(method);
6085
+ const id = `${toolCallId}.${++callNumber}`;
6086
+ const call = await catalog.prepareCall(method, params);
6087
+ if (!call.success) {
6088
+ if (catalog.has(method)) {
6089
+ this.onToolStart(run.state, run.sessionId, { toolCallId: id, toolName: method, input: params ?? {}, parentToolCallId: toolCallId });
6090
+ finish(id, method, params, { error: this.catalogCallError(run, id, call.error) });
6091
+ }
6092
+ return { ok: false, error: call.error as unknown as TJsonValue };
6093
+ }
6094
+ if (signal.aborted) return stopped(call.method);
6095
+ this.onToolStart(run.state, run.sessionId, {
6096
+ toolCallId: id, toolName: call.method, input: call.params, parentToolCallId: toolCallId,
6097
+ });
6098
+ const context = { method: call.method, toolCallId: id, abortSignal: signal, provider: providerFor(signal) };
6099
+ try {
6100
+ const decision = await call.decide(context);
6101
+ signal.throwIfAborted();
6102
+ if (decision === 'propose') {
6103
+ proposals.push({ id, method: call.method, params: call.params });
6104
+ const value = { proposed: true, id };
6105
+ finish(id, call.method, call.params, { output: value });
6106
+ return { ok: true, value };
6107
+ }
6108
+ if (decision !== 'run') {
6109
+ const payload = { error: 'denied' as const, method: call.method, reason: decision.deny };
6110
+ finish(id, call.method, call.params, { error: this.catalogCallError(run, id, payload) });
6111
+ return { ok: false, error: payload };
6112
+ }
6113
+ const value = normalizeJsonValue(await settleToolOutput(call.run(context)), this.toolOutputLimits);
6114
+ finish(id, call.method, call.params, { output: value });
6115
+ return { ok: true, value };
6116
+ } catch (error) {
6117
+ const projected = this.projectExternalError(run, error, 'toolExecution');
6118
+ finish(id, call.method, call.params, { error: projected });
6119
+ return { ok: false, error: { error: 'failed', method: call.method, message: projected.message } };
6120
+ }
6121
+ },
6122
+ };
6123
+ const result = await run.codeSandbox!.run(code, host, catalog.codeMode!, abortSignal);
6124
+ if (!result.ok) throw this.catalogCallError(run, toolCallId, result.failure);
6125
+ return {
6126
+ result: result.value,
6127
+ calls: result.calls,
6128
+ ...(result.logs ? { logs: result.logs } : {}),
6129
+ ...(proposals.length > 0
6130
+ ? { proposals: await catalog.propose(proposals, { toolCallId, abortSignal, provider: base }) }
6131
+ : {}),
6001
6132
  };
6002
6133
  }
6003
6134
 
@@ -8505,7 +8636,7 @@ export class FlexHarness<TScope = unknown> {
8505
8636
  private onToolStart(
8506
8637
  state: IStorageState,
8507
8638
  sessionId: string,
8508
- event: { toolCallId: string; toolName: string; input: unknown },
8639
+ event: { toolCallId: string; toolName: string; input: unknown; parentToolCallId?: string },
8509
8640
  ): void {
8510
8641
  const run = this.callbackRun(state, sessionId);
8511
8642
  if (!run || run.toolPartIds.has(event.toolCallId)) return;
@@ -8523,6 +8654,9 @@ export class FlexHarness<TScope = unknown> {
8523
8654
  toolName: this.boundedIdentifier(target ? target.method : event.toolName, 'toolName'),
8524
8655
  status: 'running',
8525
8656
  input,
8657
+ ...(event.parentToolCallId === undefined
8658
+ ? {}
8659
+ : { parentToolCallId: this.boundedIdentifier(event.parentToolCallId, 'parentToolCallId') }),
8526
8660
  startedAt: new Date().toISOString(),
8527
8661
  };
8528
8662
  run.toolPartIds.set(event.toolCallId, part.partId);
@@ -1,8 +1,13 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { FlexHarnessValidationError } from './errors.js';
3
3
  import {
4
+ FLEX_CODE_MODE_TOOL_NAME,
4
5
  FLEX_TOOL_CATALOG_LIMITS as limits,
5
6
  FLEX_TOOL_CATALOG_TOOL_NAMES as toolNames,
7
+ type IFlexCatalogProposal,
8
+ type IFlexCatalogProposalContext,
9
+ type IFlexCodeModeLimits,
10
+ type TFlexCatalogCallDecision,
6
11
  type IFlexCatalogDescribeInput,
7
12
  type IFlexCatalogDescribeResult,
8
13
  type IFlexCatalogMethod,
@@ -25,6 +30,7 @@ import {
25
30
  type IRenderedDeclaration,
26
31
  type TJsonSchemaNode,
27
32
  } from './utils.schemadeclarations.js';
33
+ import { normalizeToolCatalogOptions } from './utils.harnessoptions.js';
28
34
  import { validateUtf8String } from './utils.validation.js';
29
35
 
30
36
  type TValidationResult = { success: true; value: unknown } | { success: false; error: Error };
@@ -58,6 +64,7 @@ interface IMethodEntry {
58
64
  readonly outputScope?: ISchemaScope;
59
65
  readonly declaration?: string;
60
66
  readonly run: IFlexCatalogMethod['run'];
67
+ readonly onCall?: NonNullable<IFlexCatalogMethod['onCall']>;
61
68
  }
62
69
 
63
70
  interface ITypeEntry {
@@ -76,6 +83,8 @@ interface IPreparedCall {
76
83
  readonly success: true;
77
84
  readonly method: string;
78
85
  readonly params: unknown;
86
+ /** The method's `onCall` decision for the call; `run` without the hook. */
87
+ decide(context: IFlexCatalogRunContext<any>): Promise<TFlexCatalogCallDecision>;
79
88
  run(context: IFlexCatalogRunContext<any>): ReturnType<IFlexCatalogMethod['run']>;
80
89
  }
81
90
 
@@ -99,10 +108,10 @@ const controlPattern = /[\u0000-\u001f\u007f]/;
99
108
  const definitionRefPattern = /^#\/(\$defs|definitions)\/([^/]+)$/;
100
109
  const declaredTypesHeader = '// Declares types: ';
101
110
  const identifierScanPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g;
102
- const describeFormats = new Set(['typescript', 'json-schema']);
103
111
  const methodKeys = new Set([
104
- 'method', 'summary', 'description', 'tags', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run',
112
+ 'method', 'summary', 'description', 'tags', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run', 'onCall',
105
113
  ]);
114
+ const catalogKeys = new Set(['methods', 'types', 'onProposals']);
106
115
  const typeKeys = new Set(['name', 'description', 'schema', 'declaration']);
107
116
  const stopWords = new Set([
108
117
  'a', 'an', 'and', 'are', 'all', 'as', 'be', 'by', 'for', 'from', 'get', 'i', 'in', 'is', 'it', 'me', 'my',
@@ -421,14 +430,19 @@ class FlexToolCatalogCore {
421
430
  public readonly mode: TDeclarationMode,
422
431
  public readonly methods: ReadonlyMap<string, IMethodEntry>,
423
432
  public readonly types: ReadonlyMap<string, ITypeEntry>,
433
+ public readonly onProposals: IFlexToolCatalog['onProposals'] | undefined,
424
434
  ) {
425
435
  this.index = new CatalogSearchIndex([...methods.values()]);
426
436
  }
427
437
 
428
438
  public static async build(catalog: unknown, mode: TDeclarationMode): Promise<FlexToolCatalogCore> {
429
439
  if (!isPlainObject(catalog)) throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
430
- const unsupported = Object.keys(catalog).find((key) => key !== 'methods' && key !== 'types');
440
+ const unsupported = Object.keys(catalog).find((key) => !catalogKeys.has(key));
431
441
  if (unsupported) throw new FlexHarnessValidationError(`A tool catalog does not support "${unsupported}".`);
442
+ const onProposals = catalog.onProposals;
443
+ if (onProposals !== undefined && typeof onProposals !== 'function') {
444
+ throw new FlexHarnessValidationError('A tool catalog onProposals must be a function.');
445
+ }
432
446
  if (!Array.isArray(catalog.methods) || catalog.methods.length > limits.maxMethods) {
433
447
  throw new FlexHarnessValidationError(`Tool catalog methods must be an array with at most ${limits.maxMethods} entries.`);
434
448
  }
@@ -506,7 +520,14 @@ class FlexToolCatalogCore {
506
520
  });
507
521
  }
508
522
  const types = new Map([...registry.entries()].sort(([left], [right]) => compareCodePoints(left, right)));
509
- return new FlexToolCatalogCore(mode, methods, types);
523
+ return new FlexToolCatalogCore(
524
+ mode,
525
+ methods,
526
+ types,
527
+ onProposals === undefined
528
+ ? undefined
529
+ : (proposals, context) => (onProposals as NonNullable<IFlexToolCatalog['onProposals']>).call(catalog, proposals, context),
530
+ );
510
531
  }
511
532
 
512
533
  private static async normalizeType(value: unknown, label: string): Promise<ITypeEntry> {
@@ -590,6 +611,9 @@ class FlexToolCatalogCore {
590
611
  throw new FlexHarnessValidationError(`${methodLabel} declaration must declare "function ${name}(".`);
591
612
  }
592
613
  if (typeof value.run !== 'function') throw new FlexHarnessValidationError(`${methodLabel} run must be a function.`);
614
+ if (value.onCall !== undefined && typeof value.onCall !== 'function') {
615
+ throw new FlexHarnessValidationError(`${methodLabel} onCall must be a function.`);
616
+ }
593
617
  if (value.inputSchema === undefined) throw new FlexHarnessValidationError(`${methodLabel} needs an inputSchema.`);
594
618
  const input = await resolveCatalogSchema(value.inputSchema, `${methodLabel} inputSchema`);
595
619
  const inputJson = input.json;
@@ -604,6 +628,7 @@ class FlexToolCatalogCore {
604
628
  ? undefined
605
629
  : await resolveCatalogSchema(value.outputSchema, `${methodLabel} outputSchema`);
606
630
  const run = value.run as IFlexCatalogMethod['run'];
631
+ const onCall = value.onCall as IFlexCatalogMethod['onCall'];
607
632
  return {
608
633
  path,
609
634
  namespace: segments.slice(0, -1).join('.'),
@@ -616,6 +641,9 @@ class FlexToolCatalogCore {
616
641
  ...(output ? { output } : {}),
617
642
  ...(declaration === undefined ? {} : { declaration }),
618
643
  run: (params, context) => run.call(value, params, context),
644
+ ...(onCall === undefined ? {} : {
645
+ onCall: (params: unknown, context: IFlexCatalogRunContext) => onCall.call(value, params, context),
646
+ }),
619
647
  };
620
648
  }
621
649
 
@@ -791,6 +819,8 @@ export class FlexToolCatalog<TScope = unknown> {
791
819
  private constructor(
792
820
  private readonly core: FlexToolCatalogCore,
793
821
  private readonly admitted: ReadonlySet<string> | undefined,
822
+ /** The limits of `run_code`; absent while code mode is off. */
823
+ public readonly codeMode: Readonly<IFlexCodeModeLimits> | undefined,
794
824
  ) {}
795
825
 
796
826
  /**
@@ -801,10 +831,8 @@ export class FlexToolCatalog<TScope = unknown> {
801
831
  catalog: IFlexToolCatalog<TScope>,
802
832
  options: IFlexToolCatalogOptions = {},
803
833
  ): Promise<FlexToolCatalog<TScope>> {
804
- const mode = options.declarations ?? 'typescript';
805
- if (!describeFormats.has(mode)) {
806
- throw new FlexHarnessValidationError('toolCatalog.declarations must be "typescript" or "json-schema".');
807
- }
834
+ const normalized = normalizeToolCatalogOptions(options);
835
+ const mode = normalized.declarations;
808
836
  if (typeof catalog !== 'object' || catalog === null) {
809
837
  throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
810
838
  }
@@ -818,7 +846,7 @@ export class FlexToolCatalog<TScope = unknown> {
818
846
  core = FlexToolCatalogCore.build(catalog, mode);
819
847
  byMode.set(mode, core);
820
848
  }
821
- return new FlexToolCatalog<TScope>(await core, undefined);
849
+ return new FlexToolCatalog<TScope>(await core, undefined, normalized.codeMode);
822
850
  }
823
851
 
824
852
  /** Every method path this catalog offers, sorted. */
@@ -837,6 +865,7 @@ export class FlexToolCatalog<TScope = unknown> {
837
865
  return new FlexToolCatalog<TScope>(
838
866
  this.core,
839
867
  new Set([...this.core.methods.keys()].filter((path) => allowed.has(path) && this.isAdmitted(path))),
868
+ this.codeMode,
840
869
  );
841
870
  }
842
871
 
@@ -844,7 +873,10 @@ export class FlexToolCatalog<TScope = unknown> {
844
873
  return this.core.methods.has(path) && this.isAdmitted(path);
845
874
  }
846
875
 
847
- /** The three built-in tools as the model sees them. Their text depends only on the catalog. */
876
+ /**
877
+ * The built-in tools as the model sees them: the three catalog tools, and `run_code` in code
878
+ * mode. Their text depends only on the catalog and its options.
879
+ */
848
880
  public toolDefinitions(): Record<string, IFlexCatalogToolDefinition> {
849
881
  const typescript = this.core.mode === 'typescript';
850
882
  const methodPath = { type: 'string', maxLength: limits.maxMethodPathCharacters } as const;
@@ -899,9 +931,42 @@ export class FlexToolCatalog<TScope = unknown> {
899
931
  additionalProperties: false,
900
932
  },
901
933
  },
934
+ ...(this.codeMode ? { [FLEX_CODE_MODE_TOOL_NAME]: this.codeToolDefinition(this.codeMode) } : {}),
902
935
  };
903
936
  }
904
937
 
938
+ private codeToolDefinition(codeMode: Readonly<IFlexCodeModeLimits>): IFlexCatalogToolDefinition {
939
+ return {
940
+ description: [
941
+ 'Run a short TypeScript or JavaScript program against the catalog, for work that takes several calls:',
942
+ 'loops, filters, joins. Call a method as `await api.<dotted path>(params)`, for example',
943
+ '`await api.banking.listMovements({ accountId })`, with the declarations describe_tools returns; a refused',
944
+ 'call throws an `ApiError` whose `details` say why. `return` a JSON value: it and the `console.log` output',
945
+ 'come back, nothing else does. No network, files, timers or imports;',
946
+ `at most ${codeMode.maxCalls} calls and ${Math.round(codeMode.wallMs / 1000)} seconds.`,
947
+ ].join(' '),
948
+ inputSchema: {
949
+ type: 'object',
950
+ properties: { code: { type: 'string', minLength: 1, maxLength: codeMode.maxSourceBytes } },
951
+ required: ['code'],
952
+ additionalProperties: false,
953
+ },
954
+ };
955
+ }
956
+
957
+ /** Hands proposed calls to the catalog's `onProposals` and returns what the model sees for them. */
958
+ public async propose(
959
+ proposals: readonly IFlexCatalogProposal[],
960
+ context: IFlexCatalogProposalContext<TScope>,
961
+ ): Promise<unknown> {
962
+ if (!this.core.onProposals) {
963
+ throw new FlexHarnessValidationError(
964
+ `Tool catalog method "${proposals[0]?.method}" proposed a call, but the catalog has no onProposals.`,
965
+ );
966
+ }
967
+ return await this.core.onProposals(proposals, context as IFlexCatalogProposalContext);
968
+ }
969
+
905
970
  /** Search results or a namespace listing as text. */
906
971
  public search(input: IFlexCatalogSearchInput = {}): string {
907
972
  const limit = input.limit ?? limits.defaultSearchResults;
@@ -1041,6 +1106,19 @@ export class FlexToolCatalog<TScope = unknown> {
1041
1106
  success: true,
1042
1107
  method: path,
1043
1108
  params: validated,
1109
+ decide: async (context) => {
1110
+ const decision = entry.onCall ? await entry.onCall(validated, context) : 'run';
1111
+ if (decision === 'run' || decision === 'propose') return decision;
1112
+ if (
1113
+ isSchemaRecord(decision)
1114
+ && Object.keys(decision).length === 1
1115
+ && typeof decision.deny === 'string'
1116
+ && decision.deny.trim()
1117
+ ) {
1118
+ return { deny: decision.deny.slice(0, 2000) };
1119
+ }
1120
+ throw new FlexHarnessValidationError(`Tool catalog method "${path}" onCall returned an invalid decision.`);
1121
+ },
1044
1122
  run: (context) => entry.run(validated, context),
1045
1123
  };
1046
1124
  }
@@ -1383,14 +1461,17 @@ const constraintText = (subset: Record<string, unknown>): string => {
1383
1461
  };
1384
1462
 
1385
1463
  /**
1386
- * While a run has a catalog, its three tool names are reserved, and a method path must differ from
1387
- * every tool name.
1464
+ * While a run has a catalog, its tool names are reserved (`run_code` in code mode too), and a
1465
+ * method path must differ from every tool name.
1388
1466
  */
1389
1467
  export const assertNoCatalogCollisions = (
1390
1468
  catalog: FlexToolCatalog<unknown>,
1391
1469
  tools: Readonly<Record<string, unknown>>,
1392
1470
  ): void => {
1393
- const catalogToolNames = new Set<string>(Object.values(toolNames));
1471
+ const catalogToolNames = new Set<string>([
1472
+ ...Object.values(toolNames),
1473
+ ...(catalog.codeMode ? [FLEX_CODE_MODE_TOOL_NAME] : []),
1474
+ ]);
1394
1475
  for (const name of catalogToolNames) {
1395
1476
  if (Object.prototype.hasOwnProperty.call(tools, name)) {
1396
1477
  throw new FlexHarnessValidationError(`Tool provider cannot define enabled built-in tool "${name}".`);
package/ts/interfaces.ts CHANGED
@@ -31,6 +31,8 @@ export type TFlexRetryReason = plugins.TAgentRetryReason;
31
31
  * Every call carries `provider` and `requestedModelId`; key usage caps on those two.
32
32
  */
33
33
  export type TFlexModelCallUsage = plugins.TAgentModelCallUsage;
34
+ /** A model call's usage as harness events carry it: a reported call also states its cache hit ratio. */
35
+ export type TFlexObservedModelCallUsage = plugins.TAgentObservedModelCallUsage;
34
36
 
35
37
  export interface IFlexTextPromptPart {
36
38
  type: 'text';
@@ -135,6 +137,8 @@ export interface IFlexToolMessagePart {
135
137
  output?: TJsonValue;
136
138
  error?: string;
137
139
  childSessionId?: string;
140
+ /** For a catalog call a `run_code` program made: the `run_code` call's ID. */
141
+ parentToolCallId?: string;
138
142
  model?: IFlexModelIdentity;
139
143
  /** ISO time the tool call began. Absent on parts recorded before part times existed. */
140
144
  startedAt?: string;
@@ -743,6 +747,11 @@ export type TFlexCatalogCallError =
743
747
  more?: number;
744
748
  /** The lookup that returns the method's declaration. */
745
749
  see: string;
750
+ }
751
+ | {
752
+ error: 'denied';
753
+ method: string;
754
+ reason: string;
746
755
  };
747
756
 
748
757
  export interface IFlexCatalogToolDefinition {
@@ -761,7 +770,10 @@ export type TFlexCatalogSchema = { readonly [keyword: string]: unknown } | plugi
761
770
  export interface IFlexCatalogRunContext<TScope = unknown> {
762
771
  /** The dotted path the model called. */
763
772
  readonly method: string;
764
- /** The `execute` call's ID; pass it with permission requests. */
773
+ /**
774
+ * The call's ID, which its tool part and permission requests carry: the `execute` call's, or for
775
+ * a call inside `run_code` the inner call's, `<run_code call ID>.<n>`.
776
+ */
765
777
  readonly toolCallId: string;
766
778
  readonly abortSignal: AbortSignal;
767
779
  /** The run's tool-provider context: scope, session, run and `requestPermission`. */
@@ -797,6 +809,31 @@ export interface IFlexCatalogMethod<TScope = unknown, TParams = any, TResult = u
797
809
  params: TParams,
798
810
  context: IFlexCatalogRunContext<TScope>,
799
811
  ): TResult | PromiseLike<TResult> | AsyncIterable<TResult>;
812
+ /**
813
+ * Decides each validated call before it runs: `run` (the default without this hook), `propose`
814
+ * to hand it to the catalog's `onProposals` instead of running it, or `{ deny }` to refuse it.
815
+ */
816
+ onCall?(
817
+ params: TParams,
818
+ context: IFlexCatalogRunContext<TScope>,
819
+ ): TFlexCatalogCallDecision | PromiseLike<TFlexCatalogCallDecision>;
820
+ }
821
+
822
+ export type TFlexCatalogCallDecision = 'run' | 'propose' | { deny: string };
823
+
824
+ /** A call a method's `onCall` decided to propose instead of running. */
825
+ export interface IFlexCatalogProposal {
826
+ /** The call's ID: the `execute` call's, or the inner call's inside `run_code`. */
827
+ id: string;
828
+ method: string;
829
+ params: unknown;
830
+ }
831
+
832
+ export interface IFlexCatalogProposalContext<TScope = unknown> {
833
+ /** The `execute` or `run_code` call that made the proposals. */
834
+ readonly toolCallId: string;
835
+ readonly abortSignal: AbortSignal;
836
+ readonly provider: Readonly<IFlexToolProviderContext<TScope>>;
800
837
  }
801
838
 
802
839
  /** A named type that method declarations refer to; it needs a `declaration`, a `schema`, or both. */
@@ -813,6 +850,15 @@ export interface IFlexCatalogType {
813
850
  export interface IFlexToolCatalog<TScope = unknown> {
814
851
  methods: readonly IFlexCatalogMethod<TScope>[];
815
852
  types?: readonly IFlexCatalogType[];
853
+ /**
854
+ * Receives the calls `onCall` proposed, as one batch: the proposed call of an `execute`, or every
855
+ * call a `run_code` program proposed, once the program finished. A failed program proposes
856
+ * nothing. What it returns is what the model sees for them.
857
+ */
858
+ onProposals?(
859
+ proposals: readonly IFlexCatalogProposal[],
860
+ context: IFlexCatalogProposalContext<TScope>,
861
+ ): unknown;
816
862
  }
817
863
 
818
864
  export interface IFlexToolCatalogOptions {
@@ -821,8 +867,79 @@ export interface IFlexToolCatalogOptions {
821
867
  * across the session, or `json-schema` with each method's self-contained JSON Schema.
822
868
  */
823
869
  declarations?: 'typescript' | 'json-schema';
870
+ /**
871
+ * Adds `run_code`, which runs a short TypeScript or JavaScript program against the catalog in a
872
+ * QuickJS sandbox. Needs the optional peers `quickjs-emscripten-core`,
873
+ * `@jitl/quickjs-ng-wasmfile-release-sync` and `amaro`.
874
+ */
875
+ codeMode?: boolean | IFlexCodeModeOptions;
876
+ }
877
+
878
+ export interface IFlexCodeModeOptions {
879
+ limits?: Partial<IFlexCodeModeLimits>;
824
880
  }
825
881
 
882
+ /** The bounds of one `run_code` program. */
883
+ export interface IFlexCodeModeLimits {
884
+ /** UTF-8 bytes of the program. */
885
+ maxSourceBytes: number;
886
+ /** Milliseconds the program may compute; time waiting for calls does not count. */
887
+ cpuMs: number;
888
+ /** Milliseconds the program may take in all, not counting waits for a permission decision. */
889
+ wallMs: number;
890
+ /** Bytes of the sandbox's heap. */
891
+ memoryBytes: number;
892
+ /**
893
+ * Bytes of the sandbox's stack. QuickJS runs on the host's stack as well, so a larger value lets
894
+ * deep recursion reach the host's limit first; the program then fails at its `stack` limit all
895
+ * the same.
896
+ */
897
+ stackBytes: number;
898
+ /** Catalog calls per program. */
899
+ maxCalls: number;
900
+ /** Catalog calls running at once; further calls wait for a slot. */
901
+ maxConcurrentCalls: number;
902
+ /** UTF-8 bytes of console output kept; more is cut off. */
903
+ maxLogBytes: number;
904
+ }
905
+
906
+ export const FLEX_CODE_MODE_TOOL_NAME = 'run_code';
907
+
908
+ export const FLEX_CODE_MODE_DEFAULT_LIMITS: Readonly<IFlexCodeModeLimits> = Object.freeze({
909
+ maxSourceBytes: 32 * 1024,
910
+ cpuMs: 5_000,
911
+ wallMs: 60_000,
912
+ memoryBytes: 64 * 1024 * 1024,
913
+ stackBytes: 64 * 1024,
914
+ maxCalls: 200,
915
+ maxConcurrentCalls: 8,
916
+ maxLogBytes: 16 * 1024,
917
+ });
918
+
919
+ export const FLEX_CODE_MODE_MAXIMUM_LIMITS: Readonly<IFlexCodeModeLimits> = Object.freeze({
920
+ maxSourceBytes: 256 * 1024,
921
+ cpuMs: 60_000,
922
+ wallMs: 30 * 60_000,
923
+ memoryBytes: 512 * 1024 * 1024,
924
+ stackBytes: 256 * 1024,
925
+ maxCalls: 10_000,
926
+ maxConcurrentCalls: 64,
927
+ maxLogBytes: 256 * 1024,
928
+ });
929
+
930
+ /** What `run_code` reports to the model, as JSON, when a program does not finish. */
931
+ export interface IFlexCodeRunFailure {
932
+ error: 'syntax_error' | 'program_error' | 'limit_exceeded' | 'aborted';
933
+ /** The limit that stopped the program, for `limit_exceeded`. */
934
+ limit?: 'source' | 'cpu' | 'wall' | 'memory' | 'stack' | 'calls';
935
+ name?: string;
936
+ message: string;
937
+ /** The program's line, when known. */
938
+ line?: number;
939
+ /** Catalog calls the program made. */
940
+ calls: number;
941
+ logs?: string;
942
+ }
826
943
  export interface IFlexResourceToolProviderResolverContext<TScope> extends IFlexSessionGeneration {
827
944
  scopeId: string;
828
945
  scope: TScope;
@@ -1762,7 +1879,7 @@ export type TFlexModelUsageEvent = IFlexEventBase & {
1762
1879
  readonly type: 'model.usage';
1763
1880
  readonly runId?: string;
1764
1881
  readonly source: 'generation' | 'compaction';
1765
- } & Readonly<TFlexModelCallUsage>;
1882
+ } & Readonly<TFlexObservedModelCallUsage>;
1766
1883
 
1767
1884
  export interface IFlexPromptQueueEvent extends IFlexEventBase {
1768
1885
  readonly type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished';