@modelprofile.com/flexharness 10.15.1 → 10.17.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 (54) 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 +36 -0
  4. package/dist_ts/classes.flexharness.js +360 -56
  5. package/dist_ts/classes.stores.d.ts +3 -1
  6. package/dist_ts/classes.stores.js +92 -2
  7. package/dist_ts/classes.toolcatalog.d.ts +18 -1
  8. package/dist_ts/classes.toolcatalog.js +179 -18
  9. package/dist_ts/interfaces.d.ts +67 -0
  10. package/dist_ts/interfaces.js +12 -2
  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.storerules.d.ts +5 -0
  18. package/dist_ts/utils.storerules.js +10 -1
  19. package/dist_ts/utils.tooloutput.d.ts +5 -2
  20. package/dist_ts/utils.tooloutput.js +10 -3
  21. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +2 -1
  22. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +63 -5
  23. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +6 -3
  24. package/dist_ts_stores_nosqldb/classes.recordstore.js +8 -2
  25. package/dist_ts_typedrequest/catalog.d.ts +28 -0
  26. package/dist_ts_typedrequest/catalog.js +79 -0
  27. package/dist_ts_typedrequest/describe.d.ts +10 -0
  28. package/dist_ts_typedrequest/describe.js +189 -0
  29. package/dist_ts_typedrequest/index.d.ts +3 -0
  30. package/dist_ts_typedrequest/index.js +4 -0
  31. package/dist_ts_typedrequest/interfaces.d.ts +107 -0
  32. package/dist_ts_typedrequest/interfaces.js +2 -0
  33. package/dist_ts_typedrequest/plugins.d.ts +7 -0
  34. package/dist_ts_typedrequest/plugins.js +11 -0
  35. package/package.json +14 -4
  36. package/ts/00_commitinfo_data.ts +1 -1
  37. package/ts/classes.codesandbox.ts +5 -1
  38. package/ts/classes.flexharness.ts +419 -55
  39. package/ts/classes.stores.ts +109 -0
  40. package/ts/classes.toolcatalog.ts +177 -14
  41. package/ts/interfaces.ts +69 -1
  42. package/ts/plugins.ts +3 -0
  43. package/ts/utils.json.ts +30 -0
  44. package/ts/utils.results.ts +164 -0
  45. package/ts/utils.storerules.ts +15 -0
  46. package/ts/utils.tooloutput.ts +9 -2
  47. package/ts_stores_nosqldb/classes.nosqldbstores.ts +68 -2
  48. package/ts_stores_nosqldb/classes.recordstore.ts +14 -4
  49. package/ts_typedrequest/catalog.ts +91 -0
  50. package/ts_typedrequest/describe.ts +211 -0
  51. package/ts_typedrequest/index.ts +3 -0
  52. package/ts_typedrequest/interfaces.ts +124 -0
  53. package/ts_typedrequest/plugins.ts +15 -0
  54. package/ts_typedrequest/readme.md +66 -0
@@ -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,38 @@ 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
+
500
+ /** Runs `operation` after the operations queued before it under `key`, whatever their outcome. */
501
+ function queueOperation<TResult>(
502
+ queues: Map<string, Promise<void>>,
503
+ key: string,
504
+ operation: () => Promise<TResult>,
505
+ ): Promise<TResult> {
506
+ const previous = queues.get(key) ?? Promise.resolve();
507
+ const result = previous.then(operation);
508
+ const barrier = result.then(() => undefined, () => undefined);
509
+ queues.set(key, barrier);
510
+ void barrier.then(() => {
511
+ if (queues.get(key) === barrier) queues.delete(key);
512
+ });
513
+ return result;
514
+ }
515
+
465
516
  /** The steers of a run: the input its generation takes in, and the prompts it accepted for it. */
