@librechat/agents 3.3.5 → 3.3.6

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 (127) hide show
  1. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  2. package/dist/cjs/llm/openai/index.cjs +51 -6
  3. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  4. package/dist/cjs/main.cjs +14 -0
  5. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  6. package/dist/cjs/stream.cjs +6 -1
  7. package/dist/cjs/stream.cjs.map +1 -1
  8. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  9. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  10. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
  11. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  12. package/dist/cjs/tools/CodeExecutor.cjs +19 -16
  13. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  14. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  16. package/dist/cjs/tools/ReadFile.cjs +8 -4
  17. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  18. package/dist/cjs/tools/SkillTool.cjs +2 -0
  19. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  20. package/dist/cjs/tools/SubagentTool.cjs +3 -0
  21. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  22. package/dist/cjs/tools/ToolNode.cjs +88 -27
  23. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  24. package/dist/cjs/tools/ToolSearch.cjs +5 -1
  25. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  26. package/dist/cjs/tools/intentArg.cjs +230 -0
  27. package/dist/cjs/tools/intentArg.cjs.map +1 -0
  28. package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
  29. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  30. package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
  31. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  32. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  33. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  34. package/dist/cjs/tools/search/schema.cjs +2 -0
  35. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  36. package/dist/cjs/tools/search/tool.cjs +31 -1
  37. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  38. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  39. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  40. package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
  41. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  42. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  43. package/dist/esm/llm/openai/index.mjs +51 -6
  44. package/dist/esm/llm/openai/index.mjs.map +1 -1
  45. package/dist/esm/main.mjs +4 -3
  46. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  47. package/dist/esm/stream.mjs +6 -1
  48. package/dist/esm/stream.mjs.map +1 -1
  49. package/dist/esm/tools/BashExecutor.mjs +3 -1
  50. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  51. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
  52. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  53. package/dist/esm/tools/CodeExecutor.mjs +19 -16
  54. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  55. package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
  56. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  57. package/dist/esm/tools/ReadFile.mjs +8 -4
  58. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  59. package/dist/esm/tools/SkillTool.mjs +2 -0
  60. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  61. package/dist/esm/tools/SubagentTool.mjs +3 -0
  62. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +88 -28
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/ToolSearch.mjs +5 -1
  66. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  67. package/dist/esm/tools/intentArg.mjs +220 -0
  68. package/dist/esm/tools/intentArg.mjs.map +1 -0
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  71. package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
  72. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  73. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  74. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  75. package/dist/esm/tools/search/schema.mjs +2 -0
  76. package/dist/esm/tools/search/schema.mjs.map +1 -1
  77. package/dist/esm/tools/search/tool.mjs +31 -2
  78. package/dist/esm/tools/search/tool.mjs.map +1 -1
  79. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  80. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  81. package/dist/esm/tools/toolOutputReferences.mjs +41 -7
  82. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  83. package/dist/types/index.d.ts +1 -0
  84. package/dist/types/llm/openai/index.d.ts +0 -11
  85. package/dist/types/tools/BashExecutor.d.ts +18 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +18 -0
  87. package/dist/types/tools/ReadFile.d.ts +18 -0
  88. package/dist/types/tools/SkillTool.d.ts +18 -0
  89. package/dist/types/tools/SubagentTool.d.ts +9 -0
  90. package/dist/types/tools/ToolNode.d.ts +37 -5
  91. package/dist/types/tools/ToolSearch.d.ts +18 -0
  92. package/dist/types/tools/intentArg.d.ts +119 -0
  93. package/dist/types/tools/ptcTimeout.d.ts +2 -0
  94. package/dist/types/tools/search/schema.d.ts +18 -0
  95. package/dist/types/tools/search/tool.d.ts +18 -0
  96. package/dist/types/tools/toolOutputReferences.d.ts +12 -2
  97. package/dist/types/types/stream.d.ts +12 -0
  98. package/dist/types/types/tools.d.ts +27 -2
  99. package/package.json +1 -1
  100. package/src/index.ts +1 -0
  101. package/src/llm/openai/index.ts +74 -10
  102. package/src/llm/openai/llm.spec.ts +105 -0
  103. package/src/stream.ts +12 -1
  104. package/src/tools/BashExecutor.ts +6 -0
  105. package/src/tools/BashProgrammaticToolCalling.ts +2 -0
  106. package/src/tools/CodeExecutor.ts +6 -0
  107. package/src/tools/ProgrammaticToolCalling.ts +2 -0
  108. package/src/tools/ReadFile.ts +2 -0
  109. package/src/tools/SkillTool.ts +2 -0
  110. package/src/tools/SubagentTool.ts +3 -0
  111. package/src/tools/ToolNode.ts +190 -43
  112. package/src/tools/ToolSearch.ts +3 -0
  113. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
  114. package/src/tools/__tests__/intentArg.test.ts +281 -0
  115. package/src/tools/__tests__/intentCoverage.test.ts +139 -0
  116. package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
  117. package/src/tools/intentArg.ts +323 -0
  118. package/src/tools/local/CompileCheckTool.ts +4 -3
  119. package/src/tools/local/LocalCodingTools.ts +14 -13
  120. package/src/tools/ptcTimeout.ts +2 -0
  121. package/src/tools/search/outcome.test.ts +90 -0
  122. package/src/tools/search/schema.ts +3 -0
  123. package/src/tools/search/tool.ts +47 -1
  124. package/src/tools/subagent/SubagentExecutor.ts +2 -1
  125. package/src/tools/toolOutputReferences.ts +77 -9
  126. package/src/types/stream.ts +12 -0
  127. package/src/types/tools.ts +24 -2
