@librechat/agents 3.9.3 → 3.9.5
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/README.md +32 -0
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/events.cjs +11 -0
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +41 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/acceptedModelResponse.cjs +168 -0
- package/dist/cjs/graphs/acceptedModelResponse.cjs.map +1 -0
- package/dist/cjs/llm/invoke.cjs +10 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/streamLimits.cjs +1 -1
- package/dist/cjs/llm/streamLimits.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/messages/fading.cjs +14 -6
- package/dist/cjs/messages/fading.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +95 -36
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/openai/index.cjs +2 -0
- package/dist/cjs/openai/index.cjs.map +1 -1
- package/dist/cjs/openai/toolProjection.cjs +196 -0
- package/dist/cjs/openai/toolProjection.cjs.map +1 -0
- package/dist/cjs/run.cjs +8 -1
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +1 -1
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +9 -4
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ArtifactTruncation.cjs +45 -0
- package/dist/cjs/tools/ArtifactTruncation.cjs.map +1 -0
- package/dist/cjs/tools/BashExecutor.cjs +6 -1
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +6 -1
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +2 -1
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +4 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
- package/dist/cjs/utils/acceptedToolArguments.cjs +143 -0
- package/dist/cjs/utils/acceptedToolArguments.cjs.map +1 -0
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/events.mjs +11 -0
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +41 -1
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/acceptedModelResponse.mjs +165 -0
- package/dist/esm/graphs/acceptedModelResponse.mjs.map +1 -0
- package/dist/esm/llm/invoke.mjs +10 -5
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/streamLimits.mjs +1 -1
- package/dist/esm/llm/streamLimits.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/fading.mjs +14 -7
- package/dist/esm/messages/fading.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +95 -37
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/openai/index.mjs +2 -1
- package/dist/esm/openai/index.mjs.map +1 -1
- package/dist/esm/openai/toolProjection.mjs +196 -0
- package/dist/esm/openai/toolProjection.mjs.map +1 -0
- package/dist/esm/run.mjs +8 -1
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +1 -1
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +9 -4
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ArtifactTruncation.mjs +44 -0
- package/dist/esm/tools/ArtifactTruncation.mjs.map +1 -0
- package/dist/esm/tools/BashExecutor.mjs +6 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +6 -1
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +2 -1
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +5 -2
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
- package/dist/esm/utils/acceptedToolArguments.mjs +142 -0
- package/dist/esm/utils/acceptedToolArguments.mjs.map +1 -0
- package/dist/types/common/enum.d.ts +4 -0
- package/dist/types/graphs/Graph.d.ts +3 -1
- package/dist/types/graphs/acceptedModelResponse.d.ts +15 -0
- package/dist/types/messages/fading.d.ts +14 -2
- package/dist/types/messages/prune.d.ts +7 -0
- package/dist/types/openai/arguments.d.ts +2 -0
- package/dist/types/openai/index.d.ts +2 -0
- package/dist/types/openai/toolProjection.d.ts +30 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ArtifactTruncation.d.ts +3 -0
- package/dist/types/tools/ToolNode.d.ts +1 -1
- package/dist/types/types/graph.d.ts +5 -3
- package/dist/types/types/run.d.ts +5 -0
- package/dist/types/types/stream.d.ts +22 -1
- package/dist/types/types/tools.d.ts +14 -0
- package/dist/types/utils/acceptedToolArguments.d.ts +10 -0
- package/package.json +1 -1
- package/src/common/enum.ts +5 -0
- package/src/events.ts +24 -1
- package/src/graphs/Graph.ts +95 -0
- package/src/graphs/acceptedModelResponse.ts +307 -0
- package/src/llm/invoke.ts +39 -14
- package/src/llm/streamLimits.ts +1 -1
- package/src/messages/fading.ts +30 -5
- package/src/messages/prune.ts +168 -55
- package/src/openai/arguments.ts +2 -0
- package/src/openai/index.ts +6 -0
- package/src/openai/toolProjection.ts +318 -0
- package/src/run.ts +21 -0
- package/src/session/AgentSession.ts +2 -2
- package/src/stream.ts +21 -1
- package/src/tools/ArtifactTruncation.ts +85 -0
- package/src/tools/BashExecutor.ts +22 -5
- package/src/tools/CodeExecutor.ts +18 -1
- package/src/tools/ProgrammaticToolCalling.ts +17 -3
- package/src/tools/ToolNode.ts +5 -0
- package/src/tools/subagent/SubagentReplay.ts +10 -9
- package/src/types/graph.ts +15 -9
- package/src/types/run.ts +5 -0
- package/src/types/stream.ts +26 -0
- package/src/types/tools.ts +21 -0
- package/src/utils/acceptedToolArguments.ts +204 -0
package/src/graphs/Graph.ts
CHANGED
|
@@ -135,6 +135,10 @@ import {
|
|
|
135
135
|
annotateMessagesForLLM,
|
|
136
136
|
ToolOutputReferenceRegistry,
|
|
137
137
|
} from '@/tools/toolOutputReferences';
|
|
138
|
+
import {
|
|
139
|
+
InvalidModelToolCallError,
|
|
140
|
+
snapshotAcceptedModelResponse,
|
|
141
|
+
} from './acceptedModelResponse';
|
|
138
142
|
import {
|
|
139
143
|
prepareProviderRequest,
|
|
140
144
|
usesNativeOpenAIResponses,
|
|
@@ -831,6 +835,8 @@ export abstract class Graph<
|
|
|
831
835
|
callerSignal?: AbortSignal;
|
|
832
836
|
/** Set of invoked tool call IDs from non-message run steps completed mid-run, if any */
|
|
833
837
|
invokedToolIds?: Set<string>;
|
|
838
|
+
/** Explicit host policy. Never inferred from missing ToolNode claims. */
|
|
839
|
+
clientDelegatedToolNames?: ReadonlySet<string>;
|
|
834
840
|
handlerRegistry: HandlerRegistry | undefined;
|
|
835
841
|
/** Host registry retained only for forwarding tools from nested child graphs. */
|
|
836
842
|
protected parentToolHandlerRegistry: HandlerRegistry | undefined;
|
|
@@ -1543,6 +1549,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
1543
1549
|
preemption,
|
|
1544
1550
|
streamLimits,
|
|
1545
1551
|
toolExecution,
|
|
1552
|
+
clientDelegatedToolNames,
|
|
1546
1553
|
}: t.StandardGraphInput,
|
|
1547
1554
|
dependencies?: GraphFactoryDependencies
|
|
1548
1555
|
) {
|
|
@@ -1570,6 +1577,15 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
1570
1577
|
this.preemption = preemption;
|
|
1571
1578
|
this.streamLimits = resolveStreamLimits(streamLimits);
|
|
1572
1579
|
this.toolExecution = toolExecution;
|
|
1580
|
+
if (clientDelegatedToolNames != null && clientDelegatedToolNames.length > 0) {
|
|
1581
|
+
if (agents.length !== 1) {
|
|
1582
|
+
throw new Error('Client tool delegation requires a single-agent graph');
|
|
1583
|
+
}
|
|
1584
|
+
if (clientDelegatedToolNames.some((name) => !name.trim())) {
|
|
1585
|
+
throw new Error('Client delegated tool names must be nonempty');
|
|
1586
|
+
}
|
|
1587
|
+
this.clientDelegatedToolNames = new Set(clientDelegatedToolNames);
|
|
1588
|
+
}
|
|
1573
1589
|
|
|
1574
1590
|
if (agents.length === 0) {
|
|
1575
1591
|
throw new Error('At least one agent configuration is required');
|
|
@@ -2833,6 +2849,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2833
2849
|
currentToolMap?: t.ToolMap;
|
|
2834
2850
|
agentContext?: AgentContext;
|
|
2835
2851
|
}): CustomToolNode<t.BaseGraphState> | ToolNode<t.BaseGraphState> {
|
|
2852
|
+
const onToolCallsClaimed = async (
|
|
2853
|
+
messageId: string,
|
|
2854
|
+
config: RunnableConfig
|
|
2855
|
+
): Promise<void> => {
|
|
2856
|
+
const handler = this.handlerRegistry?.getHandler(
|
|
2857
|
+
GraphEvents.ON_MODEL_TOOLS_CLAIMED
|
|
2858
|
+
);
|
|
2859
|
+
if (handler == null) return;
|
|
2860
|
+
await handler.handle(
|
|
2861
|
+
GraphEvents.ON_MODEL_TOOLS_CLAIMED,
|
|
2862
|
+
{
|
|
2863
|
+
type: 'model_tools_claimed',
|
|
2864
|
+
agentId: agentContext?.agentId ?? this.defaultAgentId,
|
|
2865
|
+
messageId,
|
|
2866
|
+
},
|
|
2867
|
+
config.metadata,
|
|
2868
|
+
this
|
|
2869
|
+
);
|
|
2870
|
+
};
|
|
2836
2871
|
const toolDefinitions = agentContext?.toolDefinitions;
|
|
2837
2872
|
const eventDrivenMode =
|
|
2838
2873
|
toolDefinitions != null && toolDefinitions.length > 0;
|
|
@@ -2920,6 +2955,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2920
2955
|
this.config = config;
|
|
2921
2956
|
this.restoreRunStepResumeState(state);
|
|
2922
2957
|
},
|
|
2958
|
+
onToolCallsClaimed,
|
|
2923
2959
|
createRunStepResumeState: (): t.RunStepResumeState =>
|
|
2924
2960
|
this.createRunStepResumeState(),
|
|
2925
2961
|
errorHandler: (data, metadata): Promise<boolean> =>
|
|
@@ -3002,6 +3038,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
3002
3038
|
this.config = config;
|
|
3003
3039
|
this.restoreRunStepResumeState(state);
|
|
3004
3040
|
},
|
|
3041
|
+
onToolCallsClaimed,
|
|
3005
3042
|
createRunStepResumeState: (): t.RunStepResumeState =>
|
|
3006
3043
|
this.createRunStepResumeState(),
|
|
3007
3044
|
});
|
|
@@ -4363,6 +4400,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
4363
4400
|
* succeeding fallback would resolve a run the public contract says
|
|
4364
4401
|
* must reject. Rethrow before any recovery path.
|
|
4365
4402
|
*/
|
|
4403
|
+
if (primaryError instanceof InvalidModelToolCallError) {
|
|
4404
|
+
throw primaryError;
|
|
4405
|
+
}
|
|
4366
4406
|
if (
|
|
4367
4407
|
primaryError instanceof StreamLimitExceededError ||
|
|
4368
4408
|
primaryError instanceof PreparedSubagentError
|
|
@@ -4703,6 +4743,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
4703
4743
|
})
|
|
4704
4744
|
);
|
|
4705
4745
|
} catch (fallbackError) {
|
|
4746
|
+
if (fallbackError instanceof InvalidModelToolCallError) {
|
|
4747
|
+
throw fallbackError;
|
|
4748
|
+
}
|
|
4706
4749
|
if (
|
|
4707
4750
|
fallbackError instanceof StreamLimitExceededError ||
|
|
4708
4751
|
fallbackError instanceof PreparedSubagentError
|
|
@@ -5007,6 +5050,36 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
5007
5050
|
this.preemptIncomplete = true;
|
|
5008
5051
|
}
|
|
5009
5052
|
|
|
5053
|
+
const responseHandler = this.handlerRegistry?.getHandler(
|
|
5054
|
+
GraphEvents.ON_MODEL_RESPONSE
|
|
5055
|
+
);
|
|
5056
|
+
if (responseHandler != null && responseMessage?.getType() === 'ai') {
|
|
5057
|
+
try {
|
|
5058
|
+
// One graph-owned accepted result after all primary/fallback/overflow paths.
|
|
5059
|
+
// No inference from provider chunks, run-step IDs, or attempt callback metadata.
|
|
5060
|
+
invokeConfig.signal?.throwIfAborted();
|
|
5061
|
+
const accepted = snapshotAcceptedModelResponse(
|
|
5062
|
+
responseMessage as AIMessageChunk,
|
|
5063
|
+
v4(),
|
|
5064
|
+
agentId,
|
|
5065
|
+
this.invokedToolIds,
|
|
5066
|
+
this.clientDelegatedToolNames
|
|
5067
|
+
);
|
|
5068
|
+
// Awaited, registry-only: no trace replay, usage recording or side effects.
|
|
5069
|
+
// Detached calls prevent a consumer from changing tools about to execute.
|
|
5070
|
+
await responseHandler.handle(
|
|
5071
|
+
GraphEvents.ON_MODEL_RESPONSE,
|
|
5072
|
+
accepted,
|
|
5073
|
+
metadata,
|
|
5074
|
+
this
|
|
5075
|
+
);
|
|
5076
|
+
invokeConfig.signal?.throwIfAborted();
|
|
5077
|
+
} catch (error) {
|
|
5078
|
+
this.cleanupSignalListener();
|
|
5079
|
+
throw error;
|
|
5080
|
+
}
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5010
5083
|
this.cleanupSignalListener();
|
|
5011
5084
|
return result;
|
|
5012
5085
|
};
|
|
@@ -5475,6 +5548,28 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
5475
5548
|
if (this.summarizeOnlyAgentId != null) {
|
|
5476
5549
|
return END;
|
|
5477
5550
|
}
|
|
5551
|
+
const delegatedNames = this.clientDelegatedToolNames;
|
|
5552
|
+
if (delegatedNames != null && delegatedNames.size > 0) {
|
|
5553
|
+
const { messages } = state as t.BaseGraphState;
|
|
5554
|
+
const last = messages[messages.length - 1] as AIMessageChunk | undefined;
|
|
5555
|
+
const calls = last?.getType() === 'ai' ? last.tool_calls ?? [] : [];
|
|
5556
|
+
if (calls.some((call) => delegatedNames.has(call.name))) {
|
|
5557
|
+
if (
|
|
5558
|
+
calls.some(
|
|
5559
|
+
(call) =>
|
|
5560
|
+
!delegatedNames.has(call.name) ||
|
|
5561
|
+
(call.id != null && this.invokedToolIds?.has(call.id) === true)
|
|
5562
|
+
) ||
|
|
5563
|
+
(last?.invalid_tool_calls?.length ?? 0) > 0 ||
|
|
5564
|
+
getTruncationStopReason(last) != null
|
|
5565
|
+
) {
|
|
5566
|
+
throw new InvalidModelToolCallError(
|
|
5567
|
+
'Mixed client and graph-owned tool calls require separate model turns'
|
|
5568
|
+
);
|
|
5569
|
+
}
|
|
5570
|
+
return END;
|
|
5571
|
+
}
|
|
5572
|
+
}
|
|
5478
5573
|
const decision = toolsCondition(
|
|
5479
5574
|
state as t.BaseGraphState,
|
|
5480
5575
|
toolNode,
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { types } from 'node:util';
|
|
2
|
+
import type { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
4
|
+
import type { ModelResponseEvent } from '@/types';
|
|
5
|
+
import {
|
|
6
|
+
cloneToolArguments,
|
|
7
|
+
serializeToolArguments,
|
|
8
|
+
} from '@/utils/acceptedToolArguments';
|
|
9
|
+
import { linkStreamLimitCanonical } from '@/llm/streamLimits';
|
|
10
|
+
|
|
11
|
+
const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
const MAX_SNAPSHOT_CALLS = 1024;
|
|
13
|
+
|
|
14
|
+
export class InvalidModelToolCallError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'InvalidModelToolCallError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Detach executable calls before dispatch. Invalid diagnostics remain available
|
|
22
|
+
* for ToolNode to synthesize paired error results; accepted projection rejects them.
|
|
23
|
+
*/
|
|
24
|
+
export function detachValidatedModelToolCalls(
|
|
25
|
+
message: AIMessageChunk,
|
|
26
|
+
partial = false
|
|
27
|
+
): void {
|
|
28
|
+
try {
|
|
29
|
+
// Inspect every collection before replacing any provider-owned data. Raw
|
|
30
|
+
// fragments are read by accounting and handlers even when tool_calls is empty.
|
|
31
|
+
const toolCalls = snapshotToolCalls(message, true, partial);
|
|
32
|
+
const chunks = snapshotToolRecords(message, 'tool_call_chunks');
|
|
33
|
+
const invalid = snapshotToolRecords(message, 'invalid_tool_calls');
|
|
34
|
+
Object.defineProperties(message, {
|
|
35
|
+
tool_calls: {
|
|
36
|
+
value: toolCalls,
|
|
37
|
+
enumerable: true,
|
|
38
|
+
writable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
},
|
|
41
|
+
tool_call_chunks: {
|
|
42
|
+
value: chunks,
|
|
43
|
+
enumerable: true,
|
|
44
|
+
writable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
},
|
|
47
|
+
invalid_tool_calls: {
|
|
48
|
+
value: invalid,
|
|
49
|
+
enumerable: true,
|
|
50
|
+
writable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new InvalidModelToolCallError(
|
|
56
|
+
error instanceof Error
|
|
57
|
+
? error.message
|
|
58
|
+
: 'Accepted model response contains non-serializable tool calls'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Inspect original descriptors before a getter, proxy, or custom instance can
|
|
64
|
+
* be normalized away. The bound applies to each model message independently.
|
|
65
|
+
*/
|
|
66
|
+
function snapshotToolCalls(
|
|
67
|
+
finalResponse: AIMessageChunk,
|
|
68
|
+
allowInvalidDiagnostics: boolean,
|
|
69
|
+
allowFragmentArgs = false
|
|
70
|
+
): ToolCall[] {
|
|
71
|
+
// Read own data descriptors, not accessors supplied by a custom model. Invalid
|
|
72
|
+
// diagnostics are rejected in O(1); cloning them can run getters and bypass
|
|
73
|
+
// the valid-call snapshot's byte/count limits.
|
|
74
|
+
if (types.isProxy(finalResponse)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Accepted model response contains non-serializable tool calls'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const calls = Object.getOwnPropertyDescriptor(finalResponse, 'tool_calls');
|
|
80
|
+
const diagnostics = Object.getOwnPropertyDescriptor(
|
|
81
|
+
finalResponse,
|
|
82
|
+
'invalid_tool_calls'
|
|
83
|
+
);
|
|
84
|
+
const readArray = (descriptor: PropertyDescriptor | undefined): unknown[] => {
|
|
85
|
+
if (descriptor === undefined) return [];
|
|
86
|
+
if (!('value' in descriptor)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'Accepted model response contains non-serializable tool calls'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const value: unknown = descriptor.value;
|
|
92
|
+
if (value === undefined) return [];
|
|
93
|
+
if (types.isProxy(value) || !Array.isArray(value)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'Accepted model response contains non-serializable tool calls'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
};
|
|
100
|
+
if (readArray(diagnostics).length > 0 && !allowInvalidDiagnostics) {
|
|
101
|
+
throw new Error('Accepted model response contains invalid tool calls');
|
|
102
|
+
}
|
|
103
|
+
const source = readArray(calls);
|
|
104
|
+
if (!allowInvalidDiagnostics && source.length > MAX_SNAPSHOT_CALLS) {
|
|
105
|
+
throw new Error('Accepted model response exceeds snapshot limits');
|
|
106
|
+
}
|
|
107
|
+
const toolCalls: ToolCall[] = [];
|
|
108
|
+
let remaining = allowInvalidDiagnostics ? Infinity : MAX_SNAPSHOT_BYTES;
|
|
109
|
+
for (let index = 0; index < source.length; index++) {
|
|
110
|
+
const entry = Object.getOwnPropertyDescriptor(source, String(index));
|
|
111
|
+
if (entry == null || !('value' in entry) || entry.enumerable !== true) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'Accepted model response contains non-serializable tool calls'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const call: unknown = entry.value;
|
|
117
|
+
if (call == null || typeof call !== 'object' || types.isProxy(call)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'Accepted model response contains non-serializable tool calls'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const name = Object.getOwnPropertyDescriptor(call, 'name');
|
|
123
|
+
const originalId = Object.getOwnPropertyDescriptor(call, 'id');
|
|
124
|
+
const originalArgs = Object.getOwnPropertyDescriptor(call, 'args');
|
|
125
|
+
if (
|
|
126
|
+
name == null ||
|
|
127
|
+
!('value' in name) ||
|
|
128
|
+
typeof name.value !== 'string' ||
|
|
129
|
+
originalArgs == null ||
|
|
130
|
+
!('value' in originalArgs) ||
|
|
131
|
+
(originalId != null && !('value' in originalId))
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'Accepted model response contains non-serializable tool calls'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const providerId: unknown = originalId?.value;
|
|
138
|
+
if (providerId !== undefined && typeof providerId !== 'string') {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'Accepted model response contains non-serializable tool calls'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
let args: ToolCall['args'];
|
|
144
|
+
if (allowInvalidDiagnostics) {
|
|
145
|
+
// Callback streams may carry a not-yet-plannable argument string. It is
|
|
146
|
+
// safe scalar data, but must not become an accepted executable call.
|
|
147
|
+
args =
|
|
148
|
+
allowFragmentArgs && typeof originalArgs.value === 'string'
|
|
149
|
+
? (originalArgs.value as unknown as ToolCall['args'])
|
|
150
|
+
: cloneToolArguments(originalArgs.value);
|
|
151
|
+
} else {
|
|
152
|
+
remaining -=
|
|
153
|
+
Buffer.byteLength(name.value, 'utf8') +
|
|
154
|
+
(typeof providerId === 'string'
|
|
155
|
+
? Buffer.byteLength(providerId, 'utf8')
|
|
156
|
+
: 0);
|
|
157
|
+
const encoded = serializeToolArguments(originalArgs.value, remaining);
|
|
158
|
+
remaining -= Buffer.byteLength(encoded, 'utf8');
|
|
159
|
+
args = JSON.parse(encoded);
|
|
160
|
+
}
|
|
161
|
+
toolCalls.push({
|
|
162
|
+
name: name.value,
|
|
163
|
+
id: providerId,
|
|
164
|
+
args,
|
|
165
|
+
type: 'tool_call',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return toolCalls;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Only the accepted final response becomes a host-visible model result. */
|
|
172
|
+
export function snapshotAcceptedModelResponse(
|
|
173
|
+
finalResponse: AIMessageChunk,
|
|
174
|
+
id: string,
|
|
175
|
+
agentId: string,
|
|
176
|
+
providerExecutedIds?: ReadonlySet<string>,
|
|
177
|
+
clientDelegatedToolNames?: ReadonlySet<string>
|
|
178
|
+
): ModelResponseEvent {
|
|
179
|
+
const toolCalls = snapshotToolCalls(finalResponse, false);
|
|
180
|
+
const hasClientCall = toolCalls.some(
|
|
181
|
+
(call) => clientDelegatedToolNames?.has(call.name) === true
|
|
182
|
+
);
|
|
183
|
+
if (
|
|
184
|
+
hasClientCall &&
|
|
185
|
+
(toolCalls.some(
|
|
186
|
+
(call) =>
|
|
187
|
+
clientDelegatedToolNames?.has(call.name) !== true ||
|
|
188
|
+
(call.id != null && providerExecutedIds?.has(call.id) === true)
|
|
189
|
+
) ||
|
|
190
|
+
(finalResponse.invalid_tool_calls?.length ?? 0) > 0)
|
|
191
|
+
) {
|
|
192
|
+
throw new InvalidModelToolCallError(
|
|
193
|
+
'Mixed client and graph-owned tool calls require separate model turns'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
type: 'model_response',
|
|
198
|
+
id,
|
|
199
|
+
agentId,
|
|
200
|
+
...(finalResponse.id != null ? { messageId: finalResponse.id } : {}),
|
|
201
|
+
toolCalls,
|
|
202
|
+
toolCallDispositions: toolCalls.map((call) => {
|
|
203
|
+
if (hasClientCall) return 'client';
|
|
204
|
+
return call.id != null && providerExecutedIds?.has(call.id) === true
|
|
205
|
+
? 'provider'
|
|
206
|
+
: 'sdk';
|
|
207
|
+
}),
|
|
208
|
+
invalidToolCalls: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** These records contain only scalar fields, unlike parsed tool arguments. Never
|
|
213
|
+
* spread or iterate provider records until their original descriptors pass.
|
|
214
|
+
*/
|
|
215
|
+
function snapshotToolRecords(
|
|
216
|
+
message: AIMessageChunk,
|
|
217
|
+
field: 'tool_call_chunks' | 'invalid_tool_calls'
|
|
218
|
+
): Record<string, unknown>[] {
|
|
219
|
+
function invalid(): never {
|
|
220
|
+
throw new Error(
|
|
221
|
+
'Accepted model response contains non-serializable tool calls'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, field);
|
|
225
|
+
if (descriptor == null) return [];
|
|
226
|
+
if (!('value' in descriptor)) invalid();
|
|
227
|
+
const source: unknown = descriptor.value;
|
|
228
|
+
if (source === undefined) return [];
|
|
229
|
+
if (source == null || types.isProxy(source) || !Array.isArray(source))
|
|
230
|
+
invalid();
|
|
231
|
+
const result: Record<string, unknown>[] = [];
|
|
232
|
+
for (let index = 0; index < source.length; index++) {
|
|
233
|
+
const entry = Object.getOwnPropertyDescriptor(source, String(index));
|
|
234
|
+
if (entry == null || !('value' in entry)) invalid();
|
|
235
|
+
const record: unknown = entry.value;
|
|
236
|
+
if (record == null || typeof record !== 'object' || types.isProxy(record))
|
|
237
|
+
invalid();
|
|
238
|
+
const copy: Record<string, unknown> = {};
|
|
239
|
+
for (const key of Reflect.ownKeys(record)) {
|
|
240
|
+
if (typeof key !== 'string') invalid();
|
|
241
|
+
const property = Object.getOwnPropertyDescriptor(record, key);
|
|
242
|
+
if (property == null || !('value' in property)) invalid();
|
|
243
|
+
const value: unknown = property.value;
|
|
244
|
+
if (
|
|
245
|
+
value != null &&
|
|
246
|
+
(key === 'index'
|
|
247
|
+
? typeof value !== 'number' ||
|
|
248
|
+
!Number.isSafeInteger(value) ||
|
|
249
|
+
value < 0
|
|
250
|
+
: typeof value !== 'string')
|
|
251
|
+
)
|
|
252
|
+
invalid();
|
|
253
|
+
Object.defineProperty(copy, key, {
|
|
254
|
+
value,
|
|
255
|
+
enumerable: true,
|
|
256
|
+
writable: true,
|
|
257
|
+
configurable: true,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
result.push(copy);
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Providers can mutate and re-yield records. Inspect a per-emission snapshot,
|
|
266
|
+
* leaving their originals intact, but keep producer/consumer charge identity.
|
|
267
|
+
*/
|
|
268
|
+
export function snapshotValidatedModelChunk(
|
|
269
|
+
message: AIMessageChunk,
|
|
270
|
+
partial = true
|
|
271
|
+
): AIMessageChunk {
|
|
272
|
+
if (types.isProxy(message)) {
|
|
273
|
+
throw new InvalidModelToolCallError(
|
|
274
|
+
'Accepted model response contains non-serializable tool calls'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const descriptors = Object.getOwnPropertyDescriptors(message);
|
|
278
|
+
// Object.freeze on the provider result must not freeze the SDK's own
|
|
279
|
+
// envelope. LangChain updates IDs, lc_kwargs and response metadata later.
|
|
280
|
+
for (const descriptor of Object.values(descriptors)) {
|
|
281
|
+
descriptor.configurable = true;
|
|
282
|
+
if ('value' in descriptor) descriptor.writable = true;
|
|
283
|
+
}
|
|
284
|
+
const kwargs = descriptors.lc_kwargs;
|
|
285
|
+
if (Object.hasOwn(descriptors, 'lc_kwargs') && 'value' in kwargs) {
|
|
286
|
+
const value: unknown = kwargs.value;
|
|
287
|
+
if (value === null || typeof value !== 'object' || types.isProxy(value)) {
|
|
288
|
+
throw new InvalidModelToolCallError('Invalid model response metadata');
|
|
289
|
+
}
|
|
290
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
291
|
+
for (const field of Object.values(fields)) {
|
|
292
|
+
if (!('value' in field)) {
|
|
293
|
+
throw new InvalidModelToolCallError('Invalid model response metadata');
|
|
294
|
+
}
|
|
295
|
+
field.configurable = true;
|
|
296
|
+
field.writable = true;
|
|
297
|
+
}
|
|
298
|
+
kwargs.value = Object.create(Object.getPrototypeOf(value), fields);
|
|
299
|
+
}
|
|
300
|
+
const copy = Object.create(
|
|
301
|
+
Object.getPrototypeOf(message),
|
|
302
|
+
descriptors
|
|
303
|
+
) as AIMessageChunk;
|
|
304
|
+
detachValidatedModelToolCalls(copy, partial);
|
|
305
|
+
if (partial) linkStreamLimitCanonical(copy, message);
|
|
306
|
+
return copy;
|
|
307
|
+
}
|
package/src/llm/invoke.ts
CHANGED
|
@@ -41,6 +41,11 @@ import {
|
|
|
41
41
|
resolvePreemptAction,
|
|
42
42
|
resolveRestartGraceMs,
|
|
43
43
|
} from '@/llm/preempt';
|
|
44
|
+
import {
|
|
45
|
+
detachValidatedModelToolCalls,
|
|
46
|
+
snapshotValidatedModelChunk,
|
|
47
|
+
InvalidModelToolCallError,
|
|
48
|
+
} from '@/graphs/acceptedModelResponse';
|
|
44
49
|
import {
|
|
45
50
|
assertPreparedProviderRequestFor,
|
|
46
51
|
prepareProviderRequest,
|
|
@@ -1079,7 +1084,8 @@ async function attemptInvokeBody(
|
|
|
1079
1084
|
const signal = config.signal;
|
|
1080
1085
|
if (
|
|
1081
1086
|
signal?.aborted === true &&
|
|
1082
|
-
(signal.reason instanceof StreamLimitExceededError ||
|
|
1087
|
+
(signal.reason instanceof StreamLimitExceededError ||
|
|
1088
|
+
signal.reason instanceof PreparedSubagentError)
|
|
1083
1089
|
) {
|
|
1084
1090
|
throw signal.reason;
|
|
1085
1091
|
}
|
|
@@ -1105,8 +1111,9 @@ async function attemptInvokeBody(
|
|
|
1105
1111
|
const attemptMetadata = config.metadata as
|
|
1106
1112
|
| Record<string, unknown>
|
|
1107
1113
|
| undefined;
|
|
1108
|
-
for await (const
|
|
1114
|
+
for await (const rawChunk of stream) {
|
|
1109
1115
|
throwIfBreakerTripped();
|
|
1116
|
+
const chunk = snapshotValidatedModelChunk(rawChunk);
|
|
1110
1117
|
/** An onChunk consumer replaces the stream handler entirely, so
|
|
1111
1118
|
* stream limits are enforced here for every such caller — public
|
|
1112
1119
|
* package consumers get no other accounting. The internal
|
|
@@ -1127,10 +1134,13 @@ async function attemptInvokeBody(
|
|
|
1127
1134
|
});
|
|
1128
1135
|
}
|
|
1129
1136
|
} else if (registeredStreamHandler == null) {
|
|
1130
|
-
const metadata = config.metadata as
|
|
1137
|
+
const metadata = config.metadata as
|
|
1138
|
+
| Record<string, unknown>
|
|
1139
|
+
| undefined;
|
|
1131
1140
|
const streamHandler = new ChatModelStreamHandler();
|
|
1132
|
-
for await (const
|
|
1141
|
+
for await (const rawChunk of stream) {
|
|
1133
1142
|
throwIfBreakerTripped();
|
|
1143
|
+
const chunk = snapshotValidatedModelChunk(rawChunk);
|
|
1134
1144
|
/**
|
|
1135
1145
|
* The decision is final, so stop consuming here rather than
|
|
1136
1146
|
* trusting the adapter to honor the abort. An adapter that ignores
|
|
@@ -1239,7 +1249,9 @@ async function attemptInvokeBody(
|
|
|
1239
1249
|
}
|
|
1240
1250
|
}
|
|
1241
1251
|
} else {
|
|
1242
|
-
const metadata = config.metadata as
|
|
1252
|
+
const metadata = config.metadata as
|
|
1253
|
+
| Record<string, unknown>
|
|
1254
|
+
| undefined;
|
|
1243
1255
|
/**
|
|
1244
1256
|
* The original wire chunk still reaches the registered handler through
|
|
1245
1257
|
* `streamEvents` (where the late-reasoning skip discards it AFTER the
|
|
@@ -1248,8 +1260,9 @@ async function attemptInvokeBody(
|
|
|
1248
1260
|
* once per attempt, only when a transformation occurs.
|
|
1249
1261
|
*/
|
|
1250
1262
|
let redispatchMetadata: Record<string, unknown> | undefined;
|
|
1251
|
-
for await (const
|
|
1263
|
+
for await (const rawChunk of stream) {
|
|
1252
1264
|
throwIfBreakerTripped();
|
|
1265
|
+
const chunk = snapshotValidatedModelChunk(rawChunk);
|
|
1253
1266
|
/**
|
|
1254
1267
|
* Charged synchronously, ahead of the decoupled `streamEvents`
|
|
1255
1268
|
* reader that will echo this same chunk to the registered handler:
|
|
@@ -1258,7 +1271,11 @@ async function attemptInvokeBody(
|
|
|
1258
1271
|
* throws. The chunk is marked so the echo skips accounting.
|
|
1259
1272
|
*/
|
|
1260
1273
|
if (context != null) {
|
|
1261
|
-
enforceStreamLimitsForWireChunk({
|
|
1274
|
+
enforceStreamLimitsForWireChunk({
|
|
1275
|
+
graph: context,
|
|
1276
|
+
metadata,
|
|
1277
|
+
chunk,
|
|
1278
|
+
});
|
|
1262
1279
|
}
|
|
1263
1280
|
const handlingChunk = getStreamHandlingChunk({
|
|
1264
1281
|
current: finalChunk,
|
|
@@ -1366,7 +1383,8 @@ async function attemptInvokeBody(
|
|
|
1366
1383
|
);
|
|
1367
1384
|
}
|
|
1368
1385
|
if (finalChunk != null || sealedRunId != null) {
|
|
1369
|
-
const discardedChunk =
|
|
1386
|
+
const discardedChunk =
|
|
1387
|
+
finalChunk ?? new AIMessageChunk({ content: '' });
|
|
1370
1388
|
const responseMetadata = {
|
|
1371
1389
|
...discardedChunk.response_metadata,
|
|
1372
1390
|
preempted: true,
|
|
@@ -1448,6 +1466,7 @@ async function attemptInvokeBody(
|
|
|
1448
1466
|
);
|
|
1449
1467
|
}
|
|
1450
1468
|
|
|
1469
|
+
if (finalChunk != null) detachValidatedModelToolCalls(finalChunk);
|
|
1451
1470
|
if ((finalChunk?.tool_calls?.length ?? 0) > 0) {
|
|
1452
1471
|
finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(
|
|
1453
1472
|
(tool_call: ToolCall) => !!tool_call.name
|
|
@@ -1458,9 +1477,9 @@ async function attemptInvokeBody(
|
|
|
1458
1477
|
return { messages: [finalChunk as AIMessageChunk] };
|
|
1459
1478
|
}
|
|
1460
1479
|
|
|
1461
|
-
const finalMessage =
|
|
1462
|
-
messagesForProvider,
|
|
1463
|
-
|
|
1480
|
+
const finalMessage = snapshotValidatedModelChunk(
|
|
1481
|
+
await model.invoke(messagesForProvider, invocationConfig),
|
|
1482
|
+
false
|
|
1464
1483
|
);
|
|
1465
1484
|
if ((finalMessage.tool_calls?.length ?? 0) > 0) {
|
|
1466
1485
|
finalMessage.tool_calls = finalMessage.tool_calls?.filter(
|
|
@@ -1666,7 +1685,8 @@ export async function tryFallbackProviders({
|
|
|
1666
1685
|
* a run that must reject. Check before every fallback invocation. */
|
|
1667
1686
|
if (
|
|
1668
1687
|
config?.signal?.aborted === true &&
|
|
1669
|
-
(config.signal.reason instanceof StreamLimitExceededError ||
|
|
1688
|
+
(config.signal.reason instanceof StreamLimitExceededError ||
|
|
1689
|
+
config.signal.reason instanceof PreparedSubagentError)
|
|
1670
1690
|
) {
|
|
1671
1691
|
throw config.signal.reason;
|
|
1672
1692
|
}
|
|
@@ -1700,7 +1720,11 @@ export async function tryFallbackProviders({
|
|
|
1700
1720
|
* provider failure. Continuing would try the remaining fallbacks and a
|
|
1701
1721
|
* succeeding one would resolve a run that must reject.
|
|
1702
1722
|
*/
|
|
1703
|
-
if (
|
|
1723
|
+
if (
|
|
1724
|
+
e instanceof StreamLimitExceededError ||
|
|
1725
|
+
e instanceof PreparedSubagentError ||
|
|
1726
|
+
e instanceof InvalidModelToolCallError
|
|
1727
|
+
) {
|
|
1704
1728
|
throw e;
|
|
1705
1729
|
}
|
|
1706
1730
|
/** A parallel sibling's trip aborts this branch's composed signal, and
|
|
@@ -1709,7 +1733,8 @@ export async function tryFallbackProviders({
|
|
|
1709
1733
|
* abort. Rethrow the breaker's own reason instead. */
|
|
1710
1734
|
if (
|
|
1711
1735
|
config?.signal?.aborted === true &&
|
|
1712
|
-
(config.signal.reason instanceof StreamLimitExceededError ||
|
|
1736
|
+
(config.signal.reason instanceof StreamLimitExceededError ||
|
|
1737
|
+
config.signal.reason instanceof PreparedSubagentError)
|
|
1713
1738
|
) {
|
|
1714
1739
|
throw config.signal.reason;
|
|
1715
1740
|
}
|
package/src/llm/streamLimits.ts
CHANGED