466
517
  interface IRunSteering {
467
518
  readonly input: plugins.AgentPendingInputQueue;
@@ -893,6 +944,8 @@ export class FlexHarness<TScope = unknown> {
893
944
  private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
894
945
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
895
946
  private readonly projectManagementQueues = new Map<string, Promise<void>>();
947
+ /** A session's result writes, one at a time: they share the session's index of results. */
948
+ private readonly resultQueues = new Map<string, Promise<void>>();
896
949
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
897
950
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
898
951
  private readonly stagedSessionImports = new Map<string, IStagedSessionImport>();
@@ -4447,6 +4500,9 @@ export class FlexHarness<TScope = unknown> {
4447
4500
  reasoningPartIds: new Map(),
4448
4501
  toolPartIds: new Map(),
4449
4502
  trustedToolExecutionErrors: new Map(),
4503
+ unconfirmedToolCalls: new Map(),
4504
+ readCalls: new Map(),
4505
+ deduplicatedToolCalls: new Map(),
4450
4506
  subagentCallCount: 0,
4451
4507
  subagentSessionIds: new Set(),
4452
4508
  backgroundSubagents: new Set(),
@@ -5280,17 +5336,7 @@ export class FlexHarness<TScope = unknown> {
5280
5336
  sessionId: string,
5281
5337
  operation: () => Promise<TResult>,
5282
5338
  ): Promise<TResult> {
5283
- const key = this.projectManagementKey(storageKey, sessionId);
5284
- const previous = this.projectManagementQueues.get(key) ?? Promise.resolve();
5285
- const result = previous.then(operation);
5286
- const barrier = result.then(() => undefined, () => undefined);
5287
- this.projectManagementQueues.set(key, barrier);
5288
- void barrier.then(() => {
5289
- if (this.projectManagementQueues.get(key) === barrier) {
5290
- this.projectManagementQueues.delete(key);
5291
- }
5292
- });
5293
- return result;
5339
+ return queueOperation(this.projectManagementQueues, this.projectManagementKey(storageKey, sessionId), operation);
5294
5340
  }
5295
5341
 
5296
5342
  private async awaitProjectManagementOperations(
@@ -5781,8 +5827,14 @@ export class FlexHarness<TScope = unknown> {
5781
5827
  runId: run.runId,
5782
5828
  ...resolverRelationship,
5783
5829
  signal,
5784
- requestPermission: (request: IFlexPermissionRequestInput) =>
5785
- this.requestPermission(run.state, run, request),
5830
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
5831
+ // A call that started early asks once the model's response confirmed it.
5832
+ const confirmed = request.toolCallId === undefined
5833
+ ? undefined
5834
+ : run.unconfirmedToolCalls.get(request.toolCallId);
5835
+ if (confirmed) await confirmed;
5836
+ return this.requestPermission(run.state, run, request);
5837
+ },
5786
5838
  });
5787
5839
  const toolOutcome = Promise.resolve()
5788
5840
  .then(() => this.provideRunToolHandle(run, toolProviderContext))
@@ -5851,10 +5903,13 @@ export class FlexHarness<TScope = unknown> {
5851
5903
  signal.throwIfAborted();
5852
5904
  run.modelResolution = model!;
5853
5905
  run.toolProviderContext = toolProviderContext as Readonly<IFlexToolProviderContext<unknown>>;
5906
+ run.isReadOnlyHostCall = toolHandle?.isReadOnlyToolCall;
5854
5907
  let catalog: FlexToolCatalog<unknown> | undefined;
5855
5908
  if (toolHandle?.catalog) {
5856
5909
  try {
5857
- catalog = await FlexToolCatalog.create(toolHandle.catalog, this.toolCatalogOptions);
5910
+ catalog = await FlexToolCatalog.create(toolHandle.catalog, this.toolCatalogOptions, {
5911
+ results: this.stores.results !== undefined,
5912
+ });
5858
5913
  if (catalog.codeMode) run.codeSandbox = await FlexCodeSandbox.load();
5859
5914
  } catch (error) {
5860
5915
  throw this.projectExternalError(run, error, 'toolProvider');
@@ -5877,17 +5932,18 @@ export class FlexHarness<TScope = unknown> {
5877
5932
  : Object.fromEntries(Object.entries(combinedTools).filter(([name]) => admitted(name)));
5878
5933
  const admittedCatalog = catalog && (globalNames === undefined && agentNames === undefined
5879
5934
  ? catalog
5880
- : catalog.restrict(catalog.methods.filter(admitted)));
5935
+ : catalog.restrict(catalog.methods.filter((name) => name === FLEX_RESULT_METHOD || admitted(name))));
5881
5936
  if (admittedCatalog && admittedCatalog.methods.length > 0) {
5882
5937
  run.toolCatalog = admittedCatalog;
5883
5938
  Object.assign(admittedTools, this.createCatalogTools(run, admittedCatalog));
5884
5939
  }
5885
5940
  tools = Object.keys(admittedTools).length > 0
5886
- ? wrapToolSet(
5941
+ ? this.trackToolCalls(run, wrapToolSet(
5887
5942
  admittedTools as TFlexAgentToolSet,
5888
5943
  this.toolOutputLimits,
5889
5944
  (toolError) => this.projectExternalError(run, toolError, 'toolExecution'),
5890
- )
5945
+ (output) => this.budgetOutput(run, output),
5946
+ ))
5891
5947
  : undefined;
5892
5948
  } catch (error) {
5893
5949
  throw this.projectExternalError(run, error, 'toolProvider');
@@ -5915,7 +5971,11 @@ export class FlexHarness<TScope = unknown> {
5915
5971
  return {
5916
5972
  model: model!.model,
5917
5973
  ...(options.system ?? model!.system ? { system: options.system ?? model!.system } : {}),
5918
- ...(tools ? { tools } : {}),
5974
+ ...(tools ? {
5975
+ tools,
5976
+ isReadOnlyToolCall: ({ toolName, input }: { toolName: string; input: unknown }) =>
5977
+ this.isReadOnlyToolCall(run, toolName, input),
5978
+ } : {}),
5919
5979
  ...(model!.providerOptions ? { providerOptions: model!.providerOptions } : {}),
5920
5980
  ...(model!.cache === undefined ? {} : { cache: model!.cache }),
5921
5981
  ...(options.maxSteps ?? model!.maxSteps ? { maxSteps: options.maxSteps ?? model!.maxSteps } : {}),
@@ -5935,6 +5995,213 @@ export class FlexHarness<TScope = unknown> {
5935
5995
  }
5936
5996
  }
5937
5997
 
5998
+ /**
5999
+ * The output the model gets: the output itself within the output budget, or, in a run with a
6000
+ * catalog whose stores keep results, a handle to the stored output with a summary of it.
6001
+ */
6002
+ private async budgetOutput(run: IActiveRun, output: unknown): Promise<TJsonValue> {
6003
+ const results = this.stores.results;
6004
+ const maxBytes = this.toolOutputLimits.maxBytes;
6005
+ if (!results || !run.toolCatalog) return normalizeJsonValue(output, this.toolOutputLimits);
6006
+ const stored = normalizeJsonValue(output, {
6007
+ ...this.toolOutputLimits,
6008
+ maxBytes: Math.max(maxBytes, FLEX_RESULT_LIMITS.maxStoredBytes),
6009
+ });
6010
+ const bytes = jsonBytes(stored);
6011
+ if (bytes <= maxBytes) return stored;
6012
+ const handle = `res_${plugins.crypto.randomBytes(8).toString('hex')}`;
6013
+ const read = `Too large to return whole: read it page by page with execute({ method: "${FLEX_RESULT_METHOD}", `
6014
+ + `params: { handle: "${handle}", page: 1 } })${run.toolCatalog.codeMode
6015
+ ? `, or in run_code with await api.results.get("${handle}", { page: 1 })`
6016
+ : ''}; a dotted \`path\` reads a part of it.`;
6017
+ const result: IFlexResultHandle = {
6018
+ $flexType: 'result',
6019
+ handle,
6020
+ bytes,
6021
+ pages: resultPage(stored, '', 1, this.resultPageBytes())!.pages,
6022
+ summary: summarizeResult(stored, Math.min(FLEX_RESULT_LIMITS.summaryBytes, Math.floor(maxBytes / 4))),
6023
+ read,
6024
+ };
6025
+ // A budget too small for even the handle keeps the truncated output.
6026
+ if (jsonBytes(result) > maxBytes) return normalizeJsonValue(output, this.toolOutputLimits);
6027
+ const { storageKey } = run.state;
6028
+ await queueOperation(this.resultQueues, this.resultQueueKey(storageKey, run.sessionId), async () => {
6029
+ await results.save(storageKey, run.sessionId, handle, stored);
6030
+ // The session keeps its latest results; older ones are deleted.
6031
+ const handles = await results.list(storageKey, run.sessionId);
6032
+ for (const old of handles.slice(0, Math.max(0, handles.length - FLEX_RESULT_LIMITS.maxResultsPerSession))) {
6033
+ await results.delete(storageKey, run.sessionId, old);
6034
+ }
6035
+ });
6036
+ return result as unknown as TJsonValue;
6037
+ }
6038
+
6039
+ private resultQueueKey(storageKey: string, sessionId: string): string {
6040
+ return JSON.stringify([storageKey, sessionId]);
6041
+ }
6042
+
6043
+ /** The JSON bytes of one `results.get` page: the output budget, less room for the page's fields. */
6044
+ private resultPageBytes(): number {
6045
+ return Math.max(64, this.toolOutputLimits.maxBytes - 512);
6046
+ }
6047
+
6048
+ /** One page of a stored result of the run's session, or the error `results.get` reports. */
6049
+ private async readResult(
6050
+ run: IActiveRun,
6051
+ params: IResultRead,
6052
+ ): Promise<{ ok: true; value: TJsonValue } | { ok: false; error: TJsonValue }> {
6053
+ const method = FLEX_RESULT_METHOD;
6054
+ const stored = await this.stores.results!.load(run.state.storageKey, run.sessionId, params.handle);
6055
+ if (stored === undefined) {
6056
+ return { ok: false, error: { error: 'unknown_handle', method, handle: params.handle, hint: `A handle reads one of the latest ${FLEX_RESULT_LIMITS.maxResultsPerSession} results this session stored.` } };
6057
+ }
6058
+ const path = params.path ?? '';
6059
+ const value = selectPath(stored, path);
6060
+ if (value === undefined) {
6061
+ return { ok: false, error: { error: 'unknown_path', method, path, hint: `The result is ${describeShape(stored)}.` } };
6062
+ }
6063
+ const page = params.page ?? 1;
6064
+ const content = resultPage(value, path, page, this.resultPageBytes());
6065
+ if (!content) {
6066
+ const pages = resultPage(value, path, 1, this.resultPageBytes())!.pages;
6067
+ return { ok: false, error: { error: 'unknown_page', method, page, pages } };
6068
+ }
6069
+ return {
6070
+ ok: true,
6071
+ value: { handle: params.handle, path, page, pages: content.pages, ...content.range, value: content.value },
6072
+ };
6073
+ }
6074
+
6075
+ /**
6076
+ * Whether a call of the run's tools only reads: search_tools and describe_tools, an `execute` of
6077
+ * a method declared read-only, or a call the tool handle classifies as read-only by its real name.
6078
+ */
6079
+ private isReadOnlyToolCall(run: IActiveRun, toolName: string, input: unknown): boolean {
6080
+ const names = FLEX_TOOL_CATALOG_TOOL_NAMES;
6081
+ const catalog = run.toolCatalog;
6082
+ if (catalog) {
6083
+ if (toolName === names.search || toolName === names.describe) return true;
6084
+ if (toolName === names.execute) {
6085
+ const target = catalog.callTarget(input);
6086
+ return target !== undefined && this.isReadOnlyCatalogCall(run, catalog, target.method, target.params);
6087
+ }
6088
+ if (toolName === FLEX_CODE_MODE_TOOL_NAME) return false;
6089
+ }
6090
+ if (this.enabledBuiltInToolNames(run).has(toolName)) return false;
6091
+ return this.classifyHostToolCall(run, toolName, input);
6092
+ }
6093
+
6094
+ /** The method's path when the prepared call only reads, for the turn's reads. */
6095
+ private readOnlyMethod(
6096
+ run: IActiveRun,
6097
+ catalog: FlexToolCatalog<unknown>,
6098
+ call: { method: string; params: unknown },
6099
+ ): string | undefined {
6100
+ return this.isReadOnlyCatalogCall(run, catalog, call.method, call.params) ? call.method : undefined;
6101
+ }
6102
+
6103
+ /** A method whose `onCall` decides its calls is gated: its calls run in order, whatever they read. */
6104
+ private isReadOnlyCatalogCall(
6105
+ run: IActiveRun,
6106
+ catalog: FlexToolCatalog<unknown>,
6107
+ method: string,
6108
+ params: unknown,
6109
+ ): boolean {
6110
+ return !catalog.decides(method)
6111
+ && (catalog.isReadOnly(method) || this.classifyHostToolCall(run, method, params));
6112
+ }
6113
+
6114
+ private classifyHostToolCall(run: IActiveRun, toolName: string, input: unknown): boolean {
6115
+ try {
6116
+ return run.isReadOnlyHostCall?.({ toolName, input }) === true;
6117
+ } catch (error) {
6118
+ throw this.projectExternalError(run, error, 'toolProvider');
6119
+ }
6120
+ }
6121
+
6122
+ /**
6123
+ * Runs the run's tools through the turn's reads: a read-only call identical to an earlier read of
6124
+ * the turn shares its output, and any other call clears them. Records the confirmation of each
6125
+ * call that started while the model still streamed, which the permission requests naming the
6126
+ * call wait for. The catalog's tools read and run their methods by real name themselves.
6127
+ */
6128
+ private trackToolCalls(run: IActiveRun, tools: TFlexAgentToolSet): TFlexAgentToolSet {
6129
+ const catalogTools = new Set<string>(run.toolCatalog
6130
+ ? [...Object.values(FLEX_TOOL_CATALOG_TOOL_NAMES), FLEX_CODE_MODE_TOOL_NAME]
6131
+ : []);
6132
+ const tracked: Record<string, unknown> = {};
6133
+ for (const [name, tool] of Object.entries(tools)) {
6134
+ const execute = (tool as { execute?: (input: unknown, options: unknown) => unknown }).execute;
6135
+ if (typeof execute !== 'function') {
6136
+ tracked[name] = tool;
6137
+ continue;
6138
+ }
6139
+ tracked[name] = {
6140
+ ...tool,
6141
+ execute: (input: unknown, options: plugins.IAgentToolExecutionOptions & { toolCallId?: string }) => {
6142
+ const confirmed = options?.confirmed;
6143
+ const toolCallId = options?.toolCallId ?? '';
6144
+ if (confirmed && toolCallId) {
6145
+ run.unconfirmedToolCalls.set(toolCallId, confirmed);
6146
+ const forget = () => run.unconfirmedToolCalls.delete(toolCallId);
6147
+ confirmed.then(forget, forget);
6148
+ }
6149
+ const executeTool = () => execute.call(tool, input, options);
6150
+ if (catalogTools.has(name)) return executeTool();
6151
+ const readName = this.isReadOnlyToolCall(run, name, input) ? name : undefined;
6152
+ return this.readOnce(run, toolCallId, readName, input, confirmed, executeTool);
6153
+ },
6154
+ };
6155
+ }
6156
+ return tracked as TFlexAgentToolSet;
6157
+ }
6158
+
6159
+ /**
6160
+ * Answers a read-only call (`name` set) from an identical read earlier in the turn, sharing the
6161
+ * output of one still running, or runs it and keeps its output for the reads after it. A failed
6162
+ * read, and a read whose response was discarded, are not kept. Any other call clears the turn's
6163
+ * reads before it runs.
6164
+ */
6165
+ private readOnce(
6166
+ run: IActiveRun,
6167
+ toolCallId: string,
6168
+ name: string | undefined,
6169
+ params: unknown,
6170
+ confirmed: Promise<void> | undefined,
6171
+ execute: () => unknown,
6172
+ ): unknown {
6173
+ if (name === undefined) {
6174
+ run.readCalls.clear();
6175
+ return execute();
6176
+ }
6177
+ const canonicalParams = canonicalJson(params);
6178
+ if (canonicalParams === undefined) return execute();
6179
+ const key = `${name}\n${canonicalParams}`;
6180
+ const earlier = run.readCalls.get(key);
6181
+ if (earlier) {
6182
+ return earlier.output.then((output) => {
6183
+ if (output === notShared) return execute();
6184
+ run.deduplicatedToolCalls.set(toolCallId, earlier.toolCallId);
6185
+ return output;
6186
+ }, (error: unknown) => {
6187
+ run.deduplicatedToolCalls.set(toolCallId, earlier.toolCallId);
6188
+ throw error;
6189
+ });
6190
+ }
6191
+ const result = execute();
6192
+ const entry: IRunReadCall = {
6193
+ toolCallId,
6194
+ output: Promise.resolve(result).then((output) => isAsyncIterable(output) ? notShared : output),
6195
+ };
6196
+ run.readCalls.set(key, entry);
6197
+ const forget = () => {
6198
+ if (run.readCalls.get(key) === entry) run.readCalls.delete(key);
6199
+ };
6200
+ entry.output.catch(forget);
6201
+ confirmed?.catch(forget);
6202
+ return result;
6203
+ }
6204
+
5938
6205
  private enabledBuiltInToolNames(run: IActiveRun): Set<string> {
5939
6206
  const names = new Set<string>();
5940
6207
  if (run.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
@@ -5995,15 +6262,35 @@ export class FlexHarness<TScope = unknown> {
5995
6262
  ),
5996
6263
  execute: async (
5997
6264
  input: { method: string; params?: Record<string, unknown> },
5998
- options?: { toolCallId?: string; abortSignal?: AbortSignal },
6265
+ options?: { toolCallId?: string; abortSignal?: AbortSignal } & plugins.IAgentToolExecutionOptions,
5999
6266
  ) => {
6000
6267
  const toolCallId = options?.toolCallId ?? '';
6001
6268
  const abortSignal = options?.abortSignal ?? run.controller.signal;
6269
+ const confirmed = options?.confirmed;
6002
6270
  const call = await catalog.prepareCall(input.method, input.params);
6003
6271
  if (!call.success) throw this.catalogCallError(run, toolCallId, call.error);
6004
- const context = { method: call.method, toolCallId, abortSignal, provider: run.toolProviderContext! };
6272
+ if (call.method === FLEX_RESULT_METHOD) {
6273
+ const read = await this.readResult(run, call.params as IResultRead);
6274
+ if (!read.ok) throw this.catalogCallError(run, toolCallId, read.error);
6275
+ return read.value;
6276
+ }
6277
+ // A call that started early asks for permission once the response confirmed it.
6278
+ const base = run.toolProviderContext!;
6279
+ const provider = confirmed
6280
+ ? Object.freeze({
6281
+ ...base,
6282
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
6283
+ await confirmed;
6284
+ return base.requestPermission(request);
6285
+ },
6286
+ })
6287
+ : base;
6288
+ const context = { method: call.method, toolCallId, abortSignal, provider };
6005
6289
  const decision = await call.decide(context);
6006
- if (decision === 'run') return call.run(context);
6290
+ if (decision === 'run') {
6291
+ return this.readOnce(run, toolCallId, this.readOnlyMethod(run, catalog, call), call.params, confirmed,
6292
+ () => call.run(context));
6293
+ }
6007
6294
  if (decision === 'propose') {
6008
6295
  return catalog.propose(
6009
6296
  [{ id: toolCallId, method: call.method, params: call.params }],
@@ -6075,51 +6362,97 @@ export class FlexHarness<TScope = unknown> {
6075
6362
  });
6076
6363
  const stopped = (method: string) => ({ ok: false as const, error: { error: 'aborted', method } });
6077
6364
  let callNumber = 0;
6365
+ // Read-only calls run concurrently; any other call runs after every call the program made before it.
6366
+ const order = new plugins.AgentToolCallScheduler(abortSignal);
6078
6367
  const finish = (id: string, method: string, input: unknown, outcome: { output: unknown } | { error: Error }) => {
6079
6368
  if ('error' in outcome) run.trustedToolExecutionErrors.set(id, outcome.error);
6080
6369
  this.onToolFinish(run.state, run.sessionId, 'error' in outcome
6081
6370
  ? { toolCallId: id, toolName: method, input, success: false, error: outcome.error.message }
6082
6371
  : { toolCallId: id, toolName: method, input, success: true, output: outcome.output });
6083
6372
  };
6373
+ const callInTurn = async (
6374
+ id: string,
6375
+ method: string,
6376
+ params: unknown,
6377
+ signal: AbortSignal,
6378
+ classification: { readOnly: boolean } | { error: Error },
6379
+ ): Promise<TFlexCodeCallOutcome> => {
6380
+ const call = await catalog.prepareCall(method, params);
6381
+ if (!call.success) {
6382
+ if (catalog.has(method)) {
6383
+ this.onToolStart(run.state, run.sessionId, { toolCallId: id, toolName: method, input: params ?? {}, parentToolCallId: toolCallId });
6384
+ finish(id, method, params, { error: this.catalogCallError(run, id, call.error) });
6385
+ }
6386
+ return { ok: false, error: call.error as unknown as TJsonValue };
6387
+ }
6388
+ try {
6389
+ await order.waitForTurn(id, signal);
6390
+ } catch {
6391
+ return stopped(call.method);
6392
+ }
6393
+ if (signal.aborted) return stopped(call.method);
6394
+ this.onToolStart(run.state, run.sessionId, {
6395
+ toolCallId: id, toolName: call.method, input: call.params, parentToolCallId: toolCallId,
6396
+ });
6397
+ if ('error' in classification) {
6398
+ finish(id, call.method, call.params, { error: classification.error });
6399
+ return { ok: false, error: { error: 'failed', method: call.method, message: classification.error.message } };
6400
+ }
6401
+ if (call.method === FLEX_RESULT_METHOD) {
6402
+ const read = await this.readResult(run, call.params as IResultRead);
6403
+ finish(id, call.method, call.params, read.ok
6404
+ ? { output: read.value }
6405
+ : { error: this.catalogCallError(run, id, read.error) });
6406
+ return read;
6407
+ }
6408
+ const readOnly = classification.readOnly;
6409
+ const context = { method: call.method, toolCallId: id, abortSignal: signal, provider: providerFor(signal) };
6410
+ try {
6411
+ const decision = await call.decide(context);
6412
+ signal.throwIfAborted();
6413
+ if (decision === 'propose') {
6414
+ proposals.push({ id, method: call.method, params: call.params });
6415
+ const value = { proposed: true, id };
6416
+ finish(id, call.method, call.params, { output: value });
6417
+ return { ok: true, value };
6418
+ }
6419
+ if (decision !== 'run') {
6420
+ const payload = { error: 'denied' as const, method: call.method, reason: decision.deny };
6421
+ finish(id, call.method, call.params, { error: this.catalogCallError(run, id, payload) });
6422
+ return { ok: false, error: payload };
6423
+ }
6424
+ const output = this.readOnce(run, id, readOnly ? call.method : undefined, call.params, undefined,
6425
+ () => call.run(context));
6426
+ const value = await this.budgetOutput(run, await settleToolOutput(output));
6427
+ finish(id, call.method, call.params, { output: value });
6428
+ return { ok: true, value };
6429
+ } catch (error) {
6430
+ const projected = this.projectExternalError(run, error, 'toolExecution');
6431
+ finish(id, call.method, call.params, { error: projected });
6432
+ return { ok: false, error: { error: 'failed', method: call.method, message: projected.message } };
6433
+ }
6434
+ };
6084
6435
  const host: IFlexCodeHost = {
6085
6436
  waitedMs: () => waitedMs + (waiting > 0 ? performance.now() - waitingSince : 0),
6086
6437
  call: async (method, params, signal) => {
6087
6438
  if (signal.aborted) return stopped(method);
6088
6439
  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) };
6440
+ // The call takes its place in the program's order now, before anything is awaited.
6441
+ let classification: { readOnly: boolean } | { error: Error };
6102
6442
  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 };
6443
+ classification = {
6444
+ readOnly: catalog.has(method) && this.isReadOnlyCatalogCall(run, catalog, method, params ?? {}),
6445
+ };
6119
6446
  } 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 } };
6447
+ classification = {
6448
+ error: error instanceof Error ? error : this.projectExternalError(run, error, 'toolProvider'),
6449
+ };
6450
+ }
6451
+ order.begin(id, 'readOnly' in classification && classification.readOnly);
6452
+ try {
6453
+ return await callInTurn(id, method, params, signal, classification);
6454
+ } finally {
6455
+ order.settle(id);
6123
6456
  }
6124
6457
  },
6125
6458
  };
@@ -8698,6 +9031,10 @@ export class FlexHarness<TScope = unknown> {
8698
9031
  delete part.output;
8699
9032
  part.error = projectedError!.message;
8700
9033
  }
9034
+ const original = run.deduplicatedToolCalls.get(event.toolCallId);
9035
+ if (original !== undefined) {
9036
+ part.deduplicatedFrom = this.boundedIdentifier(original, 'deduplicatedFrom');
9037
+ }
8701
9038
  part.completedAt = new Date().toISOString();
8702
9039
  this.emitPartEvent(run, 'part.completed', part);
8703
9040
  } catch (error) {
@@ -9362,10 +9699,17 @@ export class FlexHarness<TScope = unknown> {
9362
9699
  if (catalog !== undefined && (typeof catalog !== 'object' || catalog === null)) {
9363
9700
  throw new FlexHarnessValidationError('Tool handle catalog must be an object.');
9364
9701
  }
9702
+ const isReadOnlyToolCall = handle.isReadOnlyToolCall;
9703
+ if (isReadOnlyToolCall !== undefined && typeof isReadOnlyToolCall !== 'function') {
9704
+ throw new FlexHarnessValidationError('Tool handle isReadOnlyToolCall must be a function.');
9705
+ }
9365
9706
  const closeHandle = close;
9366
9707
  return {
9367
9708
  tools,
9368
9709
  ...(catalog === undefined ? {} : { catalog }),
9710
+ ...(isReadOnlyToolCall === undefined ? {} : {
9711
+ isReadOnlyToolCall: (call: { toolName: string; input: unknown }) => isReadOnlyToolCall.call(handle, call),
9712
+ }),
9369
9713
  ...(closeHandle ? { close: () => closeHandle.call(handle) } : {}),
9370
9714
  };
9371
9715
  } catch (error) {
@@ -9394,6 +9738,8 @@ export class FlexHarness<TScope = unknown> {
9394
9738
  const descriptors = this.normalizeResourceToolProviderDescriptors(resolved);
9395
9739
  const owned: Array<{ handle: IFlexToolHandle; closed: boolean }> = [];
9396
9740
  const tools: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
9741
+ /** The handle and own name of each exposed resource tool, for the handle's classifier. */
9742
+ const resourceTools = new Map<string, { handle: IFlexToolHandle; toolName: string }>();
9397
9743
  const closeOwned = async (): Promise<void> => {
9398
9744
  const errors: unknown[] = [];
9399
9745
  for (const entry of [...owned].reverse()) {
@@ -9461,12 +9807,23 @@ export class FlexHarness<TScope = unknown> {
9461
9807
  throw new FlexHarnessValidationError(`Duplicate exposed tool "${exposedName}".`);
9462
9808
  }
9463
9809
  tools[exposedName] = tool;
9810
+ resourceTools.set(exposedName, { handle, toolName });
9464
9811
  }
9465
9812
  }
9466
9813
  if (owned.length === 0 && Object.keys(tools).length === 0) return undefined;
9814
+ const classifies = applicationHandle?.isReadOnlyToolCall !== undefined
9815
+ || [...resourceTools.values()].some((entry) => entry.handle.isReadOnlyToolCall !== undefined);
9467
9816
  return {
9468
9817
  tools: tools as TFlexAgentToolSet,
9469
9818
  ...(applicationHandle?.catalog === undefined ? {} : { catalog: applicationHandle.catalog }),
9819
+ ...(classifies ? {
9820
+ isReadOnlyToolCall: ({ toolName, input }: { toolName: string; input: unknown }) => {
9821
+ const resource = resourceTools.get(toolName);
9822
+ return resource
9823
+ ? resource.handle.isReadOnlyToolCall?.({ toolName: resource.toolName, input }) === true
9824
+ : applicationHandle?.isReadOnlyToolCall?.({ toolName, input }) === true;
9825
+ },
9826
+ } : {}),
9470
9827
  ...(owned.some((entry) => entry.handle.close) ? { close: closeOwned } : {}),
9471
9828
  };
9472
9829
  } catch (error) {
@@ -10906,11 +11263,18 @@ export class FlexHarness<TScope = unknown> {
10906
11263
  tombstone,
10907
11264
  );
10908
11265
  };
11266
+ const resultStore = this.stores.results;
11267
+ const resultCleanup = async (): Promise<void> => {
11268
+ // A result being saved for the session is written before its results are deleted.
11269
+ await this.resultQueues.get(this.resultQueueKey(storageKey, sessionId));
11270
+ await resultStore!.deleteSession(storageKey, sessionId);
11271
+ };
10909
11272
  const results = await Promise.allSettled([
10910
11273
  this.stores.agentEvents.deleteSession(storageKey, sessionId),
10911
11274
  this.stores.jobs.deleteSession(storageKey, sessionId),
10912
11275
  this.stores.projections.deleteSession(storageKey, sessionId),
10913
11276
  this.stores.permissions.deleteSession(storageKey, sessionId),
11277
+ ...(resultStore ? [resultCleanup()] : []),
10914
11278
  // An imported session refuses every project-management write, so it has no state to fence.
10915
11279
  ...(tombstone.imported ? [] : [projectManagementCleanup()]),
10916
11280
  ]);