@librechat/agents 3.7.10 → 3.7.12

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 (60) hide show
  1. package/dist/cjs/graphs/Graph.cjs +11 -1
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +36 -3
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/langfuse.cjs +6 -2
  6. package/dist/cjs/langfuse.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +2 -0
  8. package/dist/cjs/messages/format.cjs +6 -34
  9. package/dist/cjs/messages/format.cjs.map +1 -1
  10. package/dist/cjs/run.cjs +5 -1
  11. package/dist/cjs/run.cjs.map +1 -1
  12. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +22 -6
  13. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  14. package/dist/cjs/tools/ProgrammaticCallerPolicy.cjs +10 -0
  15. package/dist/cjs/tools/ProgrammaticCallerPolicy.cjs.map +1 -1
  16. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +43 -6
  17. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +12 -2
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +5 -3
  21. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
  22. package/dist/esm/graphs/Graph.mjs +11 -1
  23. package/dist/esm/graphs/Graph.mjs.map +1 -1
  24. package/dist/esm/graphs/MultiAgentGraph.mjs +36 -3
  25. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  26. package/dist/esm/langfuse.mjs +6 -2
  27. package/dist/esm/langfuse.mjs.map +1 -1
  28. package/dist/esm/main.mjs +2 -2
  29. package/dist/esm/messages/format.mjs +6 -34
  30. package/dist/esm/messages/format.mjs.map +1 -1
  31. package/dist/esm/run.mjs +5 -1
  32. package/dist/esm/run.mjs.map +1 -1
  33. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +23 -7
  34. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  35. package/dist/esm/tools/ProgrammaticCallerPolicy.mjs +10 -1
  36. package/dist/esm/tools/ProgrammaticCallerPolicy.mjs.map +1 -1
  37. package/dist/esm/tools/ProgrammaticToolCalling.mjs +42 -7
  38. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +12 -2
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +5 -3
  42. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
  43. package/dist/types/langfuse.d.ts +5 -1
  44. package/dist/types/messages/format.d.ts +2 -1
  45. package/dist/types/tools/ProgrammaticCallerPolicy.d.ts +12 -0
  46. package/dist/types/tools/ProgrammaticToolCalling.d.ts +35 -0
  47. package/dist/types/tools/ToolNode.d.ts +4 -1
  48. package/dist/types/types/tools.d.ts +5 -0
  49. package/package.json +1 -1
  50. package/src/graphs/Graph.ts +10 -0
  51. package/src/graphs/MultiAgentGraph.ts +71 -2
  52. package/src/langfuse.ts +12 -0
  53. package/src/messages/format.ts +23 -70
  54. package/src/run.ts +4 -0
  55. package/src/tools/BashProgrammaticToolCalling.ts +57 -19
  56. package/src/tools/ProgrammaticCallerPolicy.ts +31 -0
  57. package/src/tools/ProgrammaticToolCalling.ts +132 -19
  58. package/src/tools/ToolNode.ts +19 -0
  59. package/src/tools/local/LocalProgrammaticToolCalling.ts +19 -8
  60. package/src/types/tools.ts +5 -0
@@ -2620,6 +2620,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2620
2620
  // run_id); `executingAgentId` always identifies the owning agent.
2621
2621
  agentId: this.subagentScope ? agentContext?.agentId : undefined,
2622
2622
  executingAgentId: agentContext?.agentId,
2623
+ executingAgentName: agentContext?.name,
2624
+ rootAgentId: this.defaultAgentId,
2625
+ rootAgentName: this.agentContexts.get(this.defaultAgentId)?.name,
2623
2626
  toolCallStepIds: this.toolCallStepIds,
2624
2627
  toolRegistry: agentContext?.toolRegistry,
2625
2628
  getDiscoveredToolNames: (): readonly string[] =>
@@ -2697,6 +2700,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2697
2700
  // hooks can attribute the batch even at the top level.
2698
2701
  agentId: this.subagentScope ? agentContext?.agentId : undefined,
2699
2702
  executingAgentId: agentContext?.agentId,
2703
+ executingAgentName: agentContext?.name,
2704
+ rootAgentId: this.defaultAgentId,
2705
+ rootAgentName: this.agentContexts.get(this.defaultAgentId)?.name,
2700
2706
  toolCallStepIds: this.toolCallStepIds,
