@modelprofile.com/flexharness 10.15.0 → 10.16.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 (46) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.js +6 -2
  3. package/dist_ts/classes.flexharness.d.ts +33 -0
  4. package/dist_ts/classes.flexharness.js +328 -45
  5. package/dist_ts/classes.stores.d.ts +3 -1
  6. package/dist_ts/classes.stores.js +59 -1
  7. package/dist_ts/classes.toolcatalog.d.ts +9 -1
  8. package/dist_ts/classes.toolcatalog.js +82 -11
  9. package/dist_ts/interfaces.d.ts +59 -0
  10. package/dist_ts/interfaces.js +9 -1
  11. package/dist_ts/plugins.d.ts +3 -3
  12. package/dist_ts/plugins.js +3 -3
  13. package/dist_ts/utils.json.d.ts +5 -0
  14. package/dist_ts/utils.json.js +35 -1
  15. package/dist_ts/utils.results.d.ts +21 -0
  16. package/dist_ts/utils.results.js +158 -0
  17. package/dist_ts/utils.tooloutput.d.ts +5 -2
  18. package/dist_ts/utils.tooloutput.js +10 -3
  19. package/dist_ts_document/index.js +21 -8
  20. package/dist_ts_document/plugins.d.ts +2 -2
  21. package/dist_ts_document/plugins.js +3 -3
  22. package/dist_ts_media/index.js +14 -3
  23. package/dist_ts_media/plugins.d.ts +2 -2
  24. package/dist_ts_media/plugins.js +3 -3
  25. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +2 -1
  26. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +37 -4
  27. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +4 -3
  28. package/dist_ts_stores_nosqldb/classes.recordstore.js +2 -2
  29. package/package.json +3 -3
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/classes.codesandbox.ts +5 -1
  32. package/ts/classes.flexharness.ts +382 -44
  33. package/ts/classes.stores.ts +66 -0
  34. package/ts/classes.toolcatalog.ts +87 -7
  35. package/ts/interfaces.ts +60 -0
  36. package/ts/plugins.ts +3 -0
  37. package/ts/utils.json.ts +30 -0
  38. package/ts/utils.results.ts +164 -0
  39. package/ts/utils.tooloutput.ts +9 -2
  40. package/ts_document/index.ts +18 -7
  41. package/ts_document/plugins.ts +2 -2
  42. package/ts_document/readme.md +2 -0
  43. package/ts_media/index.ts +11 -2
  44. package/ts_media/plugins.ts +2 -2
  45. package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -2
  46. package/ts_stores_nosqldb/classes.recordstore.ts +6 -4
@@ -29,15 +29,19 @@ import {
29
29
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
30
30
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
31
31
  FLEX_CODE_MODE_TOOL_NAME,
32
+ FLEX_RESULT_LIMITS,
33
+ FLEX_RESULT_METHOD,
32
34
  FLEX_SESSION_IMPORT_MAX_DEPTH,
33
35
  FLEX_TOOL_CATALOG_TOOL_NAMES,
36
+ type IFlexResultHandle,
34
37
  } from './interfaces.js';
38
+ import { describeShape, resultPage, selectPath, summarizeResult } from './utils.results.js';
35
39
  import {
36
40
  FlexToolCatalog,
37
41
  assertNoCatalogCollisions,
38
42
  catalogToolInputSchema,
39
43
  } from './classes.toolcatalog.js';