@@ -31,6 +31,7 @@ import type {
31
31
  PreResolvedArgsMap,
32
32
  ResolvedArgsByCallId,
33
33
  ResolveResult,
34
+ ResolveOptions,
34
35
  } from '@/tools/toolOutputReferences';
35
36
  import type {
36
37
  HookRegistry,
@@ -47,6 +48,13 @@ import {
47
48
  serializeStructuredValueBounded,
48
49
  serializeToolContentBounded,
49
50
  } from '@/utils/toolContent';
51
+ import {
52
+ INTENT_ARG,
53
+ readOutcomeFields,
54
+ resolveToolOutcome,
55
+ isIntentLabelProperty,
56
+ outcomeFieldsFromResult,
57
+ } from '@/tools/intentArg';
50
58
  import {
51
59
  buildToolExecutionRequestPlan,
52
60
  resolveRuntimeSessionHint,
@@ -126,8 +134,37 @@ type RunToolBatchContext<T = unknown> = {
126
134
  * which relies on `node:async_hooks` and is browser-incompatible).
127
135
  */
128
136
  runInput?: T;
137
+ /** Batch-local error-completion ownership (see {@link ToolErrorOwnership}). */
138
+ errorOwnership?: ToolErrorOwnership;
139
+ };
140
+
141
+ /**
142
+ * Batch-local record of who owns each failed call's completion event.
143
+ *
144
+ * Kept per invocation rather than on the instance: tool-call ids are
145
+ * provider-scoped (and synthetic ids can repeat), so concurrent `run()`s on
146
+ * one ToolNode would otherwise share and cross-consume these markers — one
147
+ * invocation's thrown-error marker suppressing another's only completion,
148
+ * or leaving a stale marker behind when an interrupt aborts a batch before
149
+ * the output loop reads it.
150
+ *
151
+ * - `handlerOwned`: the errorHandler ran and dispatched (or threw — a throw
152
+ * is not proof it didn't dispatch). The output loop must skip these.
153
+ * - `undispatched`: the handler explicitly reported it could NOT dispatch,
154
+ * so the output loop owns the completion instead.
155
+ *
156
+ * A call in NEITHER set returned an error `ToolMessage` without ever
157
+ * entering the catch path, so the output loop owns it too.
158
+ */
159
+ export type ToolErrorOwnership = {
160
+ handlerOwned: Set<string>;
161
+ undispatched: Set<string>;
129
162
  };
130
163
 
164
+ export function createToolErrorOwnership(): ToolErrorOwnership {
165
+ return { handlerOwned: new Set(), undispatched: new Set() };
166
+ }
167
+
131
168
  type BoundedToolOutput = {
132
169
  content: string;
133
170
  registryContent: string;
@@ -499,12 +536,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
499
536
  toolCallStepIds?: Map<string, string>;
500
537
  errorHandler?: t.ToolNodeConstructorParams['errorHandler'];
501
538
  /**
502
- * Tool call ids whose `errorHandler` did NOT dispatch the error completion
503
- * event (it returned `false` or threw). The output loop must dispatch the
504
- * completion for these itself skipping them there would strand the
505
- * client's tool-call part without a terminal event.
539
+ * Fallback error-completion ownership for calls that reach `runTool`
540
+ * outside a batch context (direct `runTool` use in tests / embedders).
541
+ * Batch-scoped ownership is threaded via `RunToolBatchContext` instead
542
+ * see {@link ToolErrorOwnership} for why per-invocation scoping matters.
506
543
  */
507
- private undispatchedToolErrors: Set<string> = new Set();
544
+ private looseErrorOwnership: ToolErrorOwnership =
545
+ createToolErrorOwnership();
508
546
  private toolUsageCount: Map<string, number>;
509
547
  /** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
510
548
  private toolCallTurns: Map<string, number> = new Map();
@@ -952,14 +990,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
952
990
  args: T
953
991
  ) => ResolveResult<T>;
954
992
  let resolveFn: ResolveFn | undefined;
993
+ const resolveOptions = {
994
+ substituteIntentKey: this.toolDeclaresBusinessIntent(call.name),
995
+ };
955
996
  if (preBatchSnapshot != null) {
956
997
  resolveFn = <T>(_runId: string | undefined, args: T): ResolveResult<T> =>
957
- preBatchSnapshot.resolve(args);
998
+ preBatchSnapshot.resolve(args, resolveOptions);
958
999
  } else if (registry != null) {
959
1000
  resolveFn = <T>(
960
1001
  runIdArg: string | undefined,
961
1002
  args: T
962
- ): ResolveResult<T> => registry.resolve(runIdArg, args);
1003
+ ): ResolveResult<T> => registry.resolve(runIdArg, args, resolveOptions);
963
1004
  }
964
1005
  /**
965
1006
  * Precompute the reference key once per call — captured locally
@@ -1266,15 +1307,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1266
1307
  },
1267
1308
  config.metadata
1268
1309
  );
1269
- if (dispatched === false && call.id != null && call.id !== '') {
1270
- /**
1271
- * The handler could not dispatch the error completion (typically
1272
- * a resume pass, where a fast-failing tool errors before the
1273
- * step replay registers its run step). Remember the call so the
1274
- * output loop dispatches the completion itself instead of
1275
- * assuming the handler covered it.
1276
- */
1277
- this.undispatchedToolErrors.add(call.id);
1310
+ const ownership =
1311
+ batchContext.errorOwnership ?? this.looseErrorOwnership;
1312
+ if (call.id != null && call.id !== '') {
1313
+ if (dispatched === false) {
1314
+ /**
1315
+ * The handler could not dispatch the error completion (typically
1316
+ * a resume pass, where a fast-failing tool errors before the
1317
+ * step replay registers its run step). Remember the call so the
1318
+ * output loop dispatches the completion itself instead of
1319
+ * assuming the handler covered it.
1320
+ */
1321
+ ownership.undispatched.add(call.id);
1322
+ } else {
1323
+ ownership.handlerOwned.add(call.id);
1324
+ }
1278
1325
  }
1279
1326
  } catch (handlerError) {
1280
1327
  // A THROWN handler is not proof the completion wasn't dispatched: the
@@ -1284,6 +1331,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1284
1331
  // fallback loop re-emit a duplicate completion — only an explicit
1285
1332
  // `false` return (handled above) means "nothing dispatched"; a throw
1286
1333
  // is just logged.
1334
+ if (call.id != null && call.id !== '') {
1335
+ (
1336
+ batchContext.errorOwnership ?? this.looseErrorOwnership
1337
+ ).handlerOwned.add(call.id);
1338
+ }
1287
1339
  // eslint-disable-next-line no-console
1288
1340
  console.error('Error in errorHandler:', {
1289
1341
  toolName: call.name,
@@ -1448,13 +1500,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1448
1500
  // (later-awaited) `runTool` will actually run with — both are
1449
1501
  // anchored to the pre-batch registry state.
1450
1502
  let resolvedArgs = call.args as Record<string, unknown>;
1503
+ const hookResolveOptions = {
1504
+ substituteIntentKey: this.toolDeclaresBusinessIntent(call.name),
1505
+ };
1451
1506
  if (batchContext.preBatchSnapshot != null) {
1452
- const { resolved } = batchContext.preBatchSnapshot.resolve(call.args);
1507
+ const { resolved } = batchContext.preBatchSnapshot.resolve(
1508
+ call.args,
1509
+ hookResolveOptions
1510
+ );
1453
1511
  resolvedArgs = resolved as Record<string, unknown>;
1454
1512
  } else if (this.toolOutputRegistry != null) {
1455
1513
  const { resolved } = this.toolOutputRegistry.resolve(
1456
1514
  registryRunId,
1457
- call.args
1515
+ call.args,
1516
+ hookResolveOptions
1458
1517
  );
1459
1518
  resolvedArgs = resolved as Record<string, unknown>;
1460
1519
  }
@@ -1655,6 +1714,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1655
1714
  }
1656
1715
  }
1657
1716
 
1717
+ /**
1718
+ * A hook (`PreToolUse.updatedInput`) or HITL `edit` decision rewrote the
1719
+ * args: expose the EFFECTIVE args to downstream completion handling
1720
+ * (`handleRunToolCompletions` reads this sink), so the emitted
1721
+ * `tool_call.args` — and any intent/outcome label resolved from them —
1722
+ * reflect what the tool actually ran with. `runTool`'s own placeholder
1723
+ * substitution may overwrite this entry with the post-substitution args,
1724
+ * which is strictly more accurate.
1725
+ */
1726
+ if (
1727
+ effectiveCall !== call &&
1728
+ batchContext.resolvedArgsByCallId != null &&
1729
+ call.id != null &&
1730
+ call.id !== ''
1731
+ ) {
1732
+ batchContext.resolvedArgsByCallId.set(
1733
+ call.id,
1734
+ effectiveCall.args as Record<string, unknown>
1735
+ );
1736
+ }
1737
+
1658
1738
  const output = await this.runTool(effectiveCall, config, {
1659
1739
  ...batchContext,
1660
1740
  usageCount,
@@ -2008,8 +2088,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2008
2088
  calls: ToolCall[],
2009
2089
  outputs: (BaseMessage | Command)[],
2010
2090
  config: RunnableConfig,
2011
- resolvedArgsByCallId?: ResolvedArgsByCallId
2091
+ resolvedArgsByCallId?: ResolvedArgsByCallId,
2092
+ errorOwnership?: ToolErrorOwnership
2012
2093
  ): Promise<void> {
2094
+ const ownership = errorOwnership ?? this.looseErrorOwnership;
2013
2095
  for (let i = 0; i < calls.length; i++) {
2014
2096
  const call = calls[i];
2015
2097
  const output = outputs[i];
@@ -2022,23 +2104,24 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2022
2104
  const toolMessage = output as ToolMessage;
2023
2105
  const toolCallId = call.id ?? '';
2024
2106
 
2025
- // Skip error ToolMessages when errorHandler already dispatched ON_RUN_STEP_COMPLETED
2026
- // via handleToolCallErrorStatic dispatching again here would double-dispatch.
2027
- // When the handler reported it could NOT dispatch (no run step registered yet at
2028
- // error time, e.g. a fast-failing tool on a resume pass), fall through: by now the
2029
- // step replay has usually registered the id, so this loop's dispatch is the only
2030
- // terminal event the client's tool-call part will ever get.
2107
+ // Skip error ToolMessages only when the errorHandler OWNS the completion —
2108
+ // it ran and dispatched (or threw) via handleToolCallErrorStatic, so
2109
+ // dispatching again here would double-dispatch. Two cases fall through:
2110
+ // - the handler reported it could NOT dispatch (no run step registered yet
2111
+ // at error time, e.g. a fast-failing tool on a resume pass); by now the
2112
+ // step replay has usually registered the id, so this loop's dispatch is
2113
+ // the only terminal event the client's tool-call part will ever get.
2114
+ // - the tool RETURNED an error ToolMessage rather than throwing, so the
2115
+ // catch path (and the handler with it) never ran at all.
2116
+ // Markers are CONSUMED: leaving one set would let a later re-entry (same
2117
+ // ToolNode re-executing the batch) take the wrong branch, and the sets
2118
+ // would grow unbounded across a long-lived graph's failing calls.
2031
2119
  if (toolMessage.status === 'error' && this.errorHandler != null) {
2032
- if (this.undispatchedToolErrors.has(toolCallId)) {
2033
- // CONSUME the marker: this loop now owns the dispatch for this id.
2034
- // Leaving it set would let a later re-entry (same ToolNode instance
2035
- // re-executing the batch, where the handler CAN dispatch) fall
2036
- // through here too and double-dispatch — and the set would grow
2037
- // unbounded across a long-lived graph's fast-failing calls.
2038
- this.undispatchedToolErrors.delete(toolCallId);
2039
- } else {
2120
+ if (ownership.handlerOwned.has(toolCallId)) {
2121
+ ownership.handlerOwned.delete(toolCallId);
2040
2122
  continue;
2041
2123
  }
2124
+ ownership.undispatched.delete(toolCallId);
2042
2125
  }
2043
2126
 
2044
2127
  if (this.sessions && this.participatesInCodeSession(call.name)) {
@@ -2079,6 +2162,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2079
2162
  * the tool actually received rather than leaking the template.
2080
2163
  */
2081
2164
  const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
2165
+ /** Authored outcomes apply to failed calls too (“Search failed for…”);
2166
+ * without one, an error call simply stays unlabeled. */
2167
+ const outcome = resolveToolOutcome(
2168
+ effectiveArgs,
2169
+ readOutcomeFields(toolMessage.artifact),
2170
+ { isError: toolMessage.status === 'error' }
2171
+ );
2082
2172
  const tool_call: t.ProcessedToolCall = {
2083
2173
  args: serializeToolContentBounded(
2084
2174
  (effectiveArgs as unknown) ?? {},
@@ -2088,6 +2178,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2088
2178
  id: toolCallId,
2089
2179
  output: contentString,
2090
2180
  progress: 1,
2181
+ ...(outcome != null && { outcome }),
2091
2182
  };
2092
2183
 
2093
2184
  await safeDispatchCustomEvent(
@@ -2165,7 +2256,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2165
2256
  } else if (registry != null) {
2166
2257
  const { resolved, unresolved } = registry.resolve(
2167
2258
  registryRunId,
2168
- originalArgs
2259
+ originalArgs,
2260
+ { substituteIntentKey: this.toolDeclaresBusinessIntent(call.name) }
2169
2261
  );
2170
2262
  resolvedArgs = resolved as Record<string, unknown>;
2171
2263
  if (unresolved.length > 0 && call.id != null) {
@@ -2386,9 +2478,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2386
2478
  ): void => {
2387
2479
  if (registry != null) {
2388
2480
  const view: ToolOutputResolveView = preBatchSnapshot ?? {
2389
- resolve: <T>(args: T) => registry.resolve(registryRunId, args),
2481
+ resolve: <T>(args: T, options?: ResolveOptions) =>
2482
+ registry.resolve(registryRunId, args, options),
2390
2483
  };
2391
- const { resolved, unresolved } = view.resolve(nextArgs);
2484
+ const { resolved, unresolved } = view.resolve(nextArgs, {
2485
+ substituteIntentKey: this.toolDeclaresBusinessIntent(
2486
+ entry.call.name
2487
+ ),
2488
+ });
2392
2489
  entry.args = resolved as Record<string, unknown>;
2393
2490
  if (entry.call.id != null) {
2394
2491
  if (unresolved.length > 0) {
@@ -3120,7 +3217,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3120
3217
  request?.args ?? {},
3121
3218
  contentString,
3122
3219
  config,
3123
- request?.turn
3220
+ request?.turn,
3221
+ resolveToolOutcome(request?.args, outcomeFieldsFromResult(result), {
3222
+ isError: result.status === 'error',
3223
+ })
3124
3224
  );
3125
3225
  }
3126
3226
 
@@ -3355,13 +3455,43 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3355
3455
  }
3356
3456
  }
3357
3457
 
3458
+ /**
3459
+ * Whether `name`'s own schema declares a BUSINESS parameter called
3460
+ * `intent` (declared, and not the injected label contract — see
3461
+ * `isIntentLabelProperty`). Such a parameter must keep participating in
3462
+ * `{{tool…}}` placeholder substitution; only the display label is exempt.
3463
+ */
3464
+ private toolDeclaresBusinessIntent(name: string): boolean {
3465
+ const instance = this.toolMap.get(name) as
3466
+ | {
3467
+ schema?: {
3468
+ shape?: Record<string, unknown>;
3469
+ properties?: Record<string, unknown>;
3470
+ };
3471
+ }
3472
+ | undefined;
3473
+ const instanceProp =
3474
+ instance?.schema?.properties?.[INTENT_ARG] ??
3475
+ instance?.schema?.shape?.[INTENT_ARG];
3476
+ if (instanceProp != null) {
3477
+ return !isIntentLabelProperty(instanceProp);
3478
+ }
3479
+ const defProp =
3480
+ this.toolRegistry?.get(name)?.parameters?.properties?.[INTENT_ARG];
3481
+ if (defProp != null) {
3482
+ return !isIntentLabelProperty(defProp);
3483
+ }
3484
+ return false;
3485
+ }
3486
+
3358
3487
  private async dispatchStepCompleted(
3359
3488
  toolCallId: string,
3360
3489
  toolName: string,
3361
3490
  args: Record<string, unknown>,
3362
3491
  output: string,
3363
3492
  config: RunnableConfig,
3364
- turn?: number
3493
+ turn?: number,
3494
+ outcome?: string
3365
3495
  ): Promise<boolean> {
3366
3496
  const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
3367
3497
  if (!stepId) {
@@ -3386,6 +3516,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3386
3516
  id: toolCallId,
3387
3517
  output,
3388
3518
  progress: 1,
3519
+ ...(outcome != null && { outcome }),
3389
3520
  } as t.ProcessedToolCall,
3390
3521
  },
3391
3522
  },
@@ -3422,7 +3553,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3422
3553
  request.args,
3423
3554
  output,
3424
3555
  config,
3425
- request.turn
3556
+ request.turn,
3557
+ resolveToolOutcome(request.args, outcomeFieldsFromResult(result), {
3558
+ isError: result.status === 'error',
3559
+ })
3426
3560
  );
3427
3561
  }
3428
3562
 
@@ -3550,6 +3684,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3550
3684
  * ToolNode cannot read or wipe each other's entries.
3551
3685
  */
3552
3686
  const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
3687
+ /** Per-invocation error-completion ownership — see `ToolErrorOwnership`. */
3688
+ const errorOwnership = createToolErrorOwnership();
3553
3689
  /**
3554
3690
  * Claim this batch's turn synchronously from the registry (or
3555
3691
  * fall back to 0 when the feature is disabled). The registry is
@@ -3595,6 +3731,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3595
3731
  turn,
3596
3732
  batchScopeId,
3597
3733
  resolvedArgsByCallId,
3734
+ errorOwnership,
3598
3735
  additionalContextsSink: directAdditionalContexts,
3599
3736
  runInput: sendState as T,
3600
3737
  }
@@ -3618,7 +3755,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3618
3755
  // HumanMessage isn't a tool result.
3619
3756
  [sendOutput],
3620
3757
  config,
3621
- resolvedArgsByCallId
3758
+ resolvedArgsByCallId,
3759
+ errorOwnership
3622
3760
  );
3623
3761
  } else {
3624
3762
  let messages: BaseMessage[];
@@ -3743,7 +3881,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3743
3881
  for (const entry of eventEntries) {
3744
3882
  if (entry.call.id != null) {
3745
3883
  const { resolved, unresolved } = preBatchSnapshot.resolve(
3746
- entry.call.args as Record<string, unknown>
3884
+ entry.call.args as Record<string, unknown>,
3885
+ {
3886
+ substituteIntentKey: this.toolDeclaresBusinessIntent(
3887
+ entry.call.name
3888
+ ),
3889
+ }
3747
3890
  );
3748
3891
  preResolvedEventArgs.set(entry.call.id, {
3749
3892
  resolved: resolved as Record<string, unknown>,
@@ -3768,6 +3911,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3768
3911
  turn,
3769
3912
  batchScopeId,
3770
3913
  resolvedArgsByCallId,
3914
+ errorOwnership,
3771
3915
  preBatchSnapshot,
3772
3916
  additionalContextsSink: directAdditionalContexts,
3773
3917
  runInput: input as T,
@@ -3780,7 +3924,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3780
3924
  directCalls,
3781
3925
  directOutputs,
3782
3926
  config,
3783
- resolvedArgsByCallId
3927
+ resolvedArgsByCallId,
3928
+ errorOwnership
3784
3929
  );
3785
3930
  }
3786
3931
 
@@ -3833,6 +3978,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3833
3978
  turn,
3834
3979
  batchScopeId,
3835
3980
  resolvedArgsByCallId,
3981
+ errorOwnership,
3836
3982
  preBatchSnapshot,
3837
3983
  additionalContextsSink: directAdditionalContexts,
3838
3984
  runInput: input as T,
@@ -3842,7 +3988,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3842
3988
  filteredCalls,
3843
3989
  toolOutputs,
3844
3990
  config,
3845
- resolvedArgsByCallId
3991
+ resolvedArgsByCallId,
3992
+ errorOwnership
3846
3993
  );
3847
3994
  // Append accumulated additionalContexts as a single
3848
3995
  // HumanMessage so the next model turn sees them. Codex P2 #39.
@@ -24,6 +24,7 @@ import fetch, { RequestInit } from 'node-fetch';
24
24
  import { HttpsProxyAgent } from 'https-proxy-agent';
25
25
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
26
26
  import type * as t from '@/types';
27
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
27
28
  import { getCodeBaseURL } from './CodeExecutor';
28
29
  import { Constants } from '@/common';
29
30
 
@@ -51,6 +52,7 @@ const MCP_SERVER_DESCRIPTION =
51
52
  export const ToolSearchToolSchema = {
52
53
  type: 'object',
53
54
  properties: {
55
+ intent: { ...INTENT_PROPERTY },
54
56
  query: {
55
57
  type: 'string',
56
58
  maxLength: MAX_PATTERN_LENGTH,
@@ -117,6 +119,7 @@ function createToolSearchSchema(mode: t.ToolSearchMode): ToolSearchSchema {
117
119
  return {
118
120
  type: 'object',
119
121
  properties: {
122
+ intent: { ...INTENT_PROPERTY },
120
123
  query: {
121
124
  type: 'string',
122
125
  maxLength: MAX_PATTERN_LENGTH,
@@ -560,3 +560,177 @@ describe('ToolNode per-call onResult completion emission', () => {
560
560
  );
561
561
  });
562
562
  });
563
+
564
+ describe('ToolNode returned-error completions', () => {
565
+ afterEach(() => {
566
+ jest.restoreAllMocks();
567
+ });
568
+
569
+ /**
570
+ * A tool that RETURNS an error ToolMessage never enters the catch path, so
571
+ * the errorHandler never runs and cannot have dispatched. The output loop
572
+ * must therefore emit the completion — and with it the tool's authored
573
+ * failure label — instead of assuming the handler owned it.
574
+ */
575
+ it('emits a completion (and authored outcome) for a RETURNED error ToolMessage', async () => {
576
+ const completions: Array<{
577
+ result: { tool_call: { id: string; outcome?: string } };
578
+ }> = [];
579
+ jest
580
+ .spyOn(events, 'safeDispatchCustomEvent')
581
+ .mockImplementation(async (event, data): Promise<void> => {
582
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
583
+ completions.push(
584
+ data as { result: { tool_call: { id: string; outcome?: string } } }
585
+ );
586
+ }
587
+ });
588
+
589
+ const returnsError = tool(
590
+ async () =>
591
+ new ToolMessage({
592
+ content: 'boom',
593
+ tool_call_id: 'call_fail',
594
+ status: 'error',
595
+ artifact: { outcome: 'Search failed for OAuth' },
596
+ }),
597
+ {
598
+ name: 'failing',
599
+ description: 'returns an error message',
600
+ schema: z.object({}).passthrough(),
601
+ }
602
+ ) as unknown as StructuredToolInterface;
603
+
604
+ const errorHandler = jest.fn(async () => true);
605
+ const toolNode = new ToolNode({
606
+ tools: [returnsError],
607
+ toolCallStepIds: new Map([['call_fail', 'step_fail']]),
608
+ errorHandler: errorHandler as unknown as t.ToolNodeConstructorParams['errorHandler'],
609
+ });
610
+
611
+ await toolNode.invoke({
612
+ messages: [
613
+ createAIMessageWithToolCalls([
614
+ {
615
+ id: 'call_fail',
616
+ name: 'failing',
617
+ args: { intent: 'Searching for OAuth handling' },
618
+ },
619
+ ]),
620
+ ],
621
+ });
622
+ await flushAsync();
623
+
624
+ expect(errorHandler).not.toHaveBeenCalled();
625
+ const completion = completions.find(
626
+ (c) => c.result.tool_call.id === 'call_fail'
627
+ );
628
+ expect(completion).toBeDefined();
629
+ expect(completion?.result.tool_call.outcome).toBe('Search failed for OAuth');
630
+ });
631
+ });
632
+
633
+ describe('ToolNode error-ownership scoping', () => {
634
+ afterEach(() => {
635
+ jest.restoreAllMocks();
636
+ });
637
+
638
+ /**
639
+ * Tool-call ids are provider-scoped and synthetic ids can repeat, so
640
+ * ownership markers kept on the INSTANCE cross-consume between that
641
+ * instance's concurrent invocations.
642
+ *
643
+ * Interleaving that exposes it: invocation 1 batches a throwing call
644
+ * (whose handler claims ownership) alongside a slow call that keeps the
645
+ * batch — and therefore its output loop — pending. While it is parked,
646
+ * invocation 2 reuses the same id and RETURNS an error message. With
647
+ * instance-scoped markers, invocation 2's output loop consumes the
648
+ * marker invocation 1 set and drops its only completion.
649
+ */
650
+ it('does not let a pending invocation\'s marker suppress a concurrent call reusing the id', async () => {
651
+ const completions: string[] = [];
652
+ jest
653
+ .spyOn(events, 'safeDispatchCustomEvent')
654
+ .mockImplementation(async (event, data): Promise<void> => {
655
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
656
+ completions.push(
657
+ (data as { result: { tool_call: { id: string } } }).result.tool_call
658
+ .id
659
+ );
660
+ }
661
+ });
662
+
663
+ const shared = 'call_shared';
664
+ let releaseSlow: (() => void) | undefined;
665
+ const slowGate = new Promise<void>((resolve) => {
666
+ releaseSlow = resolve;
667
+ });
668
+
669
+ const thrower = tool(
670
+ async () => {
671
+ throw new Error('boom');
672
+ },
673
+ { name: 'thrower', description: 'throws', schema: z.object({}).passthrough() }
674
+ ) as unknown as StructuredToolInterface;
675
+ const slow = tool(
676
+ async () => {
677
+ await slowGate;
678
+ return 'done';
679
+ },
680
+ { name: 'slow', description: 'parks the batch', schema: z.object({}).passthrough() }
681
+ ) as unknown as StructuredToolInterface;
682
+ const returner = tool(
683
+ async () =>
684
+ new ToolMessage({
685
+ content: 'failed',
686
+ tool_call_id: shared,
687
+ status: 'error',
688
+ }),
689
+ {
690
+ name: 'returner',
691
+ description: 'returns an error message',
692
+ schema: z.object({}).passthrough(),
693
+ }
694
+ ) as unknown as StructuredToolInterface;
695
+
696
+ /** ONE instance — the shared state the finding is about. */
697
+ const node = new ToolNode({
698
+ tools: [thrower, slow, returner],
699
+ toolCallStepIds: new Map([
700
+ [shared, 'step_shared'],
701
+ ['call_slow', 'step_slow'],
702
+ ]),
703
+ errorHandler: (async () =>
704
+ true) as unknown as t.ToolNodeConstructorParams['errorHandler'],
705
+ });
706
+
707
+ // Invocation 1: throws (claiming ownership of `shared`) and parks.
708
+ const pending = node.invoke({
709
+ messages: [
710
+ createAIMessageWithToolCalls([
711
+ { id: shared, name: 'thrower', args: {} },
712
+ { id: 'call_slow', name: 'slow', args: {} },
713
+ ]),
714
+ ],
715
+ }) as Promise<unknown>;
716
+ await flushAsync();
717
+
718
+ // Invocation 2, while invocation 1 is still parked.
719
+ const before = completions.length;
720
+ await node.invoke({
721
+ messages: [
722
+ createAIMessageWithToolCalls([
723
+ { id: shared, name: 'returner', args: {} },
724
+ ]),
725
+ ],
726
+ });
727
+ await flushAsync();
728
+ const emittedBySecond = completions.slice(before);
729
+
730
+ releaseSlow?.();
731
+ await pending;
732
+ await flushAsync();
733
+
734
+ expect(emittedBySecond).toContain(shared);
735
+ });
736
+ });