@librechat/agents 3.3.7 → 3.3.9
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.
- package/dist/cjs/graphs/Graph.cjs +47 -13
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +18 -48
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +174 -29
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseConfig.cjs +12 -0
- package/dist/cjs/langfuseConfig.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
- package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
- package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
- package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
- package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
- package/dist/cjs/langfuseTraceShaping.cjs +121 -4
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +5 -5
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +10 -10
- package/dist/cjs/messages/format.cjs +124 -15
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/injected.cjs +10 -1
- package/dist/cjs/messages/injected.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +13 -1
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +51 -11
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +54 -24
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/messageSerialization.cjs +6 -0
- package/dist/cjs/session/messageSerialization.cjs.map +1 -1
- package/dist/cjs/stream.cjs +21 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +60 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +253 -24
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -1
- package/dist/cjs/tools/search/tool.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/graphs/Graph.mjs +48 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +3 -2
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +18 -48
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +176 -28
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseConfig.mjs +10 -1
- package/dist/esm/langfuseConfig.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeContext.mjs +21 -3
- package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeScope.mjs +39 -10
- package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
- package/dist/esm/langfuseSpanRegistry.mjs +91 -0
- package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
- package/dist/esm/langfuseTraceShaping.mjs +121 -4
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +2 -2
- package/dist/esm/llm/invoke.mjs +5 -5
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +8 -8
- package/dist/esm/messages/format.mjs +124 -15
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/injected.mjs +10 -1
- package/dist/esm/messages/injected.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +13 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +51 -11
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +54 -24
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/messageSerialization.mjs +6 -0
- package/dist/esm/session/messageSerialization.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -10
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +60 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +254 -25
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -1
- package/dist/esm/tools/search/tool.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/graphs/Graph.d.ts +19 -0
- package/dist/types/hitl/askUserQuestion.d.ts +11 -1
- package/dist/types/langfuse.d.ts +16 -8
- package/dist/types/langfuseConfig.d.ts +6 -0
- package/dist/types/langfuseRuntimeContext.d.ts +27 -1
- package/dist/types/langfuseRuntimeScope.d.ts +17 -2
- package/dist/types/langfuseSpanRegistry.d.ts +17 -0
- package/dist/types/langfuseTraceShaping.d.ts +2 -1
- package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
- package/dist/types/messages/format.d.ts +9 -8
- package/dist/types/prompts/activityLabel.d.ts +8 -1
- package/dist/types/run.d.ts +1 -1
- package/dist/types/session/types.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +7 -1
- package/dist/types/types/activityLabel.d.ts +8 -0
- package/dist/types/types/hitl.d.ts +8 -0
- package/dist/types/types/stream.d.ts +19 -0
- package/dist/types/types/tools.d.ts +30 -0
- package/package.json +7 -4
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
- package/src/graphs/Graph.ts +69 -20
- package/src/graphs/MultiAgentGraph.ts +74 -6
- package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
- package/src/hitl/askUserQuestion.ts +14 -1
- package/src/instrumentation.ts +35 -77
- package/src/langfuse.ts +320 -43
- package/src/langfuseConfig.ts +24 -0
- package/src/langfuseRuntimeContext.ts +43 -1
- package/src/langfuseRuntimeScope.ts +94 -21
- package/src/langfuseSpanRegistry.ts +131 -0
- package/src/langfuseTraceShaping.ts +194 -7
- package/src/llm/anthropic/utils/message_inputs.ts +70 -19
- package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
- package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
- package/src/llm/bedrock/utils/message_inputs.ts +32 -7
- package/src/messages/format.ts +222 -50
- package/src/messages/formatAgentMessages.test.ts +308 -6
- package/src/messages/injected.test.ts +18 -1
- package/src/messages/injected.ts +8 -1
- package/src/messages/prune.ts +12 -1
- package/src/prompts/activityLabel.ts +67 -2
- package/src/run.ts +86 -46
- package/src/scripts/activity-labels/captured.json +56 -0
- package/src/scripts/activity-labels/checks.cjs +205 -0
- package/src/scripts/activity-labels/corpus.cjs +473 -0
- package/src/scripts/activity-labels/report.cjs +203 -0
- package/src/scripts/activity-labels/rescore.cjs +102 -0
- package/src/scripts/activity-labels/run.ts +705 -0
- package/src/scripts/activity-labels/variants.ts +71 -0
- package/src/session/messageSerialization.ts +12 -1
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-prompt.test.ts +109 -0
- package/src/specs/agent-handoffs.test.ts +306 -0
- package/src/specs/langfuse-callbacks.test.ts +456 -0
- package/src/specs/langfuse-routing.integration.test.ts +138 -1
- package/src/specs/langfuse-span-registry.test.ts +70 -0
- package/src/specs/langfuse-trace-shaping.test.ts +294 -0
- package/src/specs/prune.test.ts +38 -1
- package/src/stream.ts +70 -6
- package/src/summarization/__tests__/node.test.ts +188 -0
- package/src/summarization/node.ts +72 -0
- package/src/tools/ToolNode.ts +400 -9
- package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
- package/src/tools/__tests__/hitl.test.ts +58 -0
- package/src/types/activityLabel.ts +8 -0
- package/src/types/hitl.ts +8 -0
- package/src/types/stream.ts +20 -0
- package/src/types/tools.ts +35 -1
package/src/tools/ToolNode.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { nanoid } from 'nanoid';
|
|
|
2
2
|
import { ToolCall } from '@langchain/core/messages/tool';
|
|
3
3
|
import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
|
|
4
4
|
import {
|
|
5
|
+
AIMessage,
|
|
5
6
|
ToolMessage,
|
|
6
7
|
HumanMessage,
|
|
7
8
|
isAIMessage,
|
|
@@ -24,7 +25,7 @@ import type {
|
|
|
24
25
|
ToolRuntime,
|
|
25
26
|
StructuredToolInterface,
|
|
26
27
|
} from '@langchain/core/tools';
|
|
27
|
-
import type { BaseMessage
|
|
28
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
28
29
|
import type { LangGraphRunnableConfig } from '@langchain/langgraph';
|
|
29
30
|
import type {
|
|
30
31
|
ToolOutputResolveView,
|
|
@@ -541,8 +542,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
541
542
|
* Batch-scoped ownership is threaded via `RunToolBatchContext` instead —
|
|
542
543
|
* see {@link ToolErrorOwnership} for why per-invocation scoping matters.
|
|
543
544
|
*/
|
|
544
|
-
private looseErrorOwnership: ToolErrorOwnership =
|
|
545
|
-
createToolErrorOwnership();
|
|
545
|
+
private looseErrorOwnership: ToolErrorOwnership = createToolErrorOwnership();
|
|
546
546
|
private toolUsageCount: Map<string, number>;
|
|
547
547
|
/** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
|
|
548
548
|
private toolCallTurns: Map<string, number> = new Map();
|
|
@@ -573,6 +573,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
573
573
|
private eagerEventToolExecutions?: Map<string, t.EagerEventToolExecution>;
|
|
574
574
|
/** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
|
|
575
575
|
private eagerEventToolUsageCount?: Map<string, number>;
|
|
576
|
+
/**
|
|
577
|
+
* Shared per-run eager prestart circuit breaker. Tool names added here
|
|
578
|
+
* (when a prestarted execution's args mismatch the final request) are no
|
|
579
|
+
* longer prestarted by the stream handler for the rest of the run.
|
|
580
|
+
*/
|
|
581
|
+
private eagerEventToolSuppressions?: Set<string>;
|
|
576
582
|
/** Agent ID for event-driven mode */
|
|
577
583
|
private agentId?: string;
|
|
578
584
|
/**
|
|
@@ -657,6 +663,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
657
663
|
eagerEventToolExecution,
|
|
658
664
|
eagerEventToolExecutions,
|
|
659
665
|
eagerEventToolUsageCount,
|
|
666
|
+
eagerEventToolSuppressions,
|
|
660
667
|
agentId,
|
|
661
668
|
executingAgentId,
|
|
662
669
|
directToolNames,
|
|
@@ -694,6 +701,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
694
701
|
this.eagerEventToolExecution = eagerEventToolExecution;
|
|
695
702
|
this.eagerEventToolExecutions = eagerEventToolExecutions;
|
|
696
703
|
this.eagerEventToolUsageCount = eagerEventToolUsageCount;
|
|
704
|
+
this.eagerEventToolSuppressions = eagerEventToolSuppressions;
|
|
697
705
|
this.agentId = agentId;
|
|
698
706
|
// Default to agentId so callers constructing ToolNode directly (who pass the
|
|
699
707
|
// existing agentId option) still get attribution without knowing the new option.
|
|
@@ -746,12 +754,31 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
746
754
|
options?: Partial<RunnableConfig>
|
|
747
755
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
748
756
|
): Promise<any> {
|
|
757
|
+
// Explicit agent identity for tool callbacks: node-name parsing is
|
|
758
|
+
// ambiguous when agent ids themselves embed node prefixes, so the
|
|
759
|
+
// handler prefers this metadata (see `isForeignScope`).
|
|
760
|
+
const scopedOptions =
|
|
761
|
+
this.executingAgentId == null
|
|
762
|
+
? options
|
|
763
|
+
: {
|
|
764
|
+
...options,
|
|
765
|
+
metadata: {
|
|
766
|
+
...options?.metadata,
|
|
767
|
+
agentId: this.executingAgentId,
|
|
768
|
+
},
|
|
769
|
+
};
|
|
749
770
|
return withLangfuseRuntimeScope(
|
|
750
771
|
resolveLangfuseRuntimeScope({
|
|
751
772
|
runLangfuse: this.runLangfuse,
|
|
752
773
|
langfuseOverlay: this.agentLangfuse,
|
|
774
|
+
// Run identity is inherited from the ambient stream scope (tool
|
|
775
|
+
// supersteps execute on the owning run's chain); the agent identity
|
|
776
|
+
// must be stamped here so a concurrent sibling agent's queued
|
|
777
|
+
// callback cannot adopt this agent's overlay (see
|
|
778
|
+
// `LangfuseRuntimeContext.agentId`).
|
|
779
|
+
agentId: this.executingAgentId,
|
|
753
780
|
}),
|
|
754
|
-
() => super.invoke(input,
|
|
781
|
+
() => super.invoke(input, scopedOptions)
|
|
755
782
|
);
|
|
756
783
|
}
|
|
757
784
|
|
|
@@ -3030,9 +3057,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3030
3057
|
for (const result of results) {
|
|
3031
3058
|
if (result.injectedMessages && result.injectedMessages.length > 0) {
|
|
3032
3059
|
try {
|
|
3033
|
-
injected.push(
|
|
3034
|
-
...convertInjectedMessages(result.injectedMessages)
|
|
3035
|
-
);
|
|
3060
|
+
injected.push(...convertInjectedMessages(result.injectedMessages));
|
|
3036
3061
|
} catch (e) {
|
|
3037
3062
|
// eslint-disable-next-line no-console
|
|
3038
3063
|
console.warn(
|
|
@@ -3297,6 +3322,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3297
3322
|
execution.toolName !== request.name ||
|
|
3298
3323
|
!recordArgsEqual(execution.args, request.args)
|
|
3299
3324
|
) {
|
|
3325
|
+
// Circuit breaker: a prestart/final mismatch means the streamed eager
|
|
3326
|
+
// snapshot cannot be trusted for this tool in this run. Without this,
|
|
3327
|
+
// the model retries the call, the retry prestarts and diverges the
|
|
3328
|
+
// same way, and the run loops to the recursion limit (LibreChat#14371).
|
|
3329
|
+
// On an identity mismatch, suppress the eagerly executed name too —
|
|
3330
|
+
// otherwise a retry that deterministically streams name A but
|
|
3331
|
+
// materializes name B keeps prestarting A (and repeating its side
|
|
3332
|
+
// effects) every round.
|
|
3333
|
+
this.eagerEventToolSuppressions?.add(request.name);
|
|
3334
|
+
this.eagerEventToolSuppressions?.add(execution.toolName);
|
|
3335
|
+
// eslint-disable-next-line no-console
|
|
3336
|
+
console.warn(
|
|
3337
|
+
'[ToolNode] eager prestart args diverged from the final request for ' +
|
|
3338
|
+
`tool "${request.name}" (toolCallId=${request.id}); suppressing ` +
|
|
3339
|
+
'eager prestart for this tool for the rest of the run'
|
|
3340
|
+
);
|
|
3300
3341
|
return {
|
|
3301
3342
|
toolCallId: request.id,
|
|
3302
3343
|
toolName: request.name,
|
|
@@ -3701,6 +3742,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3701
3742
|
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
3702
3743
|
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
3703
3744
|
let outputs: (BaseMessage | Command)[];
|
|
3745
|
+
/** Hoisted from the messages-state branch so the Command tail can carry
|
|
3746
|
+
* the promotion into handoff updates (same-id state copies there would
|
|
3747
|
+
* otherwise overwrite the replacement message). */
|
|
3748
|
+
let promotedAiMessage: AIMessage | undefined;
|
|
3749
|
+
let invalidCallResults: ToolMessage[] = [];
|
|
3704
3750
|
|
|
3705
3751
|
if (this.isSendInput(input)) {
|
|
3706
3752
|
const isLocalTool =
|
|
@@ -3816,6 +3862,99 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3816
3862
|
);
|
|
3817
3863
|
}) ?? [];
|
|
3818
3864
|
|
|
3865
|
+
/**
|
|
3866
|
+
* Synthesize error results for `invalid_tool_calls` — calls whose
|
|
3867
|
+
* streamed args never collapsed into a JSON object (`@langchain/core`
|
|
3868
|
+
* files them separately with `error: "Malformed args."`, and they never
|
|
3869
|
+
* enter `tool_calls`). Their `tool_use` blocks still ride the AI
|
|
3870
|
+
* message content the provider receives, so skipping them leaves a
|
|
3871
|
+
* `tool_use` with no `tool_result` and the NEXT model call is rejected
|
|
3872
|
+
* (Anthropic 400 INVALID_TOOL_RESULTS) — fatal on HITL resume, where
|
|
3873
|
+
* the paused AI message is replayed from the checkpoint. Only calls
|
|
3874
|
+
* with an id can be paired (and only those produce the 400); the
|
|
3875
|
+
* already-processed / server-tool filters mirror `filteredCalls`.
|
|
3876
|
+
*/
|
|
3877
|
+
/**
|
|
3878
|
+
* Invalid-call handling only applies when the replacement AI message
|
|
3879
|
+
* can actually land: the MESSAGES-STATE input form (the returned
|
|
3880
|
+
* messages flow through `messagesStateReducer` and the replacement
|
|
3881
|
+
* upserts by id) with an id-bearing AI message. A `BaseMessage[]`
|
|
3882
|
+
* caller receives a plain output LIST it appends to its own history —
|
|
3883
|
+
* the replacement would duplicate the assistant turn — and an id-less
|
|
3884
|
+
* message cannot be upserted. In both cases the synthesized results
|
|
3885
|
+
* are skipped TOO: results and promotion are all-or-nothing, or the
|
|
3886
|
+
* next provider request would carry an output whose call the
|
|
3887
|
+
* converters never emit (the inverted rejection). Such callers keep
|
|
3888
|
+
* the untouched status quo.
|
|
3889
|
+
*/
|
|
3890
|
+
const canPromoteInvalidCalls =
|
|
3891
|
+
!Array.isArray(input) &&
|
|
3892
|
+
typeof aiMessage.id === 'string' &&
|
|
3893
|
+
aiMessage.id.length > 0;
|
|
3894
|
+
const attributableInvalidCalls = !canPromoteInvalidCalls
|
|
3895
|
+
? []
|
|
3896
|
+
: (aiMessage.invalid_tool_calls ?? []).filter(
|
|
3897
|
+
(call) =>
|
|
3898
|
+
call.id != null &&
|
|
3899
|
+
call.id !== '' &&
|
|
3900
|
+
!toolMessageIds.has(call.id) &&
|
|
3901
|
+
!call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
|
|
3902
|
+
);
|
|
3903
|
+
invalidCallResults = attributableInvalidCalls.map(
|
|
3904
|
+
(call) =>
|
|
3905
|
+
new ToolMessage({
|
|
3906
|
+
status: 'error',
|
|
3907
|
+
content: truncateToolResultContent(
|
|
3908
|
+
`Error: ${call.error ?? 'Malformed tool call arguments.'} ` +
|
|
3909
|
+
'The tool call input could not be parsed as a JSON object; the tool was not run.\n Please fix your mistakes.',
|
|
3910
|
+
this.maxToolResultChars
|
|
3911
|
+
),
|
|
3912
|
+
name: normalizeInvalidCallName(call.name),
|
|
3913
|
+
tool_call_id: call.id!,
|
|
3914
|
+
})
|
|
3915
|
+
);
|
|
3916
|
+
|
|
3917
|
+
/**
|
|
3918
|
+
* Promote the answered invalid calls into well-formed `tool_calls` on a
|
|
3919
|
+
* REPLACEMENT copy of the AI message (`messagesStateReducer` upserts by
|
|
3920
|
+
* id). Without this, provider converters that rebuild the call side of
|
|
3921
|
+
* the wire from `tool_calls` — OpenAI Completions `tool_calls`, OpenAI
|
|
3922
|
+
* Responses `function_call` items, Gemini/Bedrock function-call parts —
|
|
3923
|
+
* drop the invalid call while the synthesized result above still
|
|
3924
|
+
* references it, inverting the dangling-pair rejection (an output whose
|
|
3925
|
+
* call is missing). Promoting at this single seam keeps the call and
|
|
3926
|
+
* result sides agreeing for EVERY provider; args become `{}` (the raw
|
|
3927
|
+
* string never parsed — the paired error result tells the model why).
|
|
3928
|
+
* Skipped when the message has no id: the reducer would append a
|
|
3929
|
+
* duplicate instead of replacing, which is worse than the dangle.
|
|
3930
|
+
*/
|
|
3931
|
+
promotedAiMessage =
|
|
3932
|
+
attributableInvalidCalls.length > 0
|
|
3933
|
+
? new AIMessage({
|
|
3934
|
+
id: aiMessage.id,
|
|
3935
|
+
content: sanitizeInvalidToolUseBlocks(
|
|
3936
|
+
aiMessage.content,
|
|
3937
|
+
attributableInvalidCalls
|
|
3938
|
+
),
|
|
3939
|
+
name: aiMessage.name,
|
|
3940
|
+
additional_kwargs: aiMessage.additional_kwargs,
|
|
3941
|
+
response_metadata: aiMessage.response_metadata,
|
|
3942
|
+
usage_metadata: aiMessage.usage_metadata,
|
|
3943
|
+
tool_calls: [
|
|
3944
|
+
...(aiMessage.tool_calls ?? []),
|
|
3945
|
+
...attributableInvalidCalls.map((call) => ({
|
|
3946
|
+
id: call.id!,
|
|
3947
|
+
name: normalizeInvalidCallName(call.name),
|
|
3948
|
+
args: {},
|
|
3949
|
+
type: 'tool_call' as const,
|
|
3950
|
+
})),
|
|
3951
|
+
],
|
|
3952
|
+
invalid_tool_calls: (aiMessage.invalid_tool_calls ?? []).filter(
|
|
3953
|
+
(call) => !attributableInvalidCalls.includes(call)
|
|
3954
|
+
),
|
|
3955
|
+
})
|
|
3956
|
+
: undefined;
|
|
3957
|
+
|
|
3819
3958
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
3820
3959
|
const directToolNames = this.directToolNames;
|
|
3821
3960
|
const hasRegisteredHandoffTool = this.hasRegisteredHandoffTool();
|
|
@@ -3838,7 +3977,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3838
3977
|
}
|
|
3839
3978
|
}
|
|
3840
3979
|
|
|
3841
|
-
if (directEntries.length === 0) {
|
|
3980
|
+
if (directEntries.length === 0 && invalidCallResults.length === 0) {
|
|
3842
3981
|
return this.executeViaEvent(filteredCalls, config, input, {
|
|
3843
3982
|
batchIndices: eventEntries.map((entry) => entry.batchIndex),
|
|
3844
3983
|
turn,
|
|
@@ -3957,8 +4096,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3957
4096
|
]
|
|
3958
4097
|
: [];
|
|
3959
4098
|
outputs = [
|
|
4099
|
+
// Replacement AI message first (reducer upsert-by-id), then results.
|
|
4100
|
+
...(promotedAiMessage != null ? [promotedAiMessage] : []),
|
|
3960
4101
|
...directOutputs,
|
|
3961
4102
|
...eventResult.toolMessages,
|
|
4103
|
+
// Synthesized invalid-call errors sit with the real tool results,
|
|
4104
|
+
// before injected context, to keep provider tool-result adjacency.
|
|
4105
|
+
...invalidCallResults,
|
|
3962
4106
|
...directInjected,
|
|
3963
4107
|
...eventResult.injected,
|
|
3964
4108
|
];
|
|
@@ -3993,10 +4137,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3993
4137
|
);
|
|
3994
4138
|
// Append accumulated additionalContexts as a single
|
|
3995
4139
|
// HumanMessage so the next model turn sees them. Codex P2 #39.
|
|
4140
|
+
const promotedPrefix = promotedAiMessage != null ? [promotedAiMessage] : [];
|
|
3996
4141
|
outputs =
|
|
3997
4142
|
directAdditionalContexts.length > 0
|
|
3998
4143
|
? [
|
|
4144
|
+
...promotedPrefix,
|
|
3999
4145
|
...toolOutputs,
|
|
4146
|
+
...invalidCallResults,
|
|
4000
4147
|
new HumanMessage({
|
|
4001
4148
|
content: directAdditionalContexts.join('\n\n'),
|
|
4002
4149
|
// Same system-role marker the event-driven path
|
|
@@ -4005,7 +4152,30 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
4005
4152
|
additional_kwargs: { role: 'system', source: 'hook' },
|
|
4006
4153
|
}),
|
|
4007
4154
|
]
|
|
4008
|
-
: toolOutputs;
|
|
4155
|
+
: [...promotedPrefix, ...toolOutputs, ...invalidCallResults];
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
/**
|
|
4159
|
+
* Resolve the streamed tool-call cards for invalid calls, best-effort.
|
|
4160
|
+
* Runs AFTER the direct batch settled: on an interrupting first pass
|
|
4161
|
+
* this line is unreachable (the node unwound), so interrupt/resume
|
|
4162
|
+
* flows emit the completion exactly once — same reasoning as the
|
|
4163
|
+
* deferred blocked-call side effects. Skipped when the stream never
|
|
4164
|
+
* registered a step for the call (non-streaming providers), where a
|
|
4165
|
+
* completion could not be routed to a card anyway.
|
|
4166
|
+
*/
|
|
4167
|
+
for (const result of invalidCallResults) {
|
|
4168
|
+
const invalidStepId = this.toolCallStepIds?.get(result.tool_call_id);
|
|
4169
|
+
if (invalidStepId == null || invalidStepId === '') {
|
|
4170
|
+
continue;
|
|
4171
|
+
}
|
|
4172
|
+
await this.dispatchStepCompleted(
|
|
4173
|
+
result.tool_call_id,
|
|
4174
|
+
result.name ?? 'unknown',
|
|
4175
|
+
{},
|
|
4176
|
+
typeof result.content === 'string' ? result.content : '',
|
|
4177
|
+
config
|
|
4178
|
+
);
|
|
4009
4179
|
}
|
|
4010
4180
|
}
|
|
4011
4181
|
|
|
@@ -4013,6 +4183,28 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
4013
4183
|
return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
|
|
4014
4184
|
}
|
|
4015
4185
|
|
|
4186
|
+
/**
|
|
4187
|
+
* Carry the invalid-call promotion into handoff commands. A handoff
|
|
4188
|
+
* tool's Command snapshots `update.messages` from the PRE-promotion
|
|
4189
|
+
* state (MultiAgentGraph builds a filtered same-id copy of the original
|
|
4190
|
+
* AI message), and commands apply after the sibling reducer updates —
|
|
4191
|
+
* so the stale copy would overwrite the replacement message, and a
|
|
4192
|
+
* Send handoff's child state could omit the synthesized results
|
|
4193
|
+
* entirely. Patch each command's same-id AI message with the promotion
|
|
4194
|
+
* and append any missing synthesized results.
|
|
4195
|
+
*/
|
|
4196
|
+
if (promotedAiMessage != null) {
|
|
4197
|
+
outputs = outputs.map((output) =>
|
|
4198
|
+
isCommand(output)
|
|
4199
|
+
? patchCommandUpdateForPromotedInvalidCalls(
|
|
4200
|
+
output,
|
|
4201
|
+
promotedAiMessage!,
|
|
4202
|
+
invalidCallResults
|
|
4203
|
+
)
|
|
4204
|
+
: output
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4016
4208
|
const combinedOutputs: (
|
|
4017
4209
|
| { messages: BaseMessage[] }
|
|
4018
4210
|
| BaseMessage[]
|
|
@@ -4162,6 +4354,176 @@ function areToolCallsInvoked(
|
|
|
4162
4354
|
);
|
|
4163
4355
|
}
|
|
4164
4356
|
|
|
4357
|
+
/**
|
|
4358
|
+
* Normalize the `tool_use` content blocks of promoted invalid calls so the
|
|
4359
|
+
* replacement AI message is valid on EVERY provider surface, not just
|
|
4360
|
+
* `tool_calls`. Anthropic formats an array-content AI message from its blocks
|
|
4361
|
+
* verbatim, and a call whose streamed `input_json` never parsed leaves the
|
|
4362
|
+
* block's `input` as the raw accumulated STRING — replayed as-is, the API
|
|
4363
|
+
* rejects it with `tool_use.input: Input should be an object` before pairing
|
|
4364
|
+
* is even checked. Blocks matching a promoted call id get `input: {}`
|
|
4365
|
+
* (mirroring the promoted args); everything else passes through untouched.
|
|
4366
|
+
* String content (OpenAI-style) is returned as-is.
|
|
4367
|
+
*/
|
|
4368
|
+
function sanitizeInvalidToolUseBlocks(
|
|
4369
|
+
content: AIMessage['content'],
|
|
4370
|
+
promotedCalls: ReadonlyArray<{ id?: string; name?: string }>
|
|
4371
|
+
): AIMessage['content'] {
|
|
4372
|
+
if (!Array.isArray(content)) {
|
|
4373
|
+
return content;
|
|
4374
|
+
}
|
|
4375
|
+
const promotedNamesById = new Map(
|
|
4376
|
+
promotedCalls
|
|
4377
|
+
.filter((call) => call.id != null)
|
|
4378
|
+
.map((call) => [call.id!, normalizeInvalidCallName(call.name)])
|
|
4379
|
+
);
|
|
4380
|
+
return content.map((block) => {
|
|
4381
|
+
if (
|
|
4382
|
+
typeof block !== 'object' ||
|
|
4383
|
+
(block as { type?: string } | null)?.type !== 'tool_use'
|
|
4384
|
+
) {
|
|
4385
|
+
return block;
|
|
4386
|
+
}
|
|
4387
|
+
const toolUse = block as { id?: string; name?: string; input?: unknown };
|
|
4388
|
+
if (toolUse.id == null || !promotedNamesById.has(toolUse.id)) {
|
|
4389
|
+
return block;
|
|
4390
|
+
}
|
|
4391
|
+
const inputIsObject =
|
|
4392
|
+
typeof toolUse.input === 'object' &&
|
|
4393
|
+
toolUse.input != null &&
|
|
4394
|
+
!Array.isArray(toolUse.input);
|
|
4395
|
+
/** `name` normalizes with the SAME fallback the promoted `tool_calls`
|
|
4396
|
+
* entry uses — a nameless block would fail provider validation on its
|
|
4397
|
+
* own even with a valid input. */
|
|
4398
|
+
const nameIsValid =
|
|
4399
|
+
typeof toolUse.name === 'string' && toolUse.name !== '';
|
|
4400
|
+
if (inputIsObject && nameIsValid) {
|
|
4401
|
+
return block;
|
|
4402
|
+
}
|
|
4403
|
+
return {
|
|
4404
|
+
...block,
|
|
4405
|
+
...(inputIsObject ? {} : { input: {} }),
|
|
4406
|
+
...(nameIsValid ? {} : { name: promotedNamesById.get(toolUse.id) }),
|
|
4407
|
+
};
|
|
4408
|
+
});
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
/**
|
|
4412
|
+
* Name fallback for attributable invalid calls, shared by every surface that
|
|
4413
|
+
* materializes them (synthesized result, promoted tool_calls entry, sanitized
|
|
4414
|
+
* block, handoff patch): `''` is normalized like `undefined` — providers
|
|
4415
|
+
* reject nameless calls, so an empty string would defeat the promotion.
|
|
4416
|
+
*
|
|
4417
|
+
* INVARIANT MAP — a tool call lives in several parallel representations, and
|
|
4418
|
+
* any surface that materializes, copies, filters, routes on, or reports one
|
|
4419
|
+
* must keep ALL of them agreeing (`tool_calls`, `invalid_tool_calls`,
|
|
4420
|
+
* provider content blocks, paired results). The attribution predicate is:
|
|
4421
|
+
* id-bearing (non-empty), non-server (`srvtoolu_`), unanswered
|
|
4422
|
+
* (`toolMessageIds`), messages-state input, id-bearing AI message. Surfaces
|
|
4423
|
+
* that apply it today — extend this list when adding another:
|
|
4424
|
+
* - `run()`'s `canPromoteInvalidCalls` gate + attributable filter
|
|
4425
|
+
* - `toolsCondition`'s invalid-only / server-mix routing branch
|
|
4426
|
+
* - `sanitizeInvalidToolUseBlocks` (block input AND name)
|
|
4427
|
+
* - `patchCommandUpdateForPromotedInvalidCalls` (handoff snapshots)
|
|
4428
|
+
* - `processHandoffReception`'s transfer-block filtering (MultiAgentGraph)
|
|
4429
|
+
* - `findPendingToolCalls` in langfuseTraceShaping (span claims)
|
|
4430
|
+
* - `serializeMessage`/`deserializeMessage` (session round-trip keeps
|
|
4431
|
+
* `invalid_tool_calls` with the content blocks they repair)
|
|
4432
|
+
*/
|
|
4433
|
+
function normalizeInvalidCallName(name: string | undefined | null): string {
|
|
4434
|
+
return name != null && name !== '' ? name : 'unknown';
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
/**
|
|
4438
|
+
* Rewrite a handoff Command's `update.messages` so the invalid-call promotion
|
|
4439
|
+
* survives into the child state: the same-id AI message copy (snapshotted
|
|
4440
|
+
* pre-promotion by the handoff tool) gets the sanitized content, the promoted
|
|
4441
|
+
* `tool_calls` entries for the answered invalid calls, and the leftover
|
|
4442
|
+
* `invalid_tool_calls`; synthesized results missing from the update are
|
|
4443
|
+
* appended so the child's history keeps every call/result pair. The update
|
|
4444
|
+
* copy's own `tool_calls` narrowing (parallel handoffs filter to a single
|
|
4445
|
+
* call) is preserved. Commands without a same-id AI message pass through.
|
|
4446
|
+
*/
|
|
4447
|
+
function patchCommandUpdateForPromotedInvalidCalls(
|
|
4448
|
+
command: Command,
|
|
4449
|
+
promoted: AIMessage,
|
|
4450
|
+
invalidResults: ToolMessage[]
|
|
4451
|
+
): Command {
|
|
4452
|
+
const update = command.update as { messages?: BaseMessage[] } | undefined;
|
|
4453
|
+
const messages = update?.messages;
|
|
4454
|
+
if (
|
|
4455
|
+
!Array.isArray(messages) ||
|
|
4456
|
+
promoted.id == null ||
|
|
4457
|
+
invalidResults.length === 0
|
|
4458
|
+
) {
|
|
4459
|
+
return command;
|
|
4460
|
+
}
|
|
4461
|
+
const hasSameIdAiMessage = messages.some(
|
|
4462
|
+
(msg) => isAIMessage(msg) && msg.id === promoted.id
|
|
4463
|
+
);
|
|
4464
|
+
if (!hasSameIdAiMessage) {
|
|
4465
|
+
return command;
|
|
4466
|
+
}
|
|
4467
|
+
const next: BaseMessage[] = messages.map((msg) => {
|
|
4468
|
+
if (!isAIMessage(msg) || msg.id !== promoted.id) {
|
|
4469
|
+
return msg;
|
|
4470
|
+
}
|
|
4471
|
+
const existingIds = new Set(
|
|
4472
|
+
(msg.tool_calls ?? []).map((call) => call.id)
|
|
4473
|
+
);
|
|
4474
|
+
const promotedEntries = invalidResults
|
|
4475
|
+
.filter((result) => !existingIds.has(result.tool_call_id))
|
|
4476
|
+
.map((result) => ({
|
|
4477
|
+
id: result.tool_call_id,
|
|
4478
|
+
name: normalizeInvalidCallName(result.name),
|
|
4479
|
+
args: {},
|
|
4480
|
+
type: 'tool_call' as const,
|
|
4481
|
+
}));
|
|
4482
|
+
return new AIMessage({
|
|
4483
|
+
id: msg.id,
|
|
4484
|
+
content: promoted.content,
|
|
4485
|
+
name: msg.name,
|
|
4486
|
+
additional_kwargs: msg.additional_kwargs,
|
|
4487
|
+
response_metadata: msg.response_metadata,
|
|
4488
|
+
usage_metadata: msg.usage_metadata,
|
|
4489
|
+
tool_calls: [...(msg.tool_calls ?? []), ...promotedEntries],
|
|
4490
|
+
invalid_tool_calls: promoted.invalid_tool_calls,
|
|
4491
|
+
});
|
|
4492
|
+
});
|
|
4493
|
+
const presentResultIds = new Set(
|
|
4494
|
+
next
|
|
4495
|
+
.filter((msg): msg is ToolMessage => msg._getType() === 'tool')
|
|
4496
|
+
.map((msg) => msg.tool_call_id)
|
|
4497
|
+
);
|
|
4498
|
+
const missingResults = invalidResults.filter(
|
|
4499
|
+
(result) => !presentResultIds.has(result.tool_call_id)
|
|
4500
|
+
);
|
|
4501
|
+
return new Command({
|
|
4502
|
+
graph: command.graph,
|
|
4503
|
+
goto: command.goto,
|
|
4504
|
+
resume: command.resume,
|
|
4505
|
+
update: { ...update, messages: [...next, ...missingResults] },
|
|
4506
|
+
});
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
/**
|
|
4510
|
+
* Whether the message carries an `invalid_tool_calls` entry ToolNode can pair a
|
|
4511
|
+
* synthesized error result with (id-bearing, non-server). Shared by the routing
|
|
4512
|
+
* condition below so an invalid-only turn still enters ToolNode — otherwise the
|
|
4513
|
+
* malformed `tool_use` block is committed with no `tool_result` and the next
|
|
4514
|
+
* model call is rejected by pairing-strict providers.
|
|
4515
|
+
*/
|
|
4516
|
+
function hasAttributableInvalidToolCalls(message: AIMessage): boolean {
|
|
4517
|
+
return (
|
|
4518
|
+
message.invalid_tool_calls?.some(
|
|
4519
|
+
(call) =>
|
|
4520
|
+
call.id != null &&
|
|
4521
|
+
call.id !== '' &&
|
|
4522
|
+
!call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
|
|
4523
|
+
) ?? false
|
|
4524
|
+
);
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4165
4527
|
export function toolsCondition<T extends string>(
|
|
4166
4528
|
state: BaseMessage[] | typeof MessagesAnnotation.State,
|
|
4167
4529
|
toolNode: T,
|
|
@@ -4178,5 +4540,34 @@ export function toolsCondition<T extends string>(
|
|
|
4178
4540
|
) {
|
|
4179
4541
|
return toolNode;
|
|
4180
4542
|
}
|
|
4543
|
+
/**
|
|
4544
|
+
* The valid calls (if any) did not route above, but ToolNode still owes any
|
|
4545
|
+
* malformed calls their synthesized error results. Route when EVERY valid
|
|
4546
|
+
* call is provider-server-executed (`srvtoolu_` — ToolNode's batch filter
|
|
4547
|
+
* excludes those before execution, so nothing re-runs): that covers both the
|
|
4548
|
+
* invalid-only turn and the Anthropic server-call + malformed-client-call
|
|
4549
|
+
* mix, where `handleAnthropicSearchResults` marks the server call invoked
|
|
4550
|
+
* and the first branch declines. A valid NON-server call that was invoked
|
|
4551
|
+
* externally stays conservative (no routing) — ToolNode does not filter on
|
|
4552
|
+
* `invokedToolIds`, so entering it would re-execute that call.
|
|
4553
|
+
*
|
|
4554
|
+
* Mirrors ToolNode's own gating exactly, or the routed turn would no-op and
|
|
4555
|
+
* bounce back to the model with the dangle intact: array-state graphs get a
|
|
4556
|
+
* plain output list (no reducer upsert — ToolNode skips invalid handling
|
|
4557
|
+
* there), and an id-less message cannot take the replacement upsert either.
|
|
4558
|
+
*/
|
|
4559
|
+
if (
|
|
4560
|
+
!Array.isArray(state) &&
|
|
4561
|
+
message &&
|
|
4562
|
+
typeof message.id === 'string' &&
|
|
4563
|
+
message.id.length > 0 &&
|
|
4564
|
+
hasAttributableInvalidToolCalls(message) &&
|
|
4565
|
+
(message.tool_calls ?? []).every(
|
|
4566
|
+
(call) =>
|
|
4567
|
+
call.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) === true
|
|
4568
|
+
)
|
|
4569
|
+
) {
|
|
4570
|
+
return toolNode;
|
|
4571
|
+
}
|
|
4181
4572
|
return END;
|
|
4182
4573
|
}
|