40
- import { FlexCodeSandbox, type IFlexCodeHost } from './classes.codesandbox.js';
44
+ import { FlexCodeSandbox, type IFlexCodeHost, type TFlexCodeCallOutcome } from './classes.codesandbox.js';
41
45
  import type {
42
46
  IFlexAgentContextCompactionOptions,
43
47
  IFlexAgentContextInvocation,
@@ -159,6 +163,7 @@ import type {
159
163
  import { InMemoryFlexHarnessStores } from './classes.stores.js';
160
164
  import {
161
165
  assertJsonSerializable,
166
+ canonicalJson,
162
167
  cloneSerializable,
163
168
  deepFreeze,
164
169
  normalizeJsonValue,
@@ -225,7 +230,7 @@ import {
225
230
  validateSessionImportHandle,
226
231
  type INormalizedSessionImportOptions,
227
232
  } from './utils.sessionimport.js';
228
- import { settleToolOutput, wrapToolSet } from './utils.tooloutput.js';
233
+ import { isAsyncIterable, settleToolOutput, wrapToolSet } from './utils.tooloutput.js';
229
234
  import {
230
235
  validateCompactSessionOptions,
231
236
  validateCreateProjectTaskInput,
@@ -435,6 +440,18 @@ interface IActiveRun {
435
440
  reasoningPartIds: Map<string, string>;
436
441
  toolPartIds: Map<string, string>;
437
442
  trustedToolExecutionErrors: Map<string, Error>;
443
+ /**
444
+ * The confirmations of read-only calls started while the model still streamed, by tool call ID:
445
+ * their permission requests wait for them.
446
+ */
447
+ unconfirmedToolCalls: Map<string, Promise<void>>;
448
+ /**
449
+ * The read-only calls of the turn by real name and canonical params, with the call that answers
450
+ * them. A call that is not read-only clears them when it runs.
451
+ */
452
+ readCalls: Map<string, IRunReadCall>;
453
+ /** Calls an identical earlier read answered: the call's ID to the earlier call's. */
454
+ deduplicatedToolCalls: Map<string, string>;
438
455
  subagentCallCount: number;
439
456
  subagentSessionIds: Set<string>;
440
457
  backgroundSubagents: Set<Promise<void>>;
@@ -450,6 +467,8 @@ interface IActiveRun {
450
467
  toolCatalog?: FlexToolCatalog<unknown>;
451
468
  /** The sandbox `run_code` programs run in, loaded when the catalog has code mode. */
452
469
  codeSandbox?: FlexCodeSandbox;
470
+ /** The tool handle's classifier of read-only calls. */
471
+ isReadOnlyHostCall?: IFlexToolHandle['isReadOnlyToolCall'];
453
472
  subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
454
473
  allowedTools?: readonly string[];
455
474
  agentAllowedTools?: readonly string[];
@@ -462,6 +481,22 @@ interface IActiveRun {
462
481
  stop: AbortController;
463
482
  }
464
483
 
484
+ /** The validated params of a `results.get` call. */
485
+ interface IResultRead {
486
+ handle: string;
487
+ page?: number;
488
+ path?: string;
489
+ }
490
+
491
+ /** A read-only call of a turn and its output, which identical reads after it share. */
492
+ interface IRunReadCall {
493
+ readonly toolCallId: string;
494
+ /** The call's output; `notShared` when it streams its output, which only its own call reads. */
495
+ readonly output: Promise<unknown>;
496
+ }
497
+
498
+ const notShared = Symbol('not shared');
499
+
465
500
  /** The steers of a run: the input its generation takes in, and the prompts it accepted for it. */
466
501
  interface IRunSteering {
467
502
  readonly input: plugins.AgentPendingInputQueue;
@@ -4447,6 +4482,9 @@ export class FlexHarness<TScope = unknown> {
4447
4482
  reasoningPartIds: new Map(),
4448
4483
  toolPartIds: new Map(),
4449
4484
  trustedToolExecutionErrors: new Map(),
4485
+ unconfirmedToolCalls: new Map(),
4486
+ readCalls: new Map(),
4487
+ deduplicatedToolCalls: new Map(),
4450
4488
  subagentCallCount: 0,
4451
4489
  subagentSessionIds: new Set(),
4452
4490
  backgroundSubagents: new Set(),
@@ -5781,8 +5819,14 @@ export class FlexHarness<TScope = unknown> {
5781
5819
  runId: run.runId,
5782
5820
  ...resolverRelationship,
5783
5821
  signal,
5784
- requestPermission: (request: IFlexPermissionRequestInput) =>
5785
- this.requestPermission(run.state, run, request),
5822
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
5823
+ // A call that started early asks once the model's response confirmed it.
5824
+ const confirmed = request.toolCallId === undefined
5825
+ ? undefined
5826
+ : run.unconfirmedToolCalls.get(request.toolCallId);
5827
+ if (confirmed) await confirmed;
5828
+ return this.requestPermission(run.state, run, request);
5829
+ },
5786
5830
  });
5787
5831
  const toolOutcome = Promise.resolve()
5788
5832
  .then(() => this.provideRunToolHandle(run, toolProviderContext))
@@ -5851,10 +5895,13 @@ export class FlexHarness<TScope = unknown> {
5851
5895
  signal.throwIfAborted();
5852
5896
  run.modelResolution = model!;
5853
5897
  run.toolProviderContext = toolProviderContext as Readonly<IFlexToolProviderContext<unknown>>;
5898
+ run.isReadOnlyHostCall = toolHandle?.isReadOnlyToolCall;
5854
5899
  let catalog: FlexToolCatalog<unknown> | undefined;
5855
5900
  if (toolHandle?.catalog) {
5856
5901
  try {
5857
- catalog = await FlexToolCatalog.create(toolHandle.catalog, this.toolCatalogOptions);
5902
+ catalog = await FlexToolCatalog.create(toolHandle.catalog, this.toolCatalogOptions, {
5903
+ results: this.stores.results !== undefined,
5904
+ });
5858
5905
  if (catalog.codeMode) run.codeSandbox = await FlexCodeSandbox.load();
5859
5906
  } catch (error) {
5860
5907
  throw this.projectExternalError(run, error, 'toolProvider');
@@ -5877,17 +5924,18 @@ export class FlexHarness<TScope = unknown> {
5877
5924
  : Object.fromEntries(Object.entries(combinedTools).filter(([name]) => admitted(name)));
5878
5925
  const admittedCatalog = catalog && (globalNames === undefined && agentNames === undefined
5879
5926
  ? catalog
5880
- : catalog.restrict(catalog.methods.filter(admitted)));
5927
+ : catalog.restrict(catalog.methods.filter((name) => name === FLEX_RESULT_METHOD || admitted(name))));
5881
5928
  if (admittedCatalog && admittedCatalog.methods.length > 0) {
5882
5929
  run.toolCatalog = admittedCatalog;
5883
5930
  Object.assign(admittedTools, this.createCatalogTools(run, admittedCatalog));
5884
5931
  }
5885
5932
  tools = Object.keys(admittedTools).length > 0
5886
- ? wrapToolSet(
5933
+ ? this.trackToolCalls(run, wrapToolSet(
5887
5934
  admittedTools as TFlexAgentToolSet,
5888
5935
  this.toolOutputLimits,
5889
5936
  (toolError) => this.projectExternalError(run, toolError, 'toolExecution'),
5890
- )
5937
+ (output) => this.budgetOutput(run, output),
5938
+ ))
5891
5939
  : undefined;
5892
5940
  } catch (error) {
5893
5941
  throw this.projectExternalError(run, error, 'toolProvider');
@@ -5915,7 +5963,11 @@ export class FlexHarness<TScope = unknown> {
5915
5963
  return {
5916
5964
  model: model!.model,
5917
5965
  ...(options.system ?? model!.system ? { system: options.system ?? model!.system } : {}),
5918
- ...(tools ? { tools } : {}),
5966
+ ...(tools ? {
5967
+ tools,
5968
+ isReadOnlyToolCall: ({ toolName, input }: { toolName: string; input: unknown }) =>
5969
+ this.isReadOnlyToolCall(run, toolName, input),
5970
+ } : {}),
5919
5971
  ...(model!.providerOptions ? { providerOptions: model!.providerOptions } : {}),
5920
5972
  ...(model!.cache === undefined ? {} : { cache: model!.cache }),
5921
5973
  ...(options.maxSteps ?? model!.maxSteps ? { maxSteps: options.maxSteps ?? model!.maxSteps } : {}),
@@ -5935,6 +5987,201 @@ export class FlexHarness<TScope = unknown> {
5935
5987
  }
5936
5988
  }
5937
5989
 
5990
+ /**
5991
+ * The output the model gets: the output itself within the output budget, or, in a run with a
5992
+ * catalog whose stores keep results, a handle to the stored output with a summary of it.
5993
+ */
5994
+ private async budgetOutput(run: IActiveRun, output: unknown): Promise<TJsonValue> {
5995
+ const results = this.stores.results;
5996
+ const maxBytes = this.toolOutputLimits.maxBytes;
5997
+ if (!results || !run.toolCatalog) return normalizeJsonValue(output, this.toolOutputLimits);
5998
+ const stored = normalizeJsonValue(output, {
5999
+ ...this.toolOutputLimits,
6000
+ maxBytes: Math.max(maxBytes, FLEX_RESULT_LIMITS.maxStoredBytes),
6001
+ });
6002
+ const bytes = jsonBytes(stored);
6003
+ if (bytes <= maxBytes) return stored;
6004
+ const handle = `res_${plugins.crypto.randomBytes(8).toString('hex')}`;
6005
+ const read = `Too large to return whole: read it page by page with execute({ method: "${FLEX_RESULT_METHOD}", `
6006
+ + `params: { handle: "${handle}", page: 1 } })${run.toolCatalog.codeMode
6007
+ ? `, or in run_code with await api.results.get("${handle}", { page: 1 })`
6008
+ : ''}; a dotted \`path\` reads a part of it.`;
6009
+ const result: IFlexResultHandle = {
6010
+ $flexType: 'result',
6011
+ handle,
6012
+ bytes,
6013
+ pages: resultPage(stored, '', 1, this.resultPageBytes())!.pages,
6014
+ summary: summarizeResult(stored, Math.min(FLEX_RESULT_LIMITS.summaryBytes, Math.floor(maxBytes / 4))),
6015
+ read,
6016
+ };
6017
+ // A budget too small for even the handle keeps the truncated output.
6018
+ if (jsonBytes(result) > maxBytes) return normalizeJsonValue(output, this.toolOutputLimits);
6019
+ await results.save(run.state.storageKey, run.sessionId, handle, stored);
6020
+ return result as unknown as TJsonValue;
6021
+ }
6022
+
6023
+ /** The JSON bytes of one `results.get` page: the output budget, less room for the page's fields. */
6024
+ private resultPageBytes(): number {
6025
+ return Math.max(64, this.toolOutputLimits.maxBytes - 512);
6026
+ }
6027
+
6028
+ /** One page of a stored result of the run's session, or the error `results.get` reports. */
6029
+ private async readResult(
6030
+ run: IActiveRun,
6031
+ params: IResultRead,
6032
+ ): Promise<{ ok: true; value: TJsonValue } | { ok: false; error: TJsonValue }> {
6033
+ const method = FLEX_RESULT_METHOD;
6034
+ const stored = await this.stores.results!.load(run.state.storageKey, run.sessionId, params.handle);
6035
+ if (stored === undefined) {
6036
+ return { ok: false, error: { error: 'unknown_handle', method, handle: params.handle, hint: 'A handle reads a result this session stored.' } };
6037
+ }
6038
+ const path = params.path ?? '';
6039
+ const value = selectPath(stored, path);
6040
+ if (value === undefined) {
6041
+ return { ok: false, error: { error: 'unknown_path', method, path, hint: `The result is ${describeShape(stored)}.` } };
6042
+ }
6043
+ const page = params.page ?? 1;
6044
+ const content = resultPage(value, path, page, this.resultPageBytes());
6045
+ if (!content) {
6046
+ const pages = resultPage(value, path, 1, this.resultPageBytes())!.pages;
6047
+ return { ok: false, error: { error: 'unknown_page', method, page, pages } };
6048
+ }
6049
+ return {
6050
+ ok: true,
6051
+ value: { handle: params.handle, path, page, pages: content.pages, ...content.range, value: content.value },
6052
+ };
6053
+ }
6054
+
6055
+ /**
6056
+ * Whether a call of the run's tools only reads: search_tools and describe_tools, an `execute` of
6057
+ * a method declared read-only, or a call the tool handle classifies as read-only by its real name.
6058
+ */
6059
+ private isReadOnlyToolCall(run: IActiveRun, toolName: string, input: unknown): boolean {
6060
+ const names = FLEX_TOOL_CATALOG_TOOL_NAMES;
6061
+ const catalog = run.toolCatalog;
6062
+ if (catalog) {
6063
+ if (toolName === names.search || toolName === names.describe) return true;
6064
+ if (toolName === names.execute) {
6065
+ const target = catalog.callTarget(input);
6066
+ return target !== undefined && this.isReadOnlyCatalogCall(run, catalog, target.method, target.params);
6067
+ }
6068
+ if (toolName === FLEX_CODE_MODE_TOOL_NAME) return false;
6069
+ }
6070
+ if (this.enabledBuiltInToolNames(run).has(toolName)) return false;
6071
+ return this.classifyHostToolCall(run, toolName, input);
6072
+ }
6073
+
6074
+ /** The method's path when the prepared call only reads, for the turn's reads. */
6075
+ private readOnlyMethod(
6076
+ run: IActiveRun,
6077
+ catalog: FlexToolCatalog<unknown>,
6078
+ call: { method: string; params: unknown },
6079
+ ): string | undefined {
6080
+ return this.isReadOnlyCatalogCall(run, catalog, call.method, call.params) ? call.method : undefined;
6081
+ }
6082
+
6083
+ /** A method whose `onCall` decides its calls is gated: its calls run in order, whatever they read. */
6084
+ private isReadOnlyCatalogCall(
6085
+ run: IActiveRun,
6086
+ catalog: FlexToolCatalog<unknown>,
6087
+ method: string,
6088
+ params: unknown,
6089
+ ): boolean {
6090
+ return !catalog.decides(method)
6091
+ && (catalog.isReadOnly(method) || this.classifyHostToolCall(run, method, params));
6092
+ }
6093
+
6094
+ private classifyHostToolCall(run: IActiveRun, toolName: string, input: unknown): boolean {
6095
+ try {
6096
+ return run.isReadOnlyHostCall?.({ toolName, input }) === true;
6097
+ } catch (error) {
6098
+ throw this.projectExternalError(run, error, 'toolProvider');
6099
+ }
6100
+ }
6101
+
6102
+ /**
6103
+ * Runs the run's tools through the turn's reads: a read-only call identical to an earlier read of
6104
+ * the turn shares its output, and any other call clears them. Records the confirmation of each
6105
+ * call that started while the model still streamed, which the permission requests naming the
6106
+ * call wait for. The catalog's tools read and run their methods by real name themselves.
6107
+ */
6108
+ private trackToolCalls(run: IActiveRun, tools: TFlexAgentToolSet): TFlexAgentToolSet {
6109
+ const catalogTools = new Set<string>(run.toolCatalog
6110
+ ? [...Object.values(FLEX_TOOL_CATALOG_TOOL_NAMES), FLEX_CODE_MODE_TOOL_NAME]
6111
+ : []);
6112
+ const tracked: Record<string, unknown> = {};
6113
+ for (const [name, tool] of Object.entries(tools)) {
6114
+ const execute = (tool as { execute?: (input: unknown, options: unknown) => unknown }).execute;
6115
+ if (typeof execute !== 'function') {
6116
+ tracked[name] = tool;
6117
+ continue;
6118
+ }
6119
+ tracked[name] = {
6120
+ ...tool,
6121
+ execute: (input: unknown, options: plugins.IAgentToolExecutionOptions & { toolCallId?: string }) => {
6122
+ const confirmed = options?.confirmed;
6123
+ const toolCallId = options?.toolCallId ?? '';
6124
+ if (confirmed && toolCallId) {
6125
+ run.unconfirmedToolCalls.set(toolCallId, confirmed);
6126
+ const forget = () => run.unconfirmedToolCalls.delete(toolCallId);
6127
+ confirmed.then(forget, forget);
6128
+ }
6129
+ const executeTool = () => execute.call(tool, input, options);
6130
+ if (catalogTools.has(name)) return executeTool();
6131
+ const readName = this.isReadOnlyToolCall(run, name, input) ? name : undefined;
6132
+ return this.readOnce(run, toolCallId, readName, input, confirmed, executeTool);
6133
+ },
6134
+ };
6135
+ }
6136
+ return tracked as TFlexAgentToolSet;
6137
+ }
6138
+
6139
+ /**
6140
+ * Answers a read-only call (`name` set) from an identical read earlier in the turn, sharing the
6141
+ * output of one still running, or runs it and keeps its output for the reads after it. A failed
6142
+ * read, and a read whose response was discarded, are not kept. Any other call clears the turn's
6143
+ * reads before it runs.
6144
+ */
6145
+ private readOnce(
6146
+ run: IActiveRun,
6147
+ toolCallId: string,
6148
+ name: string | undefined,
6149
+ params: unknown,
6150
+ confirmed: Promise<void> | undefined,
6151
+ execute: () => unknown,
6152
+ ): unknown {
6153
+ if (name === undefined) {
6154
+ run.readCalls.clear();
6155
+ return execute();
6156
+ }
6157
+ const canonicalParams = canonicalJson(params);
6158
+ if (canonicalParams === undefined) return execute();
6159
+ const key = `${name}\n${canonicalParams}`;
6160
+ const earlier = run.readCalls.get(key);
6161
+ if (earlier) {
6162
+ return earlier.output.then((output) => {
6163
+ if (output === notShared) return execute();
6164
+ run.deduplicatedToolCalls.set(toolCallId, earlier.toolCallId);
6165
+ return output;
6166
+ }, (error: unknown) => {
6167
+ run.deduplicatedToolCalls.set(toolCallId, earlier.toolCallId);
6168
+ throw error;
6169
+ });
6170
+ }
6171
+ const result = execute();
6172
+ const entry: IRunReadCall = {
6173
+ toolCallId,
6174
+ output: Promise.resolve(result).then((output) => isAsyncIterable(output) ? notShared : output),
6175
+ };
6176
+ run.readCalls.set(key, entry);
6177
+ const forget = () => {
6178
+ if (run.readCalls.get(key) === entry) run.readCalls.delete(key);
6179
+ };
6180
+ entry.output.catch(forget);
6181
+ confirmed?.catch(forget);
6182
+ return result;
6183
+ }
6184
+
5938
6185
  private enabledBuiltInToolNames(run: IActiveRun): Set<string> {
5939
6186
  const names = new Set<string>();
5940
6187
  if (run.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
@@ -5995,15 +6242,35 @@ export class FlexHarness<TScope = unknown> {
5995
6242
  ),
5996
6243
  execute: async (
5997
6244
  input: { method: string; params?: Record<string, unknown> },
5998
- options?: { toolCallId?: string; abortSignal?: AbortSignal },
6245
+ options?: { toolCallId?: string; abortSignal?: AbortSignal } & plugins.IAgentToolExecutionOptions,
5999
6246
  ) => {
6000
6247
  const toolCallId = options?.toolCallId ?? '';
6001
6248
  const abortSignal = options?.abortSignal ?? run.controller.signal;
6249
+ const confirmed = options?.confirmed;
6002
6250
  const call = await catalog.prepareCall(input.method, input.params);
6003
6251
  if (!call.success) throw this.catalogCallError(run, toolCallId, call.error);
6004
- const context = { method: call.method, toolCallId, abortSignal, provider: run.toolProviderContext! };
6252
+ if (call.method === FLEX_RESULT_METHOD) {
6253
+ const read = await this.readResult(run, call.params as IResultRead);
6254
+ if (!read.ok) throw this.catalogCallError(run, toolCallId, read.error);
6255
+ return read.value;
6256
+ }
6257
+ // A call that started early asks for permission once the response confirmed it.
6258
+ const base = run.toolProviderContext!;
6259
+ const provider = confirmed
6260
+ ? Object.freeze({
6261
+ ...base,
6262
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
6263
+ await confirmed;
6264
+ return base.requestPermission(request);
6265
+ },
6266
+ })
6267
+ : base;
6268
+ const context = { method: call.method, toolCallId, abortSignal, provider };
6005
6269
  const decision = await call.decide(context);
6006
- if (decision === 'run') return call.run(context);
6270
+ if (decision === 'run') {
6271
+ return this.readOnce(run, toolCallId, this.readOnlyMethod(run, catalog, call), call.params, confirmed,
6272
+ () => call.run(context));
6273
+ }
6007
6274
  if (decision === 'propose') {
6008
6275
  return catalog.propose(
6009
6276
  [{ id: toolCallId, method: call.method, params: call.params }],
@@ -6075,51 +6342,97 @@ export class FlexHarness<TScope = unknown> {
6075
6342
  });
6076
6343
  const stopped = (method: string) => ({ ok: false as const, error: { error: 'aborted', method } });
6077
6344
  let callNumber = 0;
6345
+ // Read-only calls run concurrently; any other call runs after every call the program made before it.
6346
+ const order = new plugins.AgentToolCallScheduler(abortSignal);
6078
6347
  const finish = (id: string, method: string, input: unknown, outcome: { output: unknown } | { error: Error }) => {
6079
6348
  if ('error' in outcome) run.trustedToolExecutionErrors.set(id, outcome.error);
6080
6349
  this.onToolFinish(run.state, run.sessionId, 'error' in outcome
6081
6350
  ? { toolCallId: id, toolName: method, input, success: false, error: outcome.error.message }
6082
6351
  : { toolCallId: id, toolName: method, input, success: true, output: outcome.output });
6083
6352
  };
6353
+ const callInTurn = async (
6354
+ id: string,
6355
+ method: string,
6356
+ params: unknown,
6357
+ signal: AbortSignal,
6358
+ classification: { readOnly: boolean } | { error: Error },
6359
+ ): Promise<TFlexCodeCallOutcome> => {
6360
+ const call = await catalog.prepareCall(method, params);
6361
+ if (!call.success) {
6362
+ if (catalog.has(method)) {
6363
+ this.onToolStart(run.state, run.sessionId, { toolCallId: id, toolName: method, input: params ?? {}, parentToolCallId: toolCallId });
6364
+ finish(id, method, params, { error: this.catalogCallError(run, id, call.error) });
6365
+ }
6366
+ return { ok: false, error: call.error as unknown as TJsonValue };
6367
+ }
6368
+ try {
6369
+ await order.waitForTurn(id, signal);
6370
+ } catch {
6371
+ return stopped(call.method);
6372
+ }
6373
+ if (signal.aborted) return stopped(call.method);
6374
+ this.onToolStart(run.state, run.sessionId, {
6375
+ toolCallId: id, toolName: call.method, input: call.params, parentToolCallId: toolCallId,
6376
+ });
6377
+ if ('error' in classification) {
6378
+ finish(id, call.method, call.params, { error: classification.error });
6379
+ return { ok: false, error: { error: 'failed', method: call.method, message: classification.error.message } };
6380
+ }
6381
+ if (call.method === FLEX_RESULT_METHOD) {
6382
+ const read = await this.readResult(run, call.params as IResultRead);
6383
+ finish(id, call.method, call.params, read.ok
6384
+ ? { output: read.value }
6385
+ : { error: this.catalogCallError(run, id, read.error) });
6386
+ return read;
6387
+ }
6388
+ const readOnly = classification.readOnly;
6389
+ const context = { method: call.method, toolCallId: id, abortSignal: signal, provider: providerFor(signal) };
6390
+ try {
6391
+ const decision = await call.decide(context);
6392
+ signal.throwIfAborted();
6393
+ if (decision === 'propose') {
6394
+ proposals.push({ id, method: call.method, params: call.params });
6395
+ const value = { proposed: true, id };
6396
+ finish(id, call.method, call.params, { output: value });
6397
+ return { ok: true, value };
6398
+ }
6399
+ if (decision !== 'run') {
6400
+ const payload = { error: 'denied' as const, method: call.method, reason: decision.deny };
6401
+ finish(id, call.method, call.params, { error: this.catalogCallError(run, id, payload) });
6402
+ return { ok: false, error: payload };
6403
+ }
6404
+ const output = this.readOnce(run, id, readOnly ? call.method : undefined, call.params, undefined,
6405
+ () => call.run(context));
6406
+ const value = await this.budgetOutput(run, await settleToolOutput(output));
6407
+ finish(id, call.method, call.params, { output: value });
6408
+ return { ok: true, value };
6409
+ } catch (error) {
6410
+ const projected = this.projectExternalError(run, error, 'toolExecution');
6411
+ finish(id, call.method, call.params, { error: projected });
6412
+ return { ok: false, error: { error: 'failed', method: call.method, message: projected.message } };
6413
+ }
6414
+ };
6084
6415
  const host: IFlexCodeHost = {
6085
6416
  waitedMs: () => waitedMs + (waiting > 0 ? performance.now() - waitingSince : 0),
6086
6417
  call: async (method, params, signal) => {
6087
6418
  if (signal.aborted) return stopped(method);
6088
6419
  const id = `${toolCallId}.${++callNumber}`;
6089
- const call = await catalog.prepareCall(method, params);
6090
- if (!call.success) {
6091
- if (catalog.has(method)) {
6092
- this.onToolStart(run.state, run.sessionId, { toolCallId: id, toolName: method, input: params ?? {}, parentToolCallId: toolCallId });
6093
- finish(id, method, params, { error: this.catalogCallError(run, id, call.error) });
6094
- }
6095
- return { ok: false, error: call.error as unknown as TJsonValue };
6096
- }
6097
- if (signal.aborted) return stopped(call.method);
6098
- this.onToolStart(run.state, run.sessionId, {
6099
- toolCallId: id, toolName: call.method, input: call.params, parentToolCallId: toolCallId,
6100
- });
6101
- const context = { method: call.method, toolCallId: id, abortSignal: signal, provider: providerFor(signal) };
6420
+ // The call takes its place in the program's order now, before anything is awaited.
6421
+ let classification: { readOnly: boolean } | { error: Error };
6102
6422
  try {
6103
- const decision = await call.decide(context);
6104
- signal.throwIfAborted();
6105
- if (decision === 'propose') {
6106
- proposals.push({ id, method: call.method, params: call.params });
6107
- const value = { proposed: true, id };
6108
- finish(id, call.method, call.params, { output: value });
6109
- return { ok: true, value };
6110
- }
6111
- if (decision !== 'run') {
6112
- const payload = { error: 'denied' as const, method: call.method, reason: decision.deny };
6113
- finish(id, call.method, call.params, { error: this.catalogCallError(run, id, payload) });
6114
- return { ok: false, error: payload };
6115
- }
6116
- const value = normalizeJsonValue(await settleToolOutput(call.run(context)), this.toolOutputLimits);
6117
- finish(id, call.method, call.params, { output: value });
6118
- return { ok: true, value };
6423
+ classification = {
6424
+ readOnly: catalog.has(method) && this.isReadOnlyCatalogCall(run, catalog, method, params ?? {}),
6425
+ };
6119
6426
  } catch (error) {
6120
- const projected = this.projectExternalError(run, error, 'toolExecution');
6121
- finish(id, call.method, call.params, { error: projected });
6122
- return { ok: false, error: { error: 'failed', method: call.method, message: projected.message } };
6427
+ classification = {
6428
+ error: error instanceof Error ? error : this.projectExternalError(run, error, 'toolProvider'),
6429
+ };
6430
+ }
6431
+ order.begin(id, 'readOnly' in classification && classification.readOnly);
6432
+ try {
6433
+ return await callInTurn(id, method, params, signal, classification);
6434
+ } finally {
6435
+ order.settle(id);
6123
6436
  }
6124
6437
  },
6125
6438
  };
@@ -8698,6 +9011,10 @@ export class FlexHarness<TScope = unknown> {
8698
9011
  delete part.output;
8699
9012
  part.error = projectedError!.message;
8700
9013
  }
9014
+ const original = run.deduplicatedToolCalls.get(event.toolCallId);
9015
+ if (original !== undefined) {
9016
+ part.deduplicatedFrom = this.boundedIdentifier(original, 'deduplicatedFrom');
9017
+ }
8701
9018
  part.completedAt = new Date().toISOString();
8702
9019
  this.emitPartEvent(run, 'part.completed', part);
8703
9020
  } catch (error) {
@@ -9362,10 +9679,17 @@ export class FlexHarness<TScope = unknown> {
9362
9679
  if (catalog !== undefined && (typeof catalog !== 'object' || catalog === null)) {
9363
9680
  throw new FlexHarnessValidationError('Tool handle catalog must be an object.');
9364
9681
  }
9682
+ const isReadOnlyToolCall = handle.isReadOnlyToolCall;
9683
+ if (isReadOnlyToolCall !== undefined && typeof isReadOnlyToolCall !== 'function') {
9684
+ throw new FlexHarnessValidationError('Tool handle isReadOnlyToolCall must be a function.');
9685
+ }
9365
9686
  const closeHandle = close;
9366
9687
  return {
9367
9688
  tools,
9368
9689
  ...(catalog === undefined ? {} : { catalog }),
9690
+ ...(isReadOnlyToolCall === undefined ? {} : {
9691
+ isReadOnlyToolCall: (call: { toolName: string; input: unknown }) => isReadOnlyToolCall.call(handle, call),
9692
+ }),
9369
9693
  ...(closeHandle ? { close: () => closeHandle.call(handle) } : {}),
9370
9694
  };
9371
9695
  } catch (error) {
@@ -9394,6 +9718,8 @@ export class FlexHarness<TScope = unknown> {
9394
9718
  const descriptors = this.normalizeResourceToolProviderDescriptors(resolved);
9395
9719
  const owned: Array<{ handle: IFlexToolHandle; closed: boolean }> = [];
9396
9720
  const tools: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
9721
+ /** The handle and own name of each exposed resource tool, for the handle's classifier. */
9722
+ const resourceTools = new Map<string, { handle: IFlexToolHandle; toolName: string }>();
9397
9723
  const closeOwned = async (): Promise<void> => {
9398
9724
  const errors: unknown[] = [];
9399
9725
  for (const entry of [...owned].reverse()) {
@@ -9461,12 +9787,23 @@ export class FlexHarness<TScope = unknown> {
9461
9787
  throw new FlexHarnessValidationError(`Duplicate exposed tool "${exposedName}".`);
9462
9788
  }
9463
9789
  tools[exposedName] = tool;
9790
+ resourceTools.set(exposedName, { handle, toolName });
9464
9791
  }
9465
9792
  }
9466
9793
  if (owned.length === 0 && Object.keys(tools).length === 0) return undefined;
9794
+ const classifies = applicationHandle?.isReadOnlyToolCall !== undefined
9795
+ || [...resourceTools.values()].some((entry) => entry.handle.isReadOnlyToolCall !== undefined);
9467
9796
  return {
9468
9797
  tools: tools as TFlexAgentToolSet,
9469
9798
  ...(applicationHandle?.catalog === undefined ? {} : { catalog: applicationHandle.catalog }),
9799
+ ...(classifies ? {
9800
+ isReadOnlyToolCall: ({ toolName, input }: { toolName: string; input: unknown }) => {
9801
+ const resource = resourceTools.get(toolName);
9802
+ return resource
9803
+ ? resource.handle.isReadOnlyToolCall?.({ toolName: resource.toolName, input }) === true
9804
+ : applicationHandle?.isReadOnlyToolCall?.({ toolName, input }) === true;
9805
+ },
9806
+ } : {}),
9470
9807
  ...(owned.some((entry) => entry.handle.close) ? { close: closeOwned } : {}),
9471
9808
  };
9472
9809
  } catch (error) {
@@ -10911,6 +11248,7 @@ export class FlexHarness<TScope = unknown> {
10911
11248
  this.stores.jobs.deleteSession(storageKey, sessionId),
10912
11249
  this.stores.projections.deleteSession(storageKey, sessionId),
10913
11250
  this.stores.permissions.deleteSession(storageKey, sessionId),
11251
+ ...(this.stores.results ? [this.stores.results.deleteSession(storageKey, sessionId)] : []),
10914
11252
  // An imported session refuses every project-management write, so it has no state to fence.
10915
11253
  ...(tombstone.imported ? [] : [projectManagementCleanup()]),
10916
11254
  ]);