2701
2707
  errorHandler: (data, metadata): Promise<boolean> =>
2702
2708
  StandardGraph.handleToolCallErrorStatic(this, data, metadata),
@@ -3815,6 +3821,10 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3815
3821
  parentMessageId: config.configurable?.requestBody?.parentMessageId,
3816
3822
  agentId,
3817
3823
  agentName: agentContext.name,
3824
+ rootAgentId: this.defaultAgentId,
3825
+ rootAgentName: this.agentContexts.get(this.defaultAgentId)?.name,
3826
+ activeAgentId: agentId,
3827
+ activeAgentName: agentContext.name,
3818
3828
  });
3819
3829
  let langfuseHandler: CallbackEntry | undefined;
3820
3830
  let invokeConfig = {
@@ -101,6 +101,14 @@ function isTransferToolName(name: unknown): boolean {
101
101
  );
102
102
  }
103
103
 
104
+ function graphToolName(tool: unknown): string | undefined {
105
+ if (tool == null || typeof tool !== 'object' || !('name' in tool)) {
106
+ return undefined;
107
+ }
108
+ const { name } = tool;
109
+ return typeof name === 'string' ? name : undefined;
110
+ }
111
+
104
112
  /**
105
113
  * Drop transfer `tool_use` content blocks from an AI message's array content.
106
114
  * Companion to the reception's tool-call filtering: array-content providers
@@ -287,6 +295,21 @@ function withHandoffGroupMetadata(
287
295
  };
288
296
  }
289
297
 
298
+ function withActiveAgentMetadata(
299
+ config: LangGraphRunnableConfig | undefined,
300
+ agentId: string,
301
+ agentName: string | undefined
302
+ ): LangGraphRunnableConfig {
303
+ return {
304
+ ...config,
305
+ metadata: {
306
+ ...config?.metadata,
307
+ activeAgentId: agentId,
308
+ ...(agentName == null ? {} : { activeAgentName: agentName }),
309
+ },
310
+ };
311
+ }
312
+
290
313
  /**
291
314
  * MultiAgentGraph extends StandardGraph to support dynamic multi-agent workflows
292
315
  * with handoffs, fan-in/fan-out, and other composable patterns.
@@ -594,6 +617,24 @@ export class MultiAgentGraph extends StandardGraph {
594
617
  private createHandoffTools(): void {
595
618
  // Group handoff edges by source agent(s)
596
619
  const handoffsByAgent = new Map<string, t.GraphEdge[]>();
620
+ const tokenAccountingRefresh = new Set<string>();
621
+
622
+ /** Transfer tool names are SDK-reserved. Remove externally supplied or
623
+ * stale transfer tools before deriving the source agent's allowed set
624
+ * from the graph edges below. */
625
+ for (const agentContext of this.agentContexts.values()) {
626
+ const originalTools = agentContext.graphTools;
627
+ const retainedTools = agentContext.graphTools?.filter(
628
+ (graphTool) => !isTransferToolName(graphToolName(graphTool))
629
+ );
630
+ if (retainedTools?.length !== originalTools?.length) {
631
+ tokenAccountingRefresh.add(agentContext.agentId);
632
+ }
633
+ agentContext.graphTools =
634
+ retainedTools != null && retainedTools.length > 0
635
+ ? retainedTools
636
+ : undefined;
637
+ }
597
638
 
598
639
  // Only process handoff edges for tool creation
599
640
  for (const edge of this.handoffEdges) {
@@ -643,6 +684,29 @@ export class MultiAgentGraph extends StandardGraph {
643
684
  for (const handoffTool of handoffTools) {
644
685
  agentContext.graphTools.push(handoffTool);
645
686
  }
687
+ if (handoffTools.length > 0) {
688
+ tokenAccountingRefresh.add(agentId);
689
+ }
690
+ }
691
+
692
+ for (const agentId of tokenAccountingRefresh) {
693
+ const agentContext = this.agentContexts.get(agentId);
694
+ if (agentContext?.tokenCounter == null) {
695
+ continue;
696
+ }
697
+ const { tokenCounter, baseIndexTokenCountMap } = agentContext;
698
+ agentContext.tokenCalculationPromise = agentContext
699
+ .calculateInstructionTokens(tokenCounter)
700
+ .then(() => {
701
+ agentContext.updateTokenMapWithInstructions(baseIndexTokenCountMap);
702
+ })
703
+ .catch((err) => {
704
+ // eslint-disable-next-line no-console
705
+ console.error(
706
+ 'Error recalculating instruction tokens after handoff tool updates:',
707
+ err
708
+ );
709
+ });
646
710
  }
647
711
  }
648
712
 
@@ -1239,10 +1303,16 @@ export class MultiAgentGraph extends StandardGraph {
1239
1303
  ): Promise<t.MultiAgentGraphState | Command> => {
1240
1304
  let result: t.MultiAgentGraphState;
1241
1305
  let inputMessages = state.messages;
1242
- const memberConfig =
1306
+ const agentContext = this.agentContexts.get(agentId);
1307
+ const recursionLimitedConfig =
1243
1308
  this.memberRecursionLimit == null
1244
1309
  ? config
1245
1310
  : { ...config, recursionLimit: this.memberRecursionLimit };
1311
+ const memberConfig = withActiveAgentMetadata(
1312
+ recursionLimitedConfig,
1313
+ agentId,
1314
+ agentContext?.name
1315
+ );
1246
1316
 
1247
1317
  /**
1248
1318
  * Check if this agent is receiving a handoff.
@@ -1254,7 +1324,6 @@ export class MultiAgentGraph extends StandardGraph {
1254
1324
  state.messages,
1255
1325
  agentId
1256
1326
  );
1257
- const agentContext = this.agentContexts.get(agentId);
1258
1327
 
1259
1328
  if (
1260
1329
  handoffContext?.sourceAgentName != null &&
package/src/langfuse.ts CHANGED
@@ -783,17 +783,29 @@ export function createLangfuseTraceMetadata({
783
783
  parentMessageId,
784
784
  agentId,
785
785
  agentName,
786
+ rootAgentId,
787
+ rootAgentName,
788
+ activeAgentId,
789
+ activeAgentName,
786
790
  }: {
787
791
  messageId?: unknown;
788
792
  parentMessageId?: unknown;
789
793
  agentId?: unknown;
790
794
  agentName?: unknown;
795
+ rootAgentId?: unknown;
796
+ rootAgentName?: unknown;
797
+ activeAgentId?: unknown;
798
+ activeAgentName?: unknown;
791
799
  }): LangfuseTraceMetadata {
792
800
  return createTraceMetadata({
793
801
  messageId,
794
802
  parentMessageId,
795
803
  agentId,
796
804
  agentName,
805
+ rootAgentId,
806
+ rootAgentName,
807
+ activeAgentId,
808
+ activeAgentName,
797
809
  });
798
810
  }
799
811
 
@@ -2771,12 +2771,22 @@ function extractSkillName(args: unknown): string | undefined {
2771
2771
  return typeof name === 'string' && name !== '' ? name : undefined;
2772
2772
  }
2773
2773
 
2774
+ function isSdkManagedToolName(name: unknown): name is string {
2775
+ return (
2776
+ typeof name === 'string' &&
2777
+ (name === Constants.SUBAGENT ||
2778
+ name === 'conditional_transfer' ||
2779
+ name.startsWith(Constants.LC_TRANSFER_TO_))
2780
+ );
2781
+ }
2782
+
2774
2783
  /**
2775
2784
  * Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
2776
2785
  *
2777
2786
  * @param payload - The array of messages to format.
2778
2787
  * @param indexTokenCountMap - Optional map of message indices to token counts.
2779
- * @param tools - Optional set of tool names that are allowed in the request.
2788
+ * @param tools - Optional set of host-managed tool names allowed in the request. SDK-managed tool
2789
+ * names are preserved independently.
2780
2790
  * @param skills - Optional map of skill name to body for reconstructing skill HumanMessages.
2781
2791
  * @param options - Optional formatting options (provider, skipSkillBodyNames).
2782
2792
  * @returns - Object containing formatted messages and updated indexTokenCountMap if provided.
@@ -2957,8 +2967,8 @@ export const formatAgentMessages = (
2957
2967
 
2958
2968
  /**
2959
2969
  * If tools set is provided, process tool_calls:
2960
- * - Keep valid tool_calls (tools in the set or dynamically discovered)
2961
- * - Convert invalid tool_calls to string representation for context preservation
2970
+ * - Keep valid tool_calls (tools in the set, SDK-managed, or dynamically discovered)
2971
+ * - Omit invalid tool_calls without exposing their output as assistant-authored text
2962
2972
  * - Dynamically expand the set when tool_search results are encountered
2963
2973
  */
2964
2974
  let processedMessage = message;
@@ -2977,8 +2987,6 @@ export const formatAgentMessages = (
2977
2987
  const filteredContent: typeof content = [];
2978
2988
  const filteredSourceContentPartIndices: SourceContentPartIndices[] = [];
2979
2989
  const invalidToolCallIds = new Set<string>();
2980
- const invalidToolStrings: string[] = [];
2981
- const invalidToolSourceContentPartIndices: number[] = [];
2982
2990
 
2983
2991
  for (let partIndex = 0; partIndex < content.length; partIndex++) {
2984
2992
  const part = content[partIndex] as
@@ -3029,7 +3037,10 @@ export const formatAgentMessages = (
3029
3037
  }
3030
3038
  }
3031
3039
 
3032
- if (discoveredTools.has(toolName)) {
3040
+ if (
3041
+ discoveredTools.has(toolName) ||
3042
+ isSdkManagedToolName(toolName)
3043
+ ) {
3033
3044
  filteredContent.push(part);
3034
3045
  filteredSourceContentPartIndices.push(partSourceContentIndices);
3035
3046
  if (
@@ -3042,20 +3053,11 @@ export const formatAgentMessages = (
3042
3053
  (pendingSkillNames ??= new Set()).add(skillName);
3043
3054
  }
3044
3055
  }
3045
- } else {
3046
- /** Invalid tool - convert to string for context preservation */
3047
- if (
3048
- typeof part.tool_call.id === 'string' &&
3049
- part.tool_call.id !== ''
3050
- ) {
3051
- invalidToolCallIds.add(part.tool_call.id);
3052
- }
3053
- const output = part.tool_call.output ?? '';
3054
- invalidToolStrings.push(`Tool: ${toolName}, ${output}`);
3055
- appendSourceContentPartIndices(
3056
- invalidToolSourceContentPartIndices,
3057
- partSourceContentIndices
3058
- );
3056
+ } else if (
3057
+ typeof part.tool_call.id === 'string' &&
3058
+ part.tool_call.id !== ''
3059
+ ) {
3060
+ invalidToolCallIds.add(part.tool_call.id);
3059
3061
  }
3060
3062
  }
3061
3063
 
@@ -3076,57 +3078,8 @@ export const formatAgentMessages = (
3076
3078
  }
3077
3079
  }
3078
3080
 
3079
- /** Append invalid tool strings to the content for context preservation */
3080
- if (invalidToolStrings.length > 0) {
3081
- /** Find the last text part or create one */
3082
- let lastTextPartIndex = -1;
3083
- for (let j = filteredContent.length - 1; j >= 0; j--) {
3084
- if (filteredContent[j].type === ContentTypes.TEXT) {
3085
- lastTextPartIndex = j;
3086
- break;
3087
- }
3088
- }
3089
-
3090
- const invalidToolText = invalidToolStrings.join('\n');
3091
- if (lastTextPartIndex >= 0) {
3092
- const lastTextPart = filteredContent[lastTextPartIndex] as {
3093
- type: string;
3094
- [ContentTypes.TEXT]?: string;
3095
- text?: string;
3096
- };
3097
- const existingText =
3098
- lastTextPart[ContentTypes.TEXT] ?? lastTextPart.text ?? '';
3099
- filteredContent[lastTextPartIndex] = {
3100
- ...lastTextPart,
3101
- [ContentTypes.TEXT]: existingText
3102
- ? `${existingText}\n${invalidToolText}`
3103
- : invalidToolText,
3104
- };
3105
- const lastTextSourceContentPartIndices =
3106
- filteredSourceContentPartIndices[lastTextPartIndex];
3107
- filteredSourceContentPartIndices[lastTextPartIndex] = [
3108
- ...(typeof lastTextSourceContentPartIndices === 'number'
3109
- ? [lastTextSourceContentPartIndices]
3110
- : lastTextSourceContentPartIndices),
3111
- ...invalidToolSourceContentPartIndices,
3112
- ];
3113
- } else {
3114
- /** No text part exists, create one */
3115
- filteredContent.push({
3116
- type: ContentTypes.TEXT,
3117
- [ContentTypes.TEXT]: invalidToolText,
3118
- });
3119
- filteredSourceContentPartIndices.push(
3120
- invalidToolSourceContentPartIndices
3121
- );
3122
- }
3123
- }
3124
-
3125
3081
  /** Use filtered content if we made any changes */
3126
- if (
3127
- filteredContent.length !== content.length ||
3128
- invalidToolStrings.length > 0
3129
- ) {
3082
+ if (filteredContent.length !== content.length) {
3130
3083
  processedMessage = { ...message, content: filteredContent };
3131
3084
  processedSourceContentPartIndices = filteredSourceContentPartIndices;
3132
3085
  }
package/src/run.ts CHANGED
@@ -1311,6 +1311,10 @@ export class Run<_T extends t.BaseGraphState> {
1311
1311
  parentMessageId: config.configurable?.requestBody?.parentMessageId,
1312
1312
  agentId: graph.defaultAgentId,
1313
1313
  agentName: primaryContext?.name,
1314
+ rootAgentId: graph.defaultAgentId,
1315
+ rootAgentName: primaryContext?.name,
1316
+ activeAgentId: graph.defaultAgentId,
1317
+ activeAgentName: primaryContext?.name,
1314
1318
  });
1315
1319
  const traceName = config.runName ?? getLangfuseTraceName(traceMetadata);
1316
1320
  const streamLangfuseConfig = this.getStreamLangfuseConfig(graph);
@@ -14,11 +14,7 @@ import {
14
14
  selectRuntimeSessionHint,
15
15
  } from './CodeExecutor';
16
16
  import {
17
- makeRequest,
18
- executeTools,
19
- formatCompletedResponse,
20
- } from './ProgrammaticToolCalling';
21
- import {
17
+ assertUnambiguousIdentifiers,
22
18
  projectProgrammaticToolMap,
23
19
  resolveProgrammaticToolDefinitions,
24
20
  selectProgrammaticTools,
@@ -29,6 +25,12 @@ import {
29
25
  createCodeApiRunTimeoutSchema,
30
26
  resolveCodeApiRunTimeoutMs,
31
27
  } from './ptcTimeout';
28
+ import {
29
+ makeRequest,
30
+ executeTools,
31
+ runPlainExecution,
32
+ formatCompletedResponse,
33
+ } from './ProgrammaticToolCalling';
32
34
  import { INTENT_PROPERTY } from '@/tools/intentArg';
33
35
  import { Constants } from '@/common';
34
36
 
@@ -117,7 +119,7 @@ ${CORE_RULES}`;
117
119
 
118
120
  const TOOL_MANIFEST_DESCRIPTION =
119
121
  'Exact registered tool names used by the code. Required when direct-only tools are configured; ' +
120
- 'validated before execution starts.';
122
+ 'validated before execution starts. Pass [] when the code calls no tools at all.';
121
123
 
122
124
  // ============================================================================
123
125
  // Schema
@@ -361,22 +363,41 @@ export function createBashProgrammaticToolCallingTool(
361
363
  programmaticToolName,
362
364
  });
363
365
 
364
- if (toolMap == null || toolMap.size === 0) {
365
- throw new Error(
366
- 'No toolMap provided. ' +
367
- 'ToolNode should inject this from AgentContext when invoked through the graph.'
368
- );
369
- }
366
+ /* These guard the replay path only. A call that selected no tools never
367
+ * replays, and event-driven ToolNode configurations legitimately inject
368
+ * an empty toolMap/toolDefs. Injected context is still required to
369
+ * conclude that with nothing injected at all, an empty selection means
370
+ * the host wired the tool up wrong. */
371
+ const needsNoTools =
372
+ effectiveTools.length === 0 &&
373
+ (params.tool_manifest?.length === 0 ||
374
+ toolDefs != null ||
375
+ disallowedToolDefs != null);
376
+
377
+ if (!needsNoTools) {
378
+ if (toolMap == null || toolMap.size === 0) {
379
+ throw new Error(
380
+ 'No toolMap provided. ' +
381
+ 'ToolNode should inject this from AgentContext when invoked through the graph.'
382
+ );
383
+ }
370
384
 
371
- if (toolDefs == null || toolDefs.length === 0) {
372
- throw new Error(
373
- 'No tool definitions provided. ' +
374
- 'Either pass tools in the input or ensure ToolNode injects toolDefs.'
375
- );
385
+ if (toolDefs == null || toolDefs.length === 0) {
386
+ throw new Error(
387
+ 'No tool definitions provided. ' +
388
+ 'Either pass tools in the input or ensure ToolNode injects toolDefs.'
389
+ );
390
+ }
376
391
  }
377
392
 
393
+ assertUnambiguousIdentifiers(
394
+ effectiveTools,
395
+ normalizeToBashIdentifier,
396
+ programmaticToolName
397
+ );
398
+
378
399
  const effectiveToolMap = projectProgrammaticToolMap(
379
- toolMap,
400
+ toolMap ?? new Map(),
380
401
  effectiveTools
381
402
  );
382
403
 
@@ -391,7 +412,7 @@ export function createBashProgrammaticToolCallingTool(
391
412
  // eslint-disable-next-line no-console
392
413
  console.log(
393
414
  `[BashPTC Debug] Sending ${effectiveTools.length} tools to API ` +
394
- `(selected from ${toolDefs.length})`
415
+ `(selected from ${toolDefs?.length ?? 0})`
395
416
  );
396
417
  }
397
418
 
@@ -423,6 +444,23 @@ export function createBashProgrammaticToolCallingTool(
423
444
  ? selectedRuntimeSessionHint
424
445
  : undefined;
425
446
 
447
+ /* Raw `code`, not `preparedCode`: the `$!` guard exists for the
448
+ * programmatic replay wrapper, which plain `/exec` never applies. */
449
+ if (needsNoTools) {
450
+ return await runPlainExecution({
451
+ baseUrl,
452
+ lang: 'bash',
453
+ code,
454
+ timeout,
455
+ sessionId: session_id,
456
+ files,
457
+ runtimeSessionHint,
458
+ proxy,
459
+ authHeaders: initParams.authHeaders,
460
+ executionProfile: initParams.executionProfile,
461
+ });
462
+ }
463
+
426
464
  let response = await makeRequest(
427
465
  EXEC_ENDPOINT,
428
466
  {
@@ -43,6 +43,37 @@ export function assertDisallowedToolUsage(
43
43
  );
44
44
  }
45
45
 
46
+ /**
47
+ * Rejects a stub set holding two tools that collapse to one runtime identifier.
48
+ *
49
+ * Stub generation binds by identifier, so such tools are indistinguishable once
50
+ * emitted — the generated program defines both and the later one wins. Failing
51
+ * beats dispatching by declaration order.
52
+ *
53
+ * Called on the set each backend actually emits, not on the selection: a
54
+ * definition dropped before codegen never binds an identifier, so a collision
55
+ * among such definitions is not a conflict.
56
+ */
57
+ export function assertUnambiguousIdentifiers(
58
+ selectedToolDefs: readonly t.LCTool[],
59
+ normalizeIdentifier: (name: string) => string,
60
+ programmaticToolName: string
61
+ ): void {
62
+ const seen = new Map<string, string>();
63
+ for (const toolDef of selectedToolDefs) {
64
+ const identifier = normalizeIdentifier(toolDef.name);
65
+ const prior = seen.get(identifier);
66
+ if (prior != null && prior !== toolDef.name) {
67
+ throw new Error(
68
+ `Tools "${prior}" and "${toolDef.name}" both become "${identifier}" in ` +
69
+ `the sandbox, so "${programmaticToolName}" cannot tell which one the ` +
70
+ 'code means. Call them directly, or rename one.'
71
+ );
72
+ }
73
+ seen.set(identifier, toolDef.name);
74
+ }
75
+ }
76
+
46
77
  export function selectProgrammaticTools(args: {
47
78
  requestedToolNames?: string[];
48
79
  allowedToolDefs?: t.LCTool[];
@@ -22,16 +22,17 @@ import {
22
22
  selectRuntimeSessionHint,
23
23
  } from './CodeExecutor';
24
24
  import {
25
- clampCodeApiRunTimeoutMs,
26
- createCodeApiRunTimeoutSchema,
27
- resolveCodeApiRunTimeoutMs,
28
- } from './ptcTimeout';
29
- import {
25
+ assertUnambiguousIdentifiers,
30
26
  projectProgrammaticToolMap,
31
27
  resolveProgrammaticToolDefinitions,
32
28
  selectProgrammaticTools,
33
29
  type ProgrammaticInvocationParams,
34
30
  } from './ProgrammaticCallerPolicy';
31
+ import {
32
+ clampCodeApiRunTimeoutMs,
33
+ createCodeApiRunTimeoutSchema,
34
+ resolveCodeApiRunTimeoutMs,
35
+ } from './ptcTimeout';
35
36
  import { resolveFetchProxyAgent } from '@/utils/proxy';
36
37
  import { INTENT_PROPERTY } from '@/tools/intentArg';
37
38
  import { Constants } from '@/common';
@@ -95,7 +96,7 @@ ${CORE_RULES}`;
95
96
 
96
97
  const TOOL_MANIFEST_DESCRIPTION =
97
98
  'Exact registered tool names used by the code. Required when direct-only tools are configured; ' +
98
- 'validated before execution starts.';
99
+ 'validated before execution starts. Pass [] when the code calls no tools at all.';
99
100
 
100
101
  export function createProgrammaticToolCallingSchema(
101
102
  maxRunTimeoutMs = DEFAULT_RUN_TIMEOUT_MS
@@ -874,6 +875,84 @@ export function formatCompletedResponse(
874
875
  ];
875
876
  }
876
877
 
878
+ /**
879
+ * Mirrors the Code API's programmatic Python wrapper (`wrapUserCodeInAsync`).
880
+ *
881
+ * The programmatic contract promises top-level `await` works; plain `/exec`
882
+ * applies no wrapper, so forwarding source unchanged would turn valid
883
+ * programmatic input into a SyntaxError. The wrapper is self-contained, so a
884
+ * later divergence in the Code API's own copy cannot break this path.
885
+ */
886
+ export function wrapPythonForPlainExecution(userCode: string): string {
887
+ const indented = userCode
888
+ .split('\n')
889
+ .map((line) => (line.trim() === '' ? '' : ` ${line}`))
890
+ .join('\n');
891
+
892
+ return (
893
+ 'async def __user_main__():\n' +
894
+ ' """Auto-generated wrapper for user code to support top-level await"""\n' +
895
+ `${indented}\n` +
896
+ '\n' +
897
+ 'if __name__ == "__main__":\n' +
898
+ ' import asyncio\n' +
899
+ ' asyncio.run(__user_main__())\n'
900
+ );
901
+ }
902
+
903
+ /**
904
+ * Runs code that selected no tools through plain `/exec`.
905
+ *
906
+ * `/exec/programmatic` rejects an empty tool manifest outright, and the sandbox
907
+ * has nothing to inject for such a call anyway — no stubs, no replay round
908
+ * trips. Routing it here makes a tool-free programmatic call behave exactly like
909
+ * the equivalent `execute_code`, against every deployed Code API, instead of
910
+ * failing with a rejection the model cannot act on.
911
+ */
912
+ export async function runPlainExecution(args: {
913
+ baseUrl: string;
914
+ lang: 'bash' | 'py';
915
+ code: string;
916
+ /**
917
+ * Forwarded so the model-supplied cap applies as soon as the Code API honors
918
+ * it on `/exec`, which does not read `timeout` today. Session affinity and
919
+ * input files are honored there, so those ride the same body.
920
+ */
921
+ timeout?: number;
922
+ sessionId?: string;
923
+ files?: t.CodeEnvFile[];
924
+ runtimeSessionHint?: string;
925
+ proxy?: string;
926
+ authHeaders?: t.CodeApiAuthHeaders;
927
+ executionProfile?: t.CodeApiExecutionProfile;
928
+ }): Promise<[string, t.ProgrammaticExecutionArtifact]> {
929
+ const response = await makeRequest(
930
+ buildCodeApiEndpoint(args.baseUrl, 'exec'),
931
+ {
932
+ lang: args.lang,
933
+ code:
934
+ args.lang === 'py'
935
+ ? wrapPythonForPlainExecution(args.code)
936
+ : args.code,
937
+ ...(args.timeout != null ? { timeout: args.timeout } : {}),
938
+ ...(args.sessionId != null && args.sessionId !== ''
939
+ ? { session_id: args.sessionId }
940
+ : {}),
941
+ ...(args.files != null && args.files.length > 0
942
+ ? { files: args.files }
943
+ : {}),
944
+ ...(args.runtimeSessionHint != null
945
+ ? { runtime_session_hint: args.runtimeSessionHint }
946
+ : {}),
947
+ },
948
+ args.proxy,
949
+ args.authHeaders,
950
+ args.executionProfile
951
+ );
952
+
953
+ return formatCompletedResponse(response, args.code);
954
+ }
955
+
877
956
  // ============================================================================
878
957
  // Tool Factory
879
958
  // ============================================================================
@@ -943,22 +1022,41 @@ export function createProgrammaticToolCallingTool(
943
1022
  programmaticToolName,
944
1023
  });
945
1024
 
946
- if (toolMap == null || toolMap.size === 0) {
947
- throw new Error(
948
- 'No toolMap provided. ' +
949
- 'ToolNode should inject this from AgentContext when invoked through the graph.'
950
- );
951
- }
1025
+ /* These guard the replay path only. A call that selected no tools never
1026
+ * replays, and event-driven ToolNode configurations legitimately inject
1027
+ * an empty toolMap/toolDefs. Injected context is still required to
1028
+ * conclude that with nothing injected at all, an empty selection means
1029
+ * the host wired the tool up wrong. */
1030
+ const needsNoTools =
1031
+ effectiveTools.length === 0 &&
1032
+ (params.tool_manifest?.length === 0 ||
1033
+ toolDefs != null ||
1034
+ disallowedToolDefs != null);
1035
+
1036
+ if (!needsNoTools) {
1037
+ if (toolMap == null || toolMap.size === 0) {
1038
+ throw new Error(
1039
+ 'No toolMap provided. ' +
1040
+ 'ToolNode should inject this from AgentContext when invoked through the graph.'
1041
+ );
1042
+ }
952
1043
 
953
- if (toolDefs == null || toolDefs.length === 0) {
954
- throw new Error(
955
- 'No tool definitions provided. ' +
956
- 'Either pass tools in the input or ensure ToolNode injects toolDefs.'
957
- );
1044
+ if (toolDefs == null || toolDefs.length === 0) {
1045
+ throw new Error(
1046
+ 'No tool definitions provided. ' +
1047
+ 'Either pass tools in the input or ensure ToolNode injects toolDefs.'
1048
+ );
1049
+ }
958
1050
  }
959
1051
 
1052
+ assertUnambiguousIdentifiers(
1053
+ effectiveTools,
1054
+ normalizeToPythonIdentifier,
1055
+ programmaticToolName
1056
+ );
1057
+
960
1058
  const effectiveToolMap = projectProgrammaticToolMap(
961
- toolMap,
1059
+ toolMap ?? new Map(),
962
1060
  effectiveTools
963
1061
  );
964
1062
 
@@ -973,7 +1071,7 @@ export function createProgrammaticToolCallingTool(
973
1071
  // eslint-disable-next-line no-console
974
1072
  console.log(
975
1073
  `[PTC Debug] Sending ${effectiveTools.length} tools to API ` +
976
- `(selected from ${toolDefs.length})`
1074
+ `(selected from ${toolDefs?.length ?? 0})`
977
1075
  );
978
1076
  }
979
1077
 
@@ -1008,6 +1106,21 @@ export function createProgrammaticToolCallingTool(
1008
1106
  ? selectedRuntimeSessionHint
1009
1107
  : undefined;
1010
1108
 
1109
+ if (needsNoTools) {
1110
+ return await runPlainExecution({
1111
+ baseUrl,
1112
+ lang: 'py',
1113
+ code,
1114
+ timeout,
1115
+ sessionId: session_id,
1116
+ files,
1117
+ runtimeSessionHint,
1118
+ proxy,
1119
+ authHeaders: initParams.authHeaders,
1120
+ executionProfile: initParams.executionProfile,
1121
+ });
1122
+ }
1123
+
1011
1124
  let response = await makeRequest(
1012
1125
  EXEC_ENDPOINT,
1013
1126
  {