@librechat/agents 3.2.54 → 3.2.55
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/llm/invoke.cjs +3 -0
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/truncation.cjs +110 -0
- package/dist/cjs/llm/truncation.cjs.map +1 -0
- package/dist/esm/llm/invoke.mjs +3 -0
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/truncation.mjs +110 -0
- package/dist/esm/llm/truncation.mjs.map +1 -0
- package/dist/types/llm/truncation.d.ts +45 -0
- package/package.json +1 -1
- package/src/llm/invoke.ts +3 -0
- package/src/llm/truncation.test.ts +242 -0
- package/src/llm/truncation.ts +178 -0
- package/src/specs/bedrock-truncation.live.test.ts +191 -0
package/dist/cjs/llm/invoke.cjs
CHANGED
|
@@ -4,6 +4,7 @@ const require_core = require("../messages/core.cjs");
|
|
|
4
4
|
require("../messages/index.cjs");
|
|
5
5
|
const require_toolOutputReferences = require("../tools/toolOutputReferences.cjs");
|
|
6
6
|
const require_stream = require("../stream.cjs");
|
|
7
|
+
const require_truncation = require("./truncation.cjs");
|
|
7
8
|
const require_providers = require("./providers.cjs");
|
|
8
9
|
const require_init = require("./init.cjs");
|
|
9
10
|
let _langchain_core_messages = require("@langchain/core/messages");
|
|
@@ -137,10 +138,12 @@ async function attemptInvoke({ model, messages, provider, context, onChunk }, co
|
|
|
137
138
|
}
|
|
138
139
|
if (require_providers.manualToolStreamProviders.has(provider)) finalChunk = require_core.modifyDeltaProperties(provider, finalChunk);
|
|
139
140
|
if ((finalChunk?.tool_calls?.length ?? 0) > 0) finalChunk.tool_calls = finalChunk.tool_calls?.filter((tool_call) => !!tool_call.name);
|
|
141
|
+
require_truncation.assertNotTruncatedToolCall(finalChunk, provider);
|
|
140
142
|
return { messages: [finalChunk] };
|
|
141
143
|
}
|
|
142
144
|
const finalMessage = await model.invoke(messagesForProvider, config);
|
|
143
145
|
if ((finalMessage.tool_calls?.length ?? 0) > 0) finalMessage.tool_calls = finalMessage.tool_calls?.filter((tool_call) => !!tool_call.name);
|
|
146
|
+
require_truncation.assertNotTruncatedToolCall(finalMessage, provider);
|
|
144
147
|
return { messages: [finalMessage] };
|
|
145
148
|
}
|
|
146
149
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"invoke.cjs","names":["ChatModelStreamHandler","AIMessageChunk","annotateMessagesForLLM","manualToolStreamProviders","modifyDeltaProperties","initializeModel"],"sources":["../../../src/llm/invoke.ts"],"sourcesContent":["import { concat } from '@langchain/core/utils/stream';\nimport { AIMessageChunk } from '@langchain/core/messages';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type { ToolCall } from '@langchain/core/messages/tool';\nimport type { BaseMessage } from '@langchain/core/messages';\nimport type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';\nimport type * as t from '@/types';\nimport { annotateMessagesForLLM } from '@/tools/toolOutputReferences';\nimport { Constants, GraphEvents, Providers } from '@/common';\nimport { manualToolStreamProviders } from '@/llm/providers';\nimport { modifyDeltaProperties } from '@/messages';\nimport { ChatModelStreamHandler } from '@/stream';\nimport { initializeModel } from '@/llm/init';\n\n/**\n * Context passed to `attemptInvoke`. Matches the subset of Graph that\n * `ChatModelStreamHandler.handle` needs *plus* the explicit\n * `getOrCreateToolOutputRegistry()` accessor that `attemptInvoke`\n * itself calls to pull the run-scoped tool-output registry off the\n * graph and project each relevant ToolMessage into a transient\n * annotated copy before the provider call.\n *\n * The intersection is intentional: `Parameters<...>[3]` resolves\n * indirectly through the stream handler's signature (which returns\n * `StandardGraph` and already exposes the accessor since #117), but\n * stating it explicitly here surfaces the contract at the call site —\n * a developer reading `attemptInvoke` doesn't have to chase the\n * upstream handler's parameter list to discover that\n * `context?.getOrCreateToolOutputRegistry()` is a real thing. Single\n * optional chain only — the method itself is required on the\n * `StandardGraph` branch of the intersection, so the second `?.` is\n * unnecessary at the call site.\n *\n * `NonNullable<...>` strips `undefined` from the upstream parameter\n * type so the intersection doesn't collapse to `never` on the\n * undefined branch; callers express optionality via `context?:\n * InvokeContext` on the function signature instead.\n *\n * Callers without a registry (e.g. summarization) simply pass no\n * `context` and the transform safely no-ops.\n */\nexport type InvokeContext = NonNullable<\n Parameters<ChatModelStreamHandler['handle']>[3]\n> & {\n getOrCreateToolOutputRegistry?(): ToolOutputReferenceRegistry | undefined;\n};\n\n/**\n * Per-chunk callback for custom stream processing.\n * When provided, replaces the default `ChatModelStreamHandler`.\n */\nexport type OnChunk = (chunk: AIMessageChunk) => void | Promise<void>;\n\nfunction getRegisteredDefaultChatStreamHandler(\n context?: InvokeContext\n): ChatModelStreamHandler | undefined {\n const handler = context?.handlerRegistry?.getHandler(\n GraphEvents.CHAT_MODEL_STREAM\n );\n return handler instanceof ChatModelStreamHandler ? handler : undefined;\n}\n\nfunction hasReasoningDetails(chunk: AIMessageChunk): boolean {\n const reasoningDetails = chunk.additional_kwargs.reasoning_details;\n return Array.isArray(reasoningDetails) && reasoningDetails.length > 0;\n}\n\nfunction removeOpenRouterFinalReasoningReplayContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null || content === next.content) {\n return next;\n }\n\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n })\n );\n}\n\nfunction getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): string | undefined {\n if (\n provider !== Providers.OPENROUTER ||\n current == null ||\n !hasReasoningDetails(next) ||\n typeof current.content !== 'string' ||\n current.content === '' ||\n typeof next.content !== 'string' ||\n next.content === ''\n ) {\n return undefined;\n }\n if (!next.content.startsWith(current.content)) {\n return next.content;\n }\n return next.content.slice(current.content.length);\n}\n\nfunction removeReasoningDetails(\n additionalKwargs: AIMessageChunk['additional_kwargs']\n): AIMessageChunk['additional_kwargs'] {\n return Object.fromEntries(\n Object.entries(additionalKwargs).filter(\n ([key]) => key !== 'reasoning_details'\n )\n );\n}\n\nfunction getStreamHandlingChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk | undefined {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null) {\n return next;\n }\n if (content === '') {\n return undefined;\n }\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n additional_kwargs: removeReasoningDetails(next.additional_kwargs),\n })\n );\n}\n\nfunction appendStreamChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n if (current == null) {\n return next;\n }\n return concat(\n current,\n removeOpenRouterFinalReasoningReplayContent({ current, next, provider })\n );\n}\n\n/**\n * Invokes a chat model with the given messages, handling both streaming and\n * non-streaming paths.\n *\n * By default, stream chunks are processed through a `ChatModelStreamHandler`\n * that dispatches run steps (MESSAGE_CREATION, TOOL_CALLS) for the graph.\n * Pass an `onChunk` callback to override this with custom chunk processing\n * (e.g. summarization delta events).\n */\nexport async function attemptInvoke(\n {\n model,\n messages,\n provider,\n context,\n onChunk,\n }: {\n model: t.ChatModel;\n messages: BaseMessage[];\n provider: Providers;\n context?: InvokeContext;\n onChunk?: OnChunk;\n },\n config?: RunnableConfig\n): Promise<Partial<t.BaseGraphState>> {\n /**\n * Pull the run-scoped tool output registry off the graph (when one\n * exists) and project ToolMessages carrying ref metadata into a\n * transient annotated copy. The original `messages` array stays\n * untouched so the graph state never sees `[ref: …]` / `_ref`\n * payload.\n */\n const registry = context?.getOrCreateToolOutputRegistry();\n const runId = config?.configurable?.run_id as string | undefined;\n const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);\n\n /**\n * Stamp the provider that is ACTUALLY serving this invocation onto the\n * callback metadata. `attemptInvoke` is the single funnel for primary,\n * fallback, and summarization model calls, so consumers that need\n * provider attribution per call (the subagent usage-capture handler)\n * read this key instead of trusting static agent config — which is\n * wrong for fallback-served calls — or `ls_provider` — which derived\n * providers inherit from their base class.\n */\n config = {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_PROVIDER]: provider,\n },\n };\n\n if (model.stream) {\n const stream = await model.stream(messagesForProvider, config);\n let finalChunk: AIMessageChunk | undefined;\n const registeredStreamHandler =\n getRegisteredDefaultChatStreamHandler(context);\n\n if (onChunk) {\n for await (const chunk of stream) {\n await onChunk(chunk);\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else if (registeredStreamHandler == null) {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n const streamHandler = new ChatModelStreamHandler();\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null) {\n await streamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null && handlingChunk !== chunk) {\n await registeredStreamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n }\n\n if (manualToolStreamProviders.has(provider)) {\n finalChunk = modifyDeltaProperties(provider, finalChunk);\n }\n\n if ((finalChunk?.tool_calls?.length ?? 0) > 0) {\n finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n\n return { messages: [finalChunk as AIMessageChunk] };\n }\n\n const finalMessage = await model.invoke(messagesForProvider, config);\n if ((finalMessage.tool_calls?.length ?? 0) > 0) {\n finalMessage.tool_calls = finalMessage.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n return { messages: [finalMessage] };\n}\n\n/**\n * Best-effort read of the configured model name from client options.\n * Providers disagree on the key (`model` vs `modelName`).\n */\nfunction extractClientOptionsModel(\n clientOptions: t.ClientOptions | undefined\n): string | undefined {\n const options = clientOptions as\n | { model?: unknown; modelName?: unknown }\n | undefined;\n if (typeof options?.model === 'string' && options.model !== '') {\n return options.model;\n }\n if (typeof options?.modelName === 'string' && options.modelName !== '') {\n return options.modelName;\n }\n return undefined;\n}\n\n/**\n * Attempts each fallback provider in order until one succeeds.\n * Throws the last error if all fallbacks fail.\n */\nexport async function tryFallbackProviders({\n fallbacks,\n tools,\n messages,\n config,\n primaryError,\n context,\n onChunk,\n}: {\n fallbacks: Array<{ provider: Providers; clientOptions?: t.ClientOptions }>;\n tools?: t.GraphTools;\n messages: BaseMessage[];\n config?: RunnableConfig;\n primaryError: unknown;\n context?: InvokeContext;\n onChunk?: OnChunk;\n}): Promise<Partial<t.BaseGraphState> | undefined> {\n let lastError: unknown = primaryError;\n for (const fb of fallbacks) {\n try {\n const fbModel = initializeModel({\n provider: fb.provider,\n clientOptions: fb.clientOptions,\n tools,\n });\n /**\n * Stamp the fallback's configured model onto callback metadata so\n * per-call attribution (subagent usage capture) doesn't fall back to\n * the PRIMARY config's model when the provider reports no\n * `ls_model_name`. The serving provider is stamped uniformly by\n * `attemptInvoke` (`INVOKED_PROVIDER`).\n */\n const fbModelName = extractClientOptionsModel(fb.clientOptions);\n const fbConfig: RunnableConfig | undefined =\n fbModelName == null\n ? config\n : {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_MODEL]: fbModelName,\n },\n };\n const result = await attemptInvoke(\n {\n model: fbModel as t.ChatModel,\n messages,\n provider: fb.provider,\n context,\n onChunk,\n },\n fbConfig\n );\n return result;\n } catch (e) {\n lastError = e;\n continue;\n }\n }\n if (lastError !== undefined) {\n throw lastError;\n }\n return undefined;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"invoke.cjs","names":["ChatModelStreamHandler","AIMessageChunk","annotateMessagesForLLM","manualToolStreamProviders","modifyDeltaProperties","initializeModel"],"sources":["../../../src/llm/invoke.ts"],"sourcesContent":["import { concat } from '@langchain/core/utils/stream';\nimport { AIMessageChunk } from '@langchain/core/messages';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type { ToolCall } from '@langchain/core/messages/tool';\nimport type { BaseMessage } from '@langchain/core/messages';\nimport type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';\nimport type * as t from '@/types';\nimport { annotateMessagesForLLM } from '@/tools/toolOutputReferences';\nimport { assertNotTruncatedToolCall } from '@/llm/truncation';\nimport { Constants, GraphEvents, Providers } from '@/common';\nimport { manualToolStreamProviders } from '@/llm/providers';\nimport { modifyDeltaProperties } from '@/messages';\nimport { ChatModelStreamHandler } from '@/stream';\nimport { initializeModel } from '@/llm/init';\n\n/**\n * Context passed to `attemptInvoke`. Matches the subset of Graph that\n * `ChatModelStreamHandler.handle` needs *plus* the explicit\n * `getOrCreateToolOutputRegistry()` accessor that `attemptInvoke`\n * itself calls to pull the run-scoped tool-output registry off the\n * graph and project each relevant ToolMessage into a transient\n * annotated copy before the provider call.\n *\n * The intersection is intentional: `Parameters<...>[3]` resolves\n * indirectly through the stream handler's signature (which returns\n * `StandardGraph` and already exposes the accessor since #117), but\n * stating it explicitly here surfaces the contract at the call site —\n * a developer reading `attemptInvoke` doesn't have to chase the\n * upstream handler's parameter list to discover that\n * `context?.getOrCreateToolOutputRegistry()` is a real thing. Single\n * optional chain only — the method itself is required on the\n * `StandardGraph` branch of the intersection, so the second `?.` is\n * unnecessary at the call site.\n *\n * `NonNullable<...>` strips `undefined` from the upstream parameter\n * type so the intersection doesn't collapse to `never` on the\n * undefined branch; callers express optionality via `context?:\n * InvokeContext` on the function signature instead.\n *\n * Callers without a registry (e.g. summarization) simply pass no\n * `context` and the transform safely no-ops.\n */\nexport type InvokeContext = NonNullable<\n Parameters<ChatModelStreamHandler['handle']>[3]\n> & {\n getOrCreateToolOutputRegistry?(): ToolOutputReferenceRegistry | undefined;\n};\n\n/**\n * Per-chunk callback for custom stream processing.\n * When provided, replaces the default `ChatModelStreamHandler`.\n */\nexport type OnChunk = (chunk: AIMessageChunk) => void | Promise<void>;\n\nfunction getRegisteredDefaultChatStreamHandler(\n context?: InvokeContext\n): ChatModelStreamHandler | undefined {\n const handler = context?.handlerRegistry?.getHandler(\n GraphEvents.CHAT_MODEL_STREAM\n );\n return handler instanceof ChatModelStreamHandler ? handler : undefined;\n}\n\nfunction hasReasoningDetails(chunk: AIMessageChunk): boolean {\n const reasoningDetails = chunk.additional_kwargs.reasoning_details;\n return Array.isArray(reasoningDetails) && reasoningDetails.length > 0;\n}\n\nfunction removeOpenRouterFinalReasoningReplayContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null || content === next.content) {\n return next;\n }\n\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n })\n );\n}\n\nfunction getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): string | undefined {\n if (\n provider !== Providers.OPENROUTER ||\n current == null ||\n !hasReasoningDetails(next) ||\n typeof current.content !== 'string' ||\n current.content === '' ||\n typeof next.content !== 'string' ||\n next.content === ''\n ) {\n return undefined;\n }\n if (!next.content.startsWith(current.content)) {\n return next.content;\n }\n return next.content.slice(current.content.length);\n}\n\nfunction removeReasoningDetails(\n additionalKwargs: AIMessageChunk['additional_kwargs']\n): AIMessageChunk['additional_kwargs'] {\n return Object.fromEntries(\n Object.entries(additionalKwargs).filter(\n ([key]) => key !== 'reasoning_details'\n )\n );\n}\n\nfunction getStreamHandlingChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk | undefined {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null) {\n return next;\n }\n if (content === '') {\n return undefined;\n }\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n additional_kwargs: removeReasoningDetails(next.additional_kwargs),\n })\n );\n}\n\nfunction appendStreamChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n if (current == null) {\n return next;\n }\n return concat(\n current,\n removeOpenRouterFinalReasoningReplayContent({ current, next, provider })\n );\n}\n\n/**\n * Invokes a chat model with the given messages, handling both streaming and\n * non-streaming paths.\n *\n * By default, stream chunks are processed through a `ChatModelStreamHandler`\n * that dispatches run steps (MESSAGE_CREATION, TOOL_CALLS) for the graph.\n * Pass an `onChunk` callback to override this with custom chunk processing\n * (e.g. summarization delta events).\n */\nexport async function attemptInvoke(\n {\n model,\n messages,\n provider,\n context,\n onChunk,\n }: {\n model: t.ChatModel;\n messages: BaseMessage[];\n provider: Providers;\n context?: InvokeContext;\n onChunk?: OnChunk;\n },\n config?: RunnableConfig\n): Promise<Partial<t.BaseGraphState>> {\n /**\n * Pull the run-scoped tool output registry off the graph (when one\n * exists) and project ToolMessages carrying ref metadata into a\n * transient annotated copy. The original `messages` array stays\n * untouched so the graph state never sees `[ref: …]` / `_ref`\n * payload.\n */\n const registry = context?.getOrCreateToolOutputRegistry();\n const runId = config?.configurable?.run_id as string | undefined;\n const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);\n\n /**\n * Stamp the provider that is ACTUALLY serving this invocation onto the\n * callback metadata. `attemptInvoke` is the single funnel for primary,\n * fallback, and summarization model calls, so consumers that need\n * provider attribution per call (the subagent usage-capture handler)\n * read this key instead of trusting static agent config — which is\n * wrong for fallback-served calls — or `ls_provider` — which derived\n * providers inherit from their base class.\n */\n config = {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_PROVIDER]: provider,\n },\n };\n\n if (model.stream) {\n const stream = await model.stream(messagesForProvider, config);\n let finalChunk: AIMessageChunk | undefined;\n const registeredStreamHandler =\n getRegisteredDefaultChatStreamHandler(context);\n\n if (onChunk) {\n for await (const chunk of stream) {\n await onChunk(chunk);\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else if (registeredStreamHandler == null) {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n const streamHandler = new ChatModelStreamHandler();\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null) {\n await streamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null && handlingChunk !== chunk) {\n await registeredStreamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n }\n\n if (manualToolStreamProviders.has(provider)) {\n finalChunk = modifyDeltaProperties(provider, finalChunk);\n }\n\n if ((finalChunk?.tool_calls?.length ?? 0) > 0) {\n finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n\n assertNotTruncatedToolCall(finalChunk, provider);\n return { messages: [finalChunk as AIMessageChunk] };\n }\n\n const finalMessage = await model.invoke(messagesForProvider, config);\n if ((finalMessage.tool_calls?.length ?? 0) > 0) {\n finalMessage.tool_calls = finalMessage.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n assertNotTruncatedToolCall(finalMessage, provider);\n return { messages: [finalMessage] };\n}\n\n/**\n * Best-effort read of the configured model name from client options.\n * Providers disagree on the key (`model` vs `modelName`).\n */\nfunction extractClientOptionsModel(\n clientOptions: t.ClientOptions | undefined\n): string | undefined {\n const options = clientOptions as\n | { model?: unknown; modelName?: unknown }\n | undefined;\n if (typeof options?.model === 'string' && options.model !== '') {\n return options.model;\n }\n if (typeof options?.modelName === 'string' && options.modelName !== '') {\n return options.modelName;\n }\n return undefined;\n}\n\n/**\n * Attempts each fallback provider in order until one succeeds.\n * Throws the last error if all fallbacks fail.\n */\nexport async function tryFallbackProviders({\n fallbacks,\n tools,\n messages,\n config,\n primaryError,\n context,\n onChunk,\n}: {\n fallbacks: Array<{ provider: Providers; clientOptions?: t.ClientOptions }>;\n tools?: t.GraphTools;\n messages: BaseMessage[];\n config?: RunnableConfig;\n primaryError: unknown;\n context?: InvokeContext;\n onChunk?: OnChunk;\n}): Promise<Partial<t.BaseGraphState> | undefined> {\n let lastError: unknown = primaryError;\n for (const fb of fallbacks) {\n try {\n const fbModel = initializeModel({\n provider: fb.provider,\n clientOptions: fb.clientOptions,\n tools,\n });\n /**\n * Stamp the fallback's configured model onto callback metadata so\n * per-call attribution (subagent usage capture) doesn't fall back to\n * the PRIMARY config's model when the provider reports no\n * `ls_model_name`. The serving provider is stamped uniformly by\n * `attemptInvoke` (`INVOKED_PROVIDER`).\n */\n const fbModelName = extractClientOptionsModel(fb.clientOptions);\n const fbConfig: RunnableConfig | undefined =\n fbModelName == null\n ? config\n : {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_MODEL]: fbModelName,\n },\n };\n const result = await attemptInvoke(\n {\n model: fbModel as t.ChatModel,\n messages,\n provider: fb.provider,\n context,\n onChunk,\n },\n fbConfig\n );\n return result;\n } catch (e) {\n lastError = e;\n continue;\n }\n }\n if (lastError !== undefined) {\n throw lastError;\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;AAsDA,SAAS,sCACP,SACoC;CACpC,MAAM,UAAU,SAAS,iBAAiB,WAAA,sBAE1C;CACA,OAAO,mBAAmBA,eAAAA,yBAAyB,UAAU,KAAA;AAC/D;AAEA,SAAS,oBAAoB,OAAgC;CAC3D,MAAM,mBAAmB,MAAM,kBAAkB;CACjD,OAAO,MAAM,QAAQ,gBAAgB,KAAK,iBAAiB,SAAS;AACtE;AAEA,SAAS,4CAA4C,EACnD,SACA,MACA,YAKiB;CACjB,MAAM,UAAU,mCAAmC;EACjD;EACA;EACA;CACF,CAAC;CACD,IAAI,WAAW,QAAQ,YAAY,KAAK,SACtC,OAAO;CAGT,OAAO,IAAIC,yBAAAA,eACT,OAAO,OAAO,CAAC,GAAG,MAAM,EACtB,QACF,CAAC,CACH;AACF;AAEA,SAAS,mCAAmC,EAC1C,SACA,MACA,YAKqB;CACrB,IACE,aAAA,gBACA,WAAW,QACX,CAAC,oBAAoB,IAAI,KACzB,OAAO,QAAQ,YAAY,YAC3B,QAAQ,YAAY,MACpB,OAAO,KAAK,YAAY,YACxB,KAAK,YAAY,IAEjB;CAEF,IAAI,CAAC,KAAK,QAAQ,WAAW,QAAQ,OAAO,GAC1C,OAAO,KAAK;CAEd,OAAO,KAAK,QAAQ,MAAM,QAAQ,QAAQ,MAAM;AAClD;AAEA,SAAS,uBACP,kBACqC;CACrC,OAAO,OAAO,YACZ,OAAO,QAAQ,gBAAgB,CAAC,CAAC,QAC9B,CAAC,SAAS,QAAQ,mBACrB,CACF;AACF;AAEA,SAAS,uBAAuB,EAC9B,SACA,MACA,YAK6B;CAC7B,MAAM,UAAU,mCAAmC;EACjD;EACA;EACA;CACF,CAAC;CACD,IAAI,WAAW,MACb,OAAO;CAET,IAAI,YAAY,IACd;CAEF,OAAO,IAAIA,yBAAAA,eACT,OAAO,OAAO,CAAC,GAAG,MAAM;EACtB;EACA,mBAAmB,uBAAuB,KAAK,iBAAiB;CAClE,CAAC,CACH;AACF;AAEA,SAAS,kBAAkB,EACzB,SACA,MACA,YAKiB;CACjB,IAAI,WAAW,MACb,OAAO;CAET,QAAA,GAAA,6BAAA,OAAA,CACE,SACA,4CAA4C;EAAE;EAAS;EAAM;CAAS,CAAC,CACzE;AACF;;;;;;;;;;AAWA,eAAsB,cACpB,EACE,OACA,UACA,UACA,SACA,WAQF,QACoC;;;;;;;;CAQpC,MAAM,WAAW,SAAS,8BAA8B;CACxD,MAAM,QAAQ,QAAQ,cAAc;CACpC,MAAM,sBAAsBC,6BAAAA,uBAAuB,UAAU,UAAU,KAAK;;;;;;;;;;CAW5E,SAAS;EACP,GAAG;EACH,UAAU;GACR,GAAI,QAAQ,YAAY,CAAC;2BACK;EAChC;CACF;CAEA,IAAI,MAAM,QAAQ;EAChB,MAAM,SAAS,MAAM,MAAM,OAAO,qBAAqB,MAAM;EAC7D,IAAI;EACJ,MAAM,0BACJ,sCAAsC,OAAO;EAE/C,IAAI,SACF,WAAW,MAAM,SAAS,QAAQ;GAChC,MAAM,QAAQ,KAAK;GACnB,aAAa,kBAAkB;IAC7B,SAAS;IACT,MAAM;IACN;GACF,CAAC;EACH;OACK,IAAI,2BAA2B,MAAM;GAC1C,MAAM,WAAW,OAAO;GACxB,MAAM,gBAAgB,IAAIF,eAAAA,uBAAuB;GACjD,WAAW,MAAM,SAAS,QAAQ;IAChC,MAAM,gBAAgB,uBAAuB;KAC3C,SAAS;KACT,MAAM;KACN;IACF,CAAC;IACD,IAAI,iBAAiB,MACnB,MAAM,cAAc,OAAA,wBAElB,EAAE,OAAO,cAAc,GACvB,UACA,OACF;IAEF,aAAa,kBAAkB;KAC7B,SAAS;KACT,MAAM;KACN;IACF,CAAC;GACH;EACF,OAAO;GACL,MAAM,WAAW,OAAO;GACxB,WAAW,MAAM,SAAS,QAAQ;IAChC,MAAM,gBAAgB,uBAAuB;KAC3C,SAAS;KACT,MAAM;KACN;IACF,CAAC;IACD,IAAI,iBAAiB,QAAQ,kBAAkB,OAC7C,MAAM,wBAAwB,OAAA,wBAE5B,EAAE,OAAO,cAAc,GACvB,UACA,OACF;IAEF,aAAa,kBAAkB;KAC7B,SAAS;KACT,MAAM;KACN;IACF,CAAC;GACH;EACF;EAEA,IAAIG,kBAAAA,0BAA0B,IAAI,QAAQ,GACxC,aAAaC,aAAAA,sBAAsB,UAAU,UAAU;EAGzD,KAAK,YAAY,YAAY,UAAU,KAAK,GAC1C,WAAY,aAAa,WAAY,YAAY,QAC9C,cAAwB,CAAC,CAAC,UAAU,IACvC;EAGF,mBAAA,2BAA2B,YAAY,QAAQ;EAC/C,OAAO,EAAE,UAAU,CAAC,UAA4B,EAAE;CACpD;CAEA,MAAM,eAAe,MAAM,MAAM,OAAO,qBAAqB,MAAM;CACnE,KAAK,aAAa,YAAY,UAAU,KAAK,GAC3C,aAAa,aAAa,aAAa,YAAY,QAChD,cAAwB,CAAC,CAAC,UAAU,IACvC;CAEF,mBAAA,2BAA2B,cAAc,QAAQ;CACjD,OAAO,EAAE,UAAU,CAAC,YAAY,EAAE;AACpC;;;;;AAMA,SAAS,0BACP,eACoB;CACpB,MAAM,UAAU;CAGhB,IAAI,OAAO,SAAS,UAAU,YAAY,QAAQ,UAAU,IAC1D,OAAO,QAAQ;CAEjB,IAAI,OAAO,SAAS,cAAc,YAAY,QAAQ,cAAc,IAClE,OAAO,QAAQ;AAGnB;;;;;AAMA,eAAsB,qBAAqB,EACzC,WACA,OACA,UACA,QACA,cACA,SACA,WASiD;CACjD,IAAI,YAAqB;CACzB,KAAK,MAAM,MAAM,WACf,IAAI;EACF,MAAM,UAAUC,aAAAA,gBAAgB;GAC9B,UAAU,GAAG;GACb,eAAe,GAAG;GAClB;EACF,CAAC;;;;;;;;EAQD,MAAM,cAAc,0BAA0B,GAAG,aAAa;EAC9D,MAAM,WACJ,eAAe,OACX,SACA;GACA,GAAG;GACH,UAAU;IACR,GAAI,QAAQ,YAAY,CAAC;yBACE;GAC7B;EACF;EAWJ,OAAO,MAVc,cACnB;GACE,OAAO;GACP;GACA,UAAU,GAAG;GACb;GACA;EACF,GACA,QACF;CAEF,SAAS,GAAG;EACV,YAAY;EACZ;CACF;CAEF,IAAI,cAAc,KAAA,GAChB,MAAM;AAGV"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require("../common/enum.cjs");
|
|
2
|
+
require("../common/index.cjs");
|
|
3
|
+
//#region src/llm/truncation.ts
|
|
4
|
+
/**
|
|
5
|
+
* Providers whose adapters deliver tool calls as complete, atomic objects
|
|
6
|
+
* rather than streamed argument deltas. Google/Vertex (GenAI) emit each
|
|
7
|
+
* `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does
|
|
8
|
+
* NOT imply the arguments were cut off — the truncation guard must skip them.
|
|
9
|
+
*/
|
|
10
|
+
const ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set(["google", "vertexai"]);
|
|
11
|
+
const MAX_TOKEN_VALUES = new Set([
|
|
12
|
+
"max_tokens",
|
|
13
|
+
"max_token",
|
|
14
|
+
"maxtokens",
|
|
15
|
+
"max_output_tokens"
|
|
16
|
+
]);
|
|
17
|
+
const LENGTH_VALUES = new Set(["length"]);
|
|
18
|
+
function normalizeStopValue(value) {
|
|
19
|
+
if (typeof value !== "string") return null;
|
|
20
|
+
const normalized = value.trim().toLowerCase();
|
|
21
|
+
if (MAX_TOKEN_VALUES.has(normalized)) return "max_tokens";
|
|
22
|
+
if (LENGTH_VALUES.has(normalized)) return "length";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
27
|
+
* shape we stream:
|
|
28
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
29
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
30
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
31
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
32
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
33
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
34
|
+
* - `response_metadata.finishReason` (Google)
|
|
35
|
+
*
|
|
36
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
37
|
+
* output token ceiling, otherwise `null`.
|
|
38
|
+
*/
|
|
39
|
+
function getTruncationStopReason(message) {
|
|
40
|
+
const meta = message?.response_metadata;
|
|
41
|
+
const additionalKwargs = message?.additional_kwargs;
|
|
42
|
+
if (meta == null && additionalKwargs == null) return null;
|
|
43
|
+
const messageStop = meta?.messageStop;
|
|
44
|
+
const incompleteDetails = meta?.incomplete_details;
|
|
45
|
+
const candidates = [
|
|
46
|
+
meta?.stopReason,
|
|
47
|
+
meta?.stop_reason,
|
|
48
|
+
meta?.finish_reason,
|
|
49
|
+
meta?.finishReason,
|
|
50
|
+
messageStop?.stopReason,
|
|
51
|
+
incompleteDetails?.reason,
|
|
52
|
+
additionalKwargs?.stop_reason
|
|
53
|
+
];
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const normalized = normalizeStopValue(candidate);
|
|
56
|
+
if (normalized != null) return normalized;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function hasOpenToolCall(message) {
|
|
61
|
+
const m = message;
|
|
62
|
+
return (m.tool_calls?.length ?? 0) > 0 || (m.tool_call_chunks?.length ?? 0) > 0 || (m.invalid_tool_calls?.length ?? 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
66
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
67
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
68
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
69
|
+
*/
|
|
70
|
+
var OutputTruncationError = class extends Error {
|
|
71
|
+
stopReason;
|
|
72
|
+
toolCallNames;
|
|
73
|
+
constructor(stopReason, toolCallNames) {
|
|
74
|
+
const named = toolCallNames.length > 0 ? ` (tool call: ${toolCallNames.join(", ")})` : "";
|
|
75
|
+
super(`The model response was truncated at the maximum output token limit before the tool call arguments were complete${named}. Increase the model's max output tokens, or have the model produce smaller arguments — for large files, write a lean main file and move bulky content into separate files.`);
|
|
76
|
+
this.name = "OutputTruncationError";
|
|
77
|
+
this.stopReason = stopReason;
|
|
78
|
+
this.toolCallNames = toolCallNames;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
function collectToolCallNames(message) {
|
|
82
|
+
const m = message;
|
|
83
|
+
const names = [
|
|
84
|
+
...m.tool_calls ?? [],
|
|
85
|
+
...m.tool_call_chunks ?? [],
|
|
86
|
+
...m.invalid_tool_calls ?? []
|
|
87
|
+
].map((tc) => tc.name).filter((name) => typeof name === "string" && name !== "");
|
|
88
|
+
return [...new Set(names)];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
92
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
93
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
94
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
95
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
96
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
97
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
98
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
99
|
+
*/
|
|
100
|
+
function assertNotTruncatedToolCall(message, provider) {
|
|
101
|
+
if (message == null) return;
|
|
102
|
+
if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) return;
|
|
103
|
+
const stopReason = getTruncationStopReason(message);
|
|
104
|
+
if (stopReason == null || !hasOpenToolCall(message)) return;
|
|
105
|
+
throw new OutputTruncationError(stopReason, collectToolCallNames(message));
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
exports.assertNotTruncatedToolCall = assertNotTruncatedToolCall;
|
|
109
|
+
|
|
110
|
+
//# sourceMappingURL=truncation.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"truncation.cjs","names":[],"sources":["../../../src/llm/truncation.ts"],"sourcesContent":["import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';\nimport { Providers } from '@/common';\n\n/**\n * Providers whose adapters deliver tool calls as complete, atomic objects\n * rather than streamed argument deltas. Google/Vertex (GenAI) emit each\n * `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does\n * NOT imply the arguments were cut off — the truncation guard must skip them.\n */\nconst ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set<Providers>([\n Providers.GOOGLE,\n Providers.VERTEXAI,\n]);\n\n/**\n * Normalized truncation reasons across providers. Providers disagree on the\n * key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,\n * Google uses `MAX_TOKENS`.\n */\nexport type TruncationStopReason = 'max_tokens' | 'length';\n\nconst MAX_TOKEN_VALUES = new Set([\n 'max_tokens',\n 'max_token',\n 'maxtokens',\n 'max_output_tokens',\n]);\nconst LENGTH_VALUES = new Set(['length']);\n\nfunction normalizeStopValue(value: unknown): TruncationStopReason | null {\n if (typeof value !== 'string') {\n return null;\n }\n const normalized = value.trim().toLowerCase();\n if (MAX_TOKEN_VALUES.has(normalized)) {\n return 'max_tokens';\n }\n if (LENGTH_VALUES.has(normalized)) {\n return 'length';\n }\n return null;\n}\n\n/**\n * Reads the truncation stop reason off a message, covering every provider\n * shape we stream:\n * - `response_metadata.stopReason` (Bedrock Converse, non-streaming)\n * - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)\n * - `response_metadata.stop_reason` (Anthropic, non-streaming)\n * - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)\n * - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)\n * - `response_metadata.incomplete_details.reason` (OpenAI Responses API)\n * - `response_metadata.finishReason` (Google)\n *\n * Returns the normalized reason when the model stopped because it hit the\n * output token ceiling, otherwise `null`.\n */\nexport function getTruncationStopReason(\n message:\n | Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'>\n | undefined\n | null\n): TruncationStopReason | null {\n const meta = message?.response_metadata as\n | Record<string, unknown>\n | undefined;\n const additionalKwargs = message?.additional_kwargs as\n | Record<string, unknown>\n | undefined;\n if (meta == null && additionalKwargs == null) {\n return null;\n }\n\n const messageStop = meta?.messageStop as { stopReason?: unknown } | undefined;\n const incompleteDetails = meta?.incomplete_details as\n | { reason?: unknown }\n | undefined;\n const candidates: unknown[] = [\n meta?.stopReason,\n meta?.stop_reason,\n meta?.finish_reason,\n meta?.finishReason,\n messageStop?.stopReason,\n incompleteDetails?.reason,\n additionalKwargs?.stop_reason,\n ];\n\n for (const candidate of candidates) {\n const normalized = normalizeStopValue(candidate);\n if (normalized != null) {\n return normalized;\n }\n }\n return null;\n}\n\nfunction hasOpenToolCall(message: AIMessageChunk | BaseMessage): boolean {\n const m = message as AIMessageChunk & {\n tool_calls?: unknown[];\n tool_call_chunks?: unknown[];\n invalid_tool_calls?: unknown[];\n };\n return (\n (m.tool_calls?.length ?? 0) > 0 ||\n (m.tool_call_chunks?.length ?? 0) > 0 ||\n (m.invalid_tool_calls?.length ?? 0) > 0\n );\n}\n\n/**\n * Error raised when a model turn is cut off by the output token limit while it\n * was still emitting a tool call. The arguments are necessarily incomplete, so\n * executing or re-prompting the call would loop on a malformed request. Failing\n * fast with this surfaces an actionable message to the caller instead.\n */\nexport class OutputTruncationError extends Error {\n readonly stopReason: TruncationStopReason;\n readonly toolCallNames: string[];\n\n constructor(stopReason: TruncationStopReason, toolCallNames: string[]) {\n const named =\n toolCallNames.length > 0\n ? ` (tool call: ${toolCallNames.join(', ')})`\n : '';\n super(\n 'The model response was truncated at the maximum output token limit before ' +\n `the tool call arguments were complete${named}. Increase the model's max ` +\n 'output tokens, or have the model produce smaller arguments — for large ' +\n 'files, write a lean main file and move bulky content into separate files.'\n );\n this.name = 'OutputTruncationError';\n this.stopReason = stopReason;\n this.toolCallNames = toolCallNames;\n }\n}\n\nfunction collectToolCallNames(message: AIMessageChunk | BaseMessage): string[] {\n const m = message as AIMessageChunk & {\n tool_calls?: Array<{ name?: string }>;\n tool_call_chunks?: Array<{ name?: string }>;\n invalid_tool_calls?: Array<{ name?: string }>;\n };\n const names = [\n ...(m.tool_calls ?? []),\n ...(m.tool_call_chunks ?? []),\n ...(m.invalid_tool_calls ?? []),\n ]\n .map((tc) => tc.name)\n .filter((name): name is string => typeof name === 'string' && name !== '');\n return [...new Set(names)];\n}\n\n/**\n * Throws {@link OutputTruncationError} when `message` was truncated by the\n * output token limit AND still carries a tool call. For providers that stream\n * tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call\n * emitted under truncation has incomplete arguments — the tool-use block is the\n * last thing the model streams — so letting it through makes the agent loop on a\n * malformed call. No-ops for normal completions, truncated plain-text turns, and\n * providers that deliver complete tool calls atomically (Google/Vertex), where\n * `MAX_TOKENS` does not imply the arguments were cut off.\n */\nexport function assertNotTruncatedToolCall(\n message: AIMessageChunk | BaseMessage | undefined | null,\n provider?: Providers\n): void {\n if (message == null) {\n return;\n }\n if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) {\n return;\n }\n const stopReason = getTruncationStopReason(message);\n if (stopReason == null || !hasOpenToolCall(message)) {\n return;\n }\n throw new OutputTruncationError(stopReason, collectToolCallNames(message));\n}\n"],"mappings":";;;;;;;;;AASA,MAAM,iCAAiC,IAAI,IAAe,CAAA,UAAA,UAG1D,CAAC;AASD,MAAM,mBAAmB,IAAI,IAAI;CAC/B;CACA;CACA;CACA;AACF,CAAC;AACD,MAAM,gBAAgB,IAAI,IAAI,CAAC,QAAQ,CAAC;AAExC,SAAS,mBAAmB,OAA6C;CACvE,IAAI,OAAO,UAAU,UACnB,OAAO;CAET,MAAM,aAAa,MAAM,KAAK,CAAC,CAAC,YAAY;CAC5C,IAAI,iBAAiB,IAAI,UAAU,GACjC,OAAO;CAET,IAAI,cAAc,IAAI,UAAU,GAC9B,OAAO;CAET,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,wBACd,SAI6B;CAC7B,MAAM,OAAO,SAAS;CAGtB,MAAM,mBAAmB,SAAS;CAGlC,IAAI,QAAQ,QAAQ,oBAAoB,MACtC,OAAO;CAGT,MAAM,cAAc,MAAM;CAC1B,MAAM,oBAAoB,MAAM;CAGhC,MAAM,aAAwB;EAC5B,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,aAAa;EACb,mBAAmB;EACnB,kBAAkB;CACpB;CAEA,KAAK,MAAM,aAAa,YAAY;EAClC,MAAM,aAAa,mBAAmB,SAAS;EAC/C,IAAI,cAAc,MAChB,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAS,gBAAgB,SAAgD;CACvE,MAAM,IAAI;CAKV,QACG,EAAE,YAAY,UAAU,KAAK,MAC7B,EAAE,kBAAkB,UAAU,KAAK,MACnC,EAAE,oBAAoB,UAAU,KAAK;AAE1C;;;;;;;AAQA,IAAa,wBAAb,cAA2C,MAAM;CAC/C;CACA;CAEA,YAAY,YAAkC,eAAyB;EACrE,MAAM,QACJ,cAAc,SAAS,IACnB,gBAAgB,cAAc,KAAK,IAAI,EAAE,KACzC;EACN,MACE,kHAC0C,MAAM,4KAGlD;EACA,KAAK,OAAO;EACZ,KAAK,aAAa;EAClB,KAAK,gBAAgB;CACvB;AACF;AAEA,SAAS,qBAAqB,SAAiD;CAC7E,MAAM,IAAI;CAKV,MAAM,QAAQ;EACZ,GAAI,EAAE,cAAc,CAAC;EACrB,GAAI,EAAE,oBAAoB,CAAC;EAC3B,GAAI,EAAE,sBAAsB,CAAC;CAC/B,CAAC,CACE,KAAK,OAAO,GAAG,IAAI,CAAC,CACpB,QAAQ,SAAyB,OAAO,SAAS,YAAY,SAAS,EAAE;CAC3E,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC;AAC3B;;;;;;;;;;;AAYA,SAAgB,2BACd,SACA,UACM;CACN,IAAI,WAAW,MACb;CAEF,IAAI,YAAY,QAAQ,+BAA+B,IAAI,QAAQ,GACjE;CAEF,MAAM,aAAa,wBAAwB,OAAO;CAClD,IAAI,cAAc,QAAQ,CAAC,gBAAgB,OAAO,GAChD;CAEF,MAAM,IAAI,sBAAsB,YAAY,qBAAqB,OAAO,CAAC;AAC3E"}
|
package/dist/esm/llm/invoke.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { modifyDeltaProperties } from "../messages/core.mjs";
|
|
|
4
4
|
import "../messages/index.mjs";
|
|
5
5
|
import { annotateMessagesForLLM } from "../tools/toolOutputReferences.mjs";
|
|
6
6
|
import { ChatModelStreamHandler } from "../stream.mjs";
|
|
7
|
+
import { assertNotTruncatedToolCall } from "./truncation.mjs";
|
|
7
8
|
import { manualToolStreamProviders } from "./providers.mjs";
|
|
8
9
|
import { initializeModel } from "./init.mjs";
|
|
9
10
|
import { AIMessageChunk } from "@langchain/core/messages";
|
|
@@ -137,10 +138,12 @@ async function attemptInvoke({ model, messages, provider, context, onChunk }, co
|
|
|
137
138
|
}
|
|
138
139
|
if (manualToolStreamProviders.has(provider)) finalChunk = modifyDeltaProperties(provider, finalChunk);
|
|
139
140
|
if ((finalChunk?.tool_calls?.length ?? 0) > 0) finalChunk.tool_calls = finalChunk.tool_calls?.filter((tool_call) => !!tool_call.name);
|
|
141
|
+
assertNotTruncatedToolCall(finalChunk, provider);
|
|
140
142
|
return { messages: [finalChunk] };
|
|
141
143
|
}
|
|
142
144
|
const finalMessage = await model.invoke(messagesForProvider, config);
|
|
143
145
|
if ((finalMessage.tool_calls?.length ?? 0) > 0) finalMessage.tool_calls = finalMessage.tool_calls?.filter((tool_call) => !!tool_call.name);
|
|
146
|
+
assertNotTruncatedToolCall(finalMessage, provider);
|
|
144
147
|
return { messages: [finalMessage] };
|
|
145
148
|
}
|
|
146
149
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"invoke.mjs","names":[],"sources":["../../../src/llm/invoke.ts"],"sourcesContent":["import { concat } from '@langchain/core/utils/stream';\nimport { AIMessageChunk } from '@langchain/core/messages';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type { ToolCall } from '@langchain/core/messages/tool';\nimport type { BaseMessage } from '@langchain/core/messages';\nimport type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';\nimport type * as t from '@/types';\nimport { annotateMessagesForLLM } from '@/tools/toolOutputReferences';\nimport { Constants, GraphEvents, Providers } from '@/common';\nimport { manualToolStreamProviders } from '@/llm/providers';\nimport { modifyDeltaProperties } from '@/messages';\nimport { ChatModelStreamHandler } from '@/stream';\nimport { initializeModel } from '@/llm/init';\n\n/**\n * Context passed to `attemptInvoke`. Matches the subset of Graph that\n * `ChatModelStreamHandler.handle` needs *plus* the explicit\n * `getOrCreateToolOutputRegistry()` accessor that `attemptInvoke`\n * itself calls to pull the run-scoped tool-output registry off the\n * graph and project each relevant ToolMessage into a transient\n * annotated copy before the provider call.\n *\n * The intersection is intentional: `Parameters<...>[3]` resolves\n * indirectly through the stream handler's signature (which returns\n * `StandardGraph` and already exposes the accessor since #117), but\n * stating it explicitly here surfaces the contract at the call site —\n * a developer reading `attemptInvoke` doesn't have to chase the\n * upstream handler's parameter list to discover that\n * `context?.getOrCreateToolOutputRegistry()` is a real thing. Single\n * optional chain only — the method itself is required on the\n * `StandardGraph` branch of the intersection, so the second `?.` is\n * unnecessary at the call site.\n *\n * `NonNullable<...>` strips `undefined` from the upstream parameter\n * type so the intersection doesn't collapse to `never` on the\n * undefined branch; callers express optionality via `context?:\n * InvokeContext` on the function signature instead.\n *\n * Callers without a registry (e.g. summarization) simply pass no\n * `context` and the transform safely no-ops.\n */\nexport type InvokeContext = NonNullable<\n Parameters<ChatModelStreamHandler['handle']>[3]\n> & {\n getOrCreateToolOutputRegistry?(): ToolOutputReferenceRegistry | undefined;\n};\n\n/**\n * Per-chunk callback for custom stream processing.\n * When provided, replaces the default `ChatModelStreamHandler`.\n */\nexport type OnChunk = (chunk: AIMessageChunk) => void | Promise<void>;\n\nfunction getRegisteredDefaultChatStreamHandler(\n context?: InvokeContext\n): ChatModelStreamHandler | undefined {\n const handler = context?.handlerRegistry?.getHandler(\n GraphEvents.CHAT_MODEL_STREAM\n );\n return handler instanceof ChatModelStreamHandler ? handler : undefined;\n}\n\nfunction hasReasoningDetails(chunk: AIMessageChunk): boolean {\n const reasoningDetails = chunk.additional_kwargs.reasoning_details;\n return Array.isArray(reasoningDetails) && reasoningDetails.length > 0;\n}\n\nfunction removeOpenRouterFinalReasoningReplayContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null || content === next.content) {\n return next;\n }\n\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n })\n );\n}\n\nfunction getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): string | undefined {\n if (\n provider !== Providers.OPENROUTER ||\n current == null ||\n !hasReasoningDetails(next) ||\n typeof current.content !== 'string' ||\n current.content === '' ||\n typeof next.content !== 'string' ||\n next.content === ''\n ) {\n return undefined;\n }\n if (!next.content.startsWith(current.content)) {\n return next.content;\n }\n return next.content.slice(current.content.length);\n}\n\nfunction removeReasoningDetails(\n additionalKwargs: AIMessageChunk['additional_kwargs']\n): AIMessageChunk['additional_kwargs'] {\n return Object.fromEntries(\n Object.entries(additionalKwargs).filter(\n ([key]) => key !== 'reasoning_details'\n )\n );\n}\n\nfunction getStreamHandlingChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk | undefined {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null) {\n return next;\n }\n if (content === '') {\n return undefined;\n }\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n additional_kwargs: removeReasoningDetails(next.additional_kwargs),\n })\n );\n}\n\nfunction appendStreamChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n if (current == null) {\n return next;\n }\n return concat(\n current,\n removeOpenRouterFinalReasoningReplayContent({ current, next, provider })\n );\n}\n\n/**\n * Invokes a chat model with the given messages, handling both streaming and\n * non-streaming paths.\n *\n * By default, stream chunks are processed through a `ChatModelStreamHandler`\n * that dispatches run steps (MESSAGE_CREATION, TOOL_CALLS) for the graph.\n * Pass an `onChunk` callback to override this with custom chunk processing\n * (e.g. summarization delta events).\n */\nexport async function attemptInvoke(\n {\n model,\n messages,\n provider,\n context,\n onChunk,\n }: {\n model: t.ChatModel;\n messages: BaseMessage[];\n provider: Providers;\n context?: InvokeContext;\n onChunk?: OnChunk;\n },\n config?: RunnableConfig\n): Promise<Partial<t.BaseGraphState>> {\n /**\n * Pull the run-scoped tool output registry off the graph (when one\n * exists) and project ToolMessages carrying ref metadata into a\n * transient annotated copy. The original `messages` array stays\n * untouched so the graph state never sees `[ref: …]` / `_ref`\n * payload.\n */\n const registry = context?.getOrCreateToolOutputRegistry();\n const runId = config?.configurable?.run_id as string | undefined;\n const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);\n\n /**\n * Stamp the provider that is ACTUALLY serving this invocation onto the\n * callback metadata. `attemptInvoke` is the single funnel for primary,\n * fallback, and summarization model calls, so consumers that need\n * provider attribution per call (the subagent usage-capture handler)\n * read this key instead of trusting static agent config — which is\n * wrong for fallback-served calls — or `ls_provider` — which derived\n * providers inherit from their base class.\n */\n config = {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_PROVIDER]: provider,\n },\n };\n\n if (model.stream) {\n const stream = await model.stream(messagesForProvider, config);\n let finalChunk: AIMessageChunk | undefined;\n const registeredStreamHandler =\n getRegisteredDefaultChatStreamHandler(context);\n\n if (onChunk) {\n for await (const chunk of stream) {\n await onChunk(chunk);\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else if (registeredStreamHandler == null) {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n const streamHandler = new ChatModelStreamHandler();\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null) {\n await streamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null && handlingChunk !== chunk) {\n await registeredStreamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n }\n\n if (manualToolStreamProviders.has(provider)) {\n finalChunk = modifyDeltaProperties(provider, finalChunk);\n }\n\n if ((finalChunk?.tool_calls?.length ?? 0) > 0) {\n finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n\n return { messages: [finalChunk as AIMessageChunk] };\n }\n\n const finalMessage = await model.invoke(messagesForProvider, config);\n if ((finalMessage.tool_calls?.length ?? 0) > 0) {\n finalMessage.tool_calls = finalMessage.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n return { messages: [finalMessage] };\n}\n\n/**\n * Best-effort read of the configured model name from client options.\n * Providers disagree on the key (`model` vs `modelName`).\n */\nfunction extractClientOptionsModel(\n clientOptions: t.ClientOptions | undefined\n): string | undefined {\n const options = clientOptions as\n | { model?: unknown; modelName?: unknown }\n | undefined;\n if (typeof options?.model === 'string' && options.model !== '') {\n return options.model;\n }\n if (typeof options?.modelName === 'string' && options.modelName !== '') {\n return options.modelName;\n }\n return undefined;\n}\n\n/**\n * Attempts each fallback provider in order until one succeeds.\n * Throws the last error if all fallbacks fail.\n */\nexport async function tryFallbackProviders({\n fallbacks,\n tools,\n messages,\n config,\n primaryError,\n context,\n onChunk,\n}: {\n fallbacks: Array<{ provider: Providers; clientOptions?: t.ClientOptions }>;\n tools?: t.GraphTools;\n messages: BaseMessage[];\n config?: RunnableConfig;\n primaryError: unknown;\n context?: InvokeContext;\n onChunk?: OnChunk;\n}): Promise<Partial<t.BaseGraphState> | undefined> {\n let lastError: unknown = primaryError;\n for (const fb of fallbacks) {\n try {\n const fbModel = initializeModel({\n provider: fb.provider,\n clientOptions: fb.clientOptions,\n tools,\n });\n /**\n * Stamp the fallback's configured model onto callback metadata so\n * per-call attribution (subagent usage capture) doesn't fall back to\n * the PRIMARY config's model when the provider reports no\n * `ls_model_name`. The serving provider is stamped uniformly by\n * `attemptInvoke` (`INVOKED_PROVIDER`).\n */\n const fbModelName = extractClientOptionsModel(fb.clientOptions);\n const fbConfig: RunnableConfig | undefined =\n fbModelName == null\n ? config\n : {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_MODEL]: fbModelName,\n },\n };\n const result = await attemptInvoke(\n {\n model: fbModel as t.ChatModel,\n messages,\n provider: fb.provider,\n context,\n onChunk,\n },\n fbConfig\n );\n return result;\n } catch (e) {\n lastError = e;\n continue;\n }\n }\n if (lastError !== undefined) {\n throw lastError;\n }\n return undefined;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"invoke.mjs","names":[],"sources":["../../../src/llm/invoke.ts"],"sourcesContent":["import { concat } from '@langchain/core/utils/stream';\nimport { AIMessageChunk } from '@langchain/core/messages';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type { ToolCall } from '@langchain/core/messages/tool';\nimport type { BaseMessage } from '@langchain/core/messages';\nimport type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';\nimport type * as t from '@/types';\nimport { annotateMessagesForLLM } from '@/tools/toolOutputReferences';\nimport { assertNotTruncatedToolCall } from '@/llm/truncation';\nimport { Constants, GraphEvents, Providers } from '@/common';\nimport { manualToolStreamProviders } from '@/llm/providers';\nimport { modifyDeltaProperties } from '@/messages';\nimport { ChatModelStreamHandler } from '@/stream';\nimport { initializeModel } from '@/llm/init';\n\n/**\n * Context passed to `attemptInvoke`. Matches the subset of Graph that\n * `ChatModelStreamHandler.handle` needs *plus* the explicit\n * `getOrCreateToolOutputRegistry()` accessor that `attemptInvoke`\n * itself calls to pull the run-scoped tool-output registry off the\n * graph and project each relevant ToolMessage into a transient\n * annotated copy before the provider call.\n *\n * The intersection is intentional: `Parameters<...>[3]` resolves\n * indirectly through the stream handler's signature (which returns\n * `StandardGraph` and already exposes the accessor since #117), but\n * stating it explicitly here surfaces the contract at the call site —\n * a developer reading `attemptInvoke` doesn't have to chase the\n * upstream handler's parameter list to discover that\n * `context?.getOrCreateToolOutputRegistry()` is a real thing. Single\n * optional chain only — the method itself is required on the\n * `StandardGraph` branch of the intersection, so the second `?.` is\n * unnecessary at the call site.\n *\n * `NonNullable<...>` strips `undefined` from the upstream parameter\n * type so the intersection doesn't collapse to `never` on the\n * undefined branch; callers express optionality via `context?:\n * InvokeContext` on the function signature instead.\n *\n * Callers without a registry (e.g. summarization) simply pass no\n * `context` and the transform safely no-ops.\n */\nexport type InvokeContext = NonNullable<\n Parameters<ChatModelStreamHandler['handle']>[3]\n> & {\n getOrCreateToolOutputRegistry?(): ToolOutputReferenceRegistry | undefined;\n};\n\n/**\n * Per-chunk callback for custom stream processing.\n * When provided, replaces the default `ChatModelStreamHandler`.\n */\nexport type OnChunk = (chunk: AIMessageChunk) => void | Promise<void>;\n\nfunction getRegisteredDefaultChatStreamHandler(\n context?: InvokeContext\n): ChatModelStreamHandler | undefined {\n const handler = context?.handlerRegistry?.getHandler(\n GraphEvents.CHAT_MODEL_STREAM\n );\n return handler instanceof ChatModelStreamHandler ? handler : undefined;\n}\n\nfunction hasReasoningDetails(chunk: AIMessageChunk): boolean {\n const reasoningDetails = chunk.additional_kwargs.reasoning_details;\n return Array.isArray(reasoningDetails) && reasoningDetails.length > 0;\n}\n\nfunction removeOpenRouterFinalReasoningReplayContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null || content === next.content) {\n return next;\n }\n\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n })\n );\n}\n\nfunction getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): string | undefined {\n if (\n provider !== Providers.OPENROUTER ||\n current == null ||\n !hasReasoningDetails(next) ||\n typeof current.content !== 'string' ||\n current.content === '' ||\n typeof next.content !== 'string' ||\n next.content === ''\n ) {\n return undefined;\n }\n if (!next.content.startsWith(current.content)) {\n return next.content;\n }\n return next.content.slice(current.content.length);\n}\n\nfunction removeReasoningDetails(\n additionalKwargs: AIMessageChunk['additional_kwargs']\n): AIMessageChunk['additional_kwargs'] {\n return Object.fromEntries(\n Object.entries(additionalKwargs).filter(\n ([key]) => key !== 'reasoning_details'\n )\n );\n}\n\nfunction getStreamHandlingChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk | undefined {\n const content = getOpenRouterFinalReasoningContent({\n current,\n next,\n provider,\n });\n if (content == null) {\n return next;\n }\n if (content === '') {\n return undefined;\n }\n return new AIMessageChunk(\n Object.assign({}, next, {\n content,\n additional_kwargs: removeReasoningDetails(next.additional_kwargs),\n })\n );\n}\n\nfunction appendStreamChunk({\n current,\n next,\n provider,\n}: {\n current?: AIMessageChunk;\n next: AIMessageChunk;\n provider: Providers;\n}): AIMessageChunk {\n if (current == null) {\n return next;\n }\n return concat(\n current,\n removeOpenRouterFinalReasoningReplayContent({ current, next, provider })\n );\n}\n\n/**\n * Invokes a chat model with the given messages, handling both streaming and\n * non-streaming paths.\n *\n * By default, stream chunks are processed through a `ChatModelStreamHandler`\n * that dispatches run steps (MESSAGE_CREATION, TOOL_CALLS) for the graph.\n * Pass an `onChunk` callback to override this with custom chunk processing\n * (e.g. summarization delta events).\n */\nexport async function attemptInvoke(\n {\n model,\n messages,\n provider,\n context,\n onChunk,\n }: {\n model: t.ChatModel;\n messages: BaseMessage[];\n provider: Providers;\n context?: InvokeContext;\n onChunk?: OnChunk;\n },\n config?: RunnableConfig\n): Promise<Partial<t.BaseGraphState>> {\n /**\n * Pull the run-scoped tool output registry off the graph (when one\n * exists) and project ToolMessages carrying ref metadata into a\n * transient annotated copy. The original `messages` array stays\n * untouched so the graph state never sees `[ref: …]` / `_ref`\n * payload.\n */\n const registry = context?.getOrCreateToolOutputRegistry();\n const runId = config?.configurable?.run_id as string | undefined;\n const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);\n\n /**\n * Stamp the provider that is ACTUALLY serving this invocation onto the\n * callback metadata. `attemptInvoke` is the single funnel for primary,\n * fallback, and summarization model calls, so consumers that need\n * provider attribution per call (the subagent usage-capture handler)\n * read this key instead of trusting static agent config — which is\n * wrong for fallback-served calls — or `ls_provider` — which derived\n * providers inherit from their base class.\n */\n config = {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_PROVIDER]: provider,\n },\n };\n\n if (model.stream) {\n const stream = await model.stream(messagesForProvider, config);\n let finalChunk: AIMessageChunk | undefined;\n const registeredStreamHandler =\n getRegisteredDefaultChatStreamHandler(context);\n\n if (onChunk) {\n for await (const chunk of stream) {\n await onChunk(chunk);\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else if (registeredStreamHandler == null) {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n const streamHandler = new ChatModelStreamHandler();\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null) {\n await streamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n } else {\n const metadata = config.metadata as Record<string, unknown> | undefined;\n for await (const chunk of stream) {\n const handlingChunk = getStreamHandlingChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n if (handlingChunk != null && handlingChunk !== chunk) {\n await registeredStreamHandler.handle(\n GraphEvents.CHAT_MODEL_STREAM,\n { chunk: handlingChunk },\n metadata,\n context\n );\n }\n finalChunk = appendStreamChunk({\n current: finalChunk,\n next: chunk,\n provider,\n });\n }\n }\n\n if (manualToolStreamProviders.has(provider)) {\n finalChunk = modifyDeltaProperties(provider, finalChunk);\n }\n\n if ((finalChunk?.tool_calls?.length ?? 0) > 0) {\n finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n\n assertNotTruncatedToolCall(finalChunk, provider);\n return { messages: [finalChunk as AIMessageChunk] };\n }\n\n const finalMessage = await model.invoke(messagesForProvider, config);\n if ((finalMessage.tool_calls?.length ?? 0) > 0) {\n finalMessage.tool_calls = finalMessage.tool_calls?.filter(\n (tool_call: ToolCall) => !!tool_call.name\n );\n }\n assertNotTruncatedToolCall(finalMessage, provider);\n return { messages: [finalMessage] };\n}\n\n/**\n * Best-effort read of the configured model name from client options.\n * Providers disagree on the key (`model` vs `modelName`).\n */\nfunction extractClientOptionsModel(\n clientOptions: t.ClientOptions | undefined\n): string | undefined {\n const options = clientOptions as\n | { model?: unknown; modelName?: unknown }\n | undefined;\n if (typeof options?.model === 'string' && options.model !== '') {\n return options.model;\n }\n if (typeof options?.modelName === 'string' && options.modelName !== '') {\n return options.modelName;\n }\n return undefined;\n}\n\n/**\n * Attempts each fallback provider in order until one succeeds.\n * Throws the last error if all fallbacks fail.\n */\nexport async function tryFallbackProviders({\n fallbacks,\n tools,\n messages,\n config,\n primaryError,\n context,\n onChunk,\n}: {\n fallbacks: Array<{ provider: Providers; clientOptions?: t.ClientOptions }>;\n tools?: t.GraphTools;\n messages: BaseMessage[];\n config?: RunnableConfig;\n primaryError: unknown;\n context?: InvokeContext;\n onChunk?: OnChunk;\n}): Promise<Partial<t.BaseGraphState> | undefined> {\n let lastError: unknown = primaryError;\n for (const fb of fallbacks) {\n try {\n const fbModel = initializeModel({\n provider: fb.provider,\n clientOptions: fb.clientOptions,\n tools,\n });\n /**\n * Stamp the fallback's configured model onto callback metadata so\n * per-call attribution (subagent usage capture) doesn't fall back to\n * the PRIMARY config's model when the provider reports no\n * `ls_model_name`. The serving provider is stamped uniformly by\n * `attemptInvoke` (`INVOKED_PROVIDER`).\n */\n const fbModelName = extractClientOptionsModel(fb.clientOptions);\n const fbConfig: RunnableConfig | undefined =\n fbModelName == null\n ? config\n : {\n ...config,\n metadata: {\n ...(config?.metadata ?? {}),\n [Constants.INVOKED_MODEL]: fbModelName,\n },\n };\n const result = await attemptInvoke(\n {\n model: fbModel as t.ChatModel,\n messages,\n provider: fb.provider,\n context,\n onChunk,\n },\n fbConfig\n );\n return result;\n } catch (e) {\n lastError = e;\n continue;\n }\n }\n if (lastError !== undefined) {\n throw lastError;\n }\n return undefined;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import "../common/enum.mjs";
|
|
2
|
+
import "../common/index.mjs";
|
|
3
|
+
//#region src/llm/truncation.ts
|
|
4
|
+
/**
|
|
5
|
+
* Providers whose adapters deliver tool calls as complete, atomic objects
|
|
6
|
+
* rather than streamed argument deltas. Google/Vertex (GenAI) emit each
|
|
7
|
+
* `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does
|
|
8
|
+
* NOT imply the arguments were cut off — the truncation guard must skip them.
|
|
9
|
+
*/
|
|
10
|
+
const ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set(["google", "vertexai"]);
|
|
11
|
+
const MAX_TOKEN_VALUES = new Set([
|
|
12
|
+
"max_tokens",
|
|
13
|
+
"max_token",
|
|
14
|
+
"maxtokens",
|
|
15
|
+
"max_output_tokens"
|
|
16
|
+
]);
|
|
17
|
+
const LENGTH_VALUES = new Set(["length"]);
|
|
18
|
+
function normalizeStopValue(value) {
|
|
19
|
+
if (typeof value !== "string") return null;
|
|
20
|
+
const normalized = value.trim().toLowerCase();
|
|
21
|
+
if (MAX_TOKEN_VALUES.has(normalized)) return "max_tokens";
|
|
22
|
+
if (LENGTH_VALUES.has(normalized)) return "length";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
27
|
+
* shape we stream:
|
|
28
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
29
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
30
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
31
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
32
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
33
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
34
|
+
* - `response_metadata.finishReason` (Google)
|
|
35
|
+
*
|
|
36
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
37
|
+
* output token ceiling, otherwise `null`.
|
|
38
|
+
*/
|
|
39
|
+
function getTruncationStopReason(message) {
|
|
40
|
+
const meta = message?.response_metadata;
|
|
41
|
+
const additionalKwargs = message?.additional_kwargs;
|
|
42
|
+
if (meta == null && additionalKwargs == null) return null;
|
|
43
|
+
const messageStop = meta?.messageStop;
|
|
44
|
+
const incompleteDetails = meta?.incomplete_details;
|
|
45
|
+
const candidates = [
|
|
46
|
+
meta?.stopReason,
|
|
47
|
+
meta?.stop_reason,
|
|
48
|
+
meta?.finish_reason,
|
|
49
|
+
meta?.finishReason,
|
|
50
|
+
messageStop?.stopReason,
|
|
51
|
+
incompleteDetails?.reason,
|
|
52
|
+
additionalKwargs?.stop_reason
|
|
53
|
+
];
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const normalized = normalizeStopValue(candidate);
|
|
56
|
+
if (normalized != null) return normalized;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function hasOpenToolCall(message) {
|
|
61
|
+
const m = message;
|
|
62
|
+
return (m.tool_calls?.length ?? 0) > 0 || (m.tool_call_chunks?.length ?? 0) > 0 || (m.invalid_tool_calls?.length ?? 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
66
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
67
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
68
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
69
|
+
*/
|
|
70
|
+
var OutputTruncationError = class extends Error {
|
|
71
|
+
stopReason;
|
|
72
|
+
toolCallNames;
|
|
73
|
+
constructor(stopReason, toolCallNames) {
|
|
74
|
+
const named = toolCallNames.length > 0 ? ` (tool call: ${toolCallNames.join(", ")})` : "";
|
|
75
|
+
super(`The model response was truncated at the maximum output token limit before the tool call arguments were complete${named}. Increase the model's max output tokens, or have the model produce smaller arguments — for large files, write a lean main file and move bulky content into separate files.`);
|
|
76
|
+
this.name = "OutputTruncationError";
|
|
77
|
+
this.stopReason = stopReason;
|
|
78
|
+
this.toolCallNames = toolCallNames;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
function collectToolCallNames(message) {
|
|
82
|
+
const m = message;
|
|
83
|
+
const names = [
|
|
84
|
+
...m.tool_calls ?? [],
|
|
85
|
+
...m.tool_call_chunks ?? [],
|
|
86
|
+
...m.invalid_tool_calls ?? []
|
|
87
|
+
].map((tc) => tc.name).filter((name) => typeof name === "string" && name !== "");
|
|
88
|
+
return [...new Set(names)];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
92
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
93
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
94
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
95
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
96
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
97
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
98
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
99
|
+
*/
|
|
100
|
+
function assertNotTruncatedToolCall(message, provider) {
|
|
101
|
+
if (message == null) return;
|
|
102
|
+
if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) return;
|
|
103
|
+
const stopReason = getTruncationStopReason(message);
|
|
104
|
+
if (stopReason == null || !hasOpenToolCall(message)) return;
|
|
105
|
+
throw new OutputTruncationError(stopReason, collectToolCallNames(message));
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { assertNotTruncatedToolCall };
|
|
109
|
+
|
|
110
|
+
//# sourceMappingURL=truncation.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"truncation.mjs","names":[],"sources":["../../../src/llm/truncation.ts"],"sourcesContent":["import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';\nimport { Providers } from '@/common';\n\n/**\n * Providers whose adapters deliver tool calls as complete, atomic objects\n * rather than streamed argument deltas. Google/Vertex (GenAI) emit each\n * `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does\n * NOT imply the arguments were cut off — the truncation guard must skip them.\n */\nconst ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set<Providers>([\n Providers.GOOGLE,\n Providers.VERTEXAI,\n]);\n\n/**\n * Normalized truncation reasons across providers. Providers disagree on the\n * key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,\n * Google uses `MAX_TOKENS`.\n */\nexport type TruncationStopReason = 'max_tokens' | 'length';\n\nconst MAX_TOKEN_VALUES = new Set([\n 'max_tokens',\n 'max_token',\n 'maxtokens',\n 'max_output_tokens',\n]);\nconst LENGTH_VALUES = new Set(['length']);\n\nfunction normalizeStopValue(value: unknown): TruncationStopReason | null {\n if (typeof value !== 'string') {\n return null;\n }\n const normalized = value.trim().toLowerCase();\n if (MAX_TOKEN_VALUES.has(normalized)) {\n return 'max_tokens';\n }\n if (LENGTH_VALUES.has(normalized)) {\n return 'length';\n }\n return null;\n}\n\n/**\n * Reads the truncation stop reason off a message, covering every provider\n * shape we stream:\n * - `response_metadata.stopReason` (Bedrock Converse, non-streaming)\n * - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)\n * - `response_metadata.stop_reason` (Anthropic, non-streaming)\n * - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)\n * - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)\n * - `response_metadata.incomplete_details.reason` (OpenAI Responses API)\n * - `response_metadata.finishReason` (Google)\n *\n * Returns the normalized reason when the model stopped because it hit the\n * output token ceiling, otherwise `null`.\n */\nexport function getTruncationStopReason(\n message:\n | Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'>\n | undefined\n | null\n): TruncationStopReason | null {\n const meta = message?.response_metadata as\n | Record<string, unknown>\n | undefined;\n const additionalKwargs = message?.additional_kwargs as\n | Record<string, unknown>\n | undefined;\n if (meta == null && additionalKwargs == null) {\n return null;\n }\n\n const messageStop = meta?.messageStop as { stopReason?: unknown } | undefined;\n const incompleteDetails = meta?.incomplete_details as\n | { reason?: unknown }\n | undefined;\n const candidates: unknown[] = [\n meta?.stopReason,\n meta?.stop_reason,\n meta?.finish_reason,\n meta?.finishReason,\n messageStop?.stopReason,\n incompleteDetails?.reason,\n additionalKwargs?.stop_reason,\n ];\n\n for (const candidate of candidates) {\n const normalized = normalizeStopValue(candidate);\n if (normalized != null) {\n return normalized;\n }\n }\n return null;\n}\n\nfunction hasOpenToolCall(message: AIMessageChunk | BaseMessage): boolean {\n const m = message as AIMessageChunk & {\n tool_calls?: unknown[];\n tool_call_chunks?: unknown[];\n invalid_tool_calls?: unknown[];\n };\n return (\n (m.tool_calls?.length ?? 0) > 0 ||\n (m.tool_call_chunks?.length ?? 0) > 0 ||\n (m.invalid_tool_calls?.length ?? 0) > 0\n );\n}\n\n/**\n * Error raised when a model turn is cut off by the output token limit while it\n * was still emitting a tool call. The arguments are necessarily incomplete, so\n * executing or re-prompting the call would loop on a malformed request. Failing\n * fast with this surfaces an actionable message to the caller instead.\n */\nexport class OutputTruncationError extends Error {\n readonly stopReason: TruncationStopReason;\n readonly toolCallNames: string[];\n\n constructor(stopReason: TruncationStopReason, toolCallNames: string[]) {\n const named =\n toolCallNames.length > 0\n ? ` (tool call: ${toolCallNames.join(', ')})`\n : '';\n super(\n 'The model response was truncated at the maximum output token limit before ' +\n `the tool call arguments were complete${named}. Increase the model's max ` +\n 'output tokens, or have the model produce smaller arguments — for large ' +\n 'files, write a lean main file and move bulky content into separate files.'\n );\n this.name = 'OutputTruncationError';\n this.stopReason = stopReason;\n this.toolCallNames = toolCallNames;\n }\n}\n\nfunction collectToolCallNames(message: AIMessageChunk | BaseMessage): string[] {\n const m = message as AIMessageChunk & {\n tool_calls?: Array<{ name?: string }>;\n tool_call_chunks?: Array<{ name?: string }>;\n invalid_tool_calls?: Array<{ name?: string }>;\n };\n const names = [\n ...(m.tool_calls ?? []),\n ...(m.tool_call_chunks ?? []),\n ...(m.invalid_tool_calls ?? []),\n ]\n .map((tc) => tc.name)\n .filter((name): name is string => typeof name === 'string' && name !== '');\n return [...new Set(names)];\n}\n\n/**\n * Throws {@link OutputTruncationError} when `message` was truncated by the\n * output token limit AND still carries a tool call. For providers that stream\n * tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call\n * emitted under truncation has incomplete arguments — the tool-use block is the\n * last thing the model streams — so letting it through makes the agent loop on a\n * malformed call. No-ops for normal completions, truncated plain-text turns, and\n * providers that deliver complete tool calls atomically (Google/Vertex), where\n * `MAX_TOKENS` does not imply the arguments were cut off.\n */\nexport function assertNotTruncatedToolCall(\n message: AIMessageChunk | BaseMessage | undefined | null,\n provider?: Providers\n): void {\n if (message == null) {\n return;\n }\n if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) {\n return;\n }\n const stopReason = getTruncationStopReason(message);\n if (stopReason == null || !hasOpenToolCall(message)) {\n return;\n }\n throw new OutputTruncationError(stopReason, collectToolCallNames(message));\n}\n"],"mappings":";;;;;;;;;AASA,MAAM,iCAAiC,IAAI,IAAe,CAAA,UAAA,UAG1D,CAAC;AASD,MAAM,mBAAmB,IAAI,IAAI;CAC/B;CACA;CACA;CACA;AACF,CAAC;AACD,MAAM,gBAAgB,IAAI,IAAI,CAAC,QAAQ,CAAC;AAExC,SAAS,mBAAmB,OAA6C;CACvE,IAAI,OAAO,UAAU,UACnB,OAAO;CAET,MAAM,aAAa,MAAM,KAAK,CAAC,CAAC,YAAY;CAC5C,IAAI,iBAAiB,IAAI,UAAU,GACjC,OAAO;CAET,IAAI,cAAc,IAAI,UAAU,GAC9B,OAAO;CAET,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,wBACd,SAI6B;CAC7B,MAAM,OAAO,SAAS;CAGtB,MAAM,mBAAmB,SAAS;CAGlC,IAAI,QAAQ,QAAQ,oBAAoB,MACtC,OAAO;CAGT,MAAM,cAAc,MAAM;CAC1B,MAAM,oBAAoB,MAAM;CAGhC,MAAM,aAAwB;EAC5B,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,aAAa;EACb,mBAAmB;EACnB,kBAAkB;CACpB;CAEA,KAAK,MAAM,aAAa,YAAY;EAClC,MAAM,aAAa,mBAAmB,SAAS;EAC/C,IAAI,cAAc,MAChB,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAS,gBAAgB,SAAgD;CACvE,MAAM,IAAI;CAKV,QACG,EAAE,YAAY,UAAU,KAAK,MAC7B,EAAE,kBAAkB,UAAU,KAAK,MACnC,EAAE,oBAAoB,UAAU,KAAK;AAE1C;;;;;;;AAQA,IAAa,wBAAb,cAA2C,MAAM;CAC/C;CACA;CAEA,YAAY,YAAkC,eAAyB;EACrE,MAAM,QACJ,cAAc,SAAS,IACnB,gBAAgB,cAAc,KAAK,IAAI,EAAE,KACzC;EACN,MACE,kHAC0C,MAAM,4KAGlD;EACA,KAAK,OAAO;EACZ,KAAK,aAAa;EAClB,KAAK,gBAAgB;CACvB;AACF;AAEA,SAAS,qBAAqB,SAAiD;CAC7E,MAAM,IAAI;CAKV,MAAM,QAAQ;EACZ,GAAI,EAAE,cAAc,CAAC;EACrB,GAAI,EAAE,oBAAoB,CAAC;EAC3B,GAAI,EAAE,sBAAsB,CAAC;CAC/B,CAAC,CACE,KAAK,OAAO,GAAG,IAAI,CAAC,CACpB,QAAQ,SAAyB,OAAO,SAAS,YAAY,SAAS,EAAE;CAC3E,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC;AAC3B;;;;;;;;;;;AAYA,SAAgB,2BACd,SACA,UACM;CACN,IAAI,WAAW,MACb;CAEF,IAAI,YAAY,QAAQ,+BAA+B,IAAI,QAAQ,GACjE;CAEF,MAAM,aAAa,wBAAwB,OAAO;CAClD,IAAI,cAAc,QAAQ,CAAC,gBAAgB,OAAO,GAChD;CAEF,MAAM,IAAI,sBAAsB,YAAY,qBAAqB,OAAO,CAAC;AAC3E"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
/**
|
|
4
|
+
* Normalized truncation reasons across providers. Providers disagree on the
|
|
5
|
+
* key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,
|
|
6
|
+
* Google uses `MAX_TOKENS`.
|
|
7
|
+
*/
|
|
8
|
+
export type TruncationStopReason = 'max_tokens' | 'length';
|
|
9
|
+
/**
|
|
10
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
11
|
+
* shape we stream:
|
|
12
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
13
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
14
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
15
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
16
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
17
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
18
|
+
* - `response_metadata.finishReason` (Google)
|
|
19
|
+
*
|
|
20
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
21
|
+
* output token ceiling, otherwise `null`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getTruncationStopReason(message: Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'> | undefined | null): TruncationStopReason | null;
|
|
24
|
+
/**
|
|
25
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
26
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
27
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
28
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
29
|
+
*/
|
|
30
|
+
export declare class OutputTruncationError extends Error {
|
|
31
|
+
readonly stopReason: TruncationStopReason;
|
|
32
|
+
readonly toolCallNames: string[];
|
|
33
|
+
constructor(stopReason: TruncationStopReason, toolCallNames: string[]);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
37
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
38
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
39
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
40
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
41
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
42
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
43
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertNotTruncatedToolCall(message: AIMessageChunk | BaseMessage | undefined | null, provider?: Providers): void;
|
package/package.json
CHANGED
package/src/llm/invoke.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { BaseMessage } from '@langchain/core/messages';
|
|
|
6
6
|
import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
7
7
|
import type * as t from '@/types';
|
|
8
8
|
import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
|
|
9
|
+
import { assertNotTruncatedToolCall } from '@/llm/truncation';
|
|
9
10
|
import { Constants, GraphEvents, Providers } from '@/common';
|
|
10
11
|
import { manualToolStreamProviders } from '@/llm/providers';
|
|
11
12
|
import { modifyDeltaProperties } from '@/messages';
|
|
@@ -297,6 +298,7 @@ export async function attemptInvoke(
|
|
|
297
298
|
);
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
assertNotTruncatedToolCall(finalChunk, provider);
|
|
300
302
|
return { messages: [finalChunk as AIMessageChunk] };
|
|
301
303
|
}
|
|
302
304
|
|
|
@@ -306,6 +308,7 @@ export async function attemptInvoke(
|
|
|
306
308
|
(tool_call: ToolCall) => !!tool_call.name
|
|
307
309
|
);
|
|
308
310
|
}
|
|
311
|
+
assertNotTruncatedToolCall(finalMessage, provider);
|
|
309
312
|
return { messages: [finalMessage] };
|
|
310
313
|
}
|
|
311
314
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
OutputTruncationError,
|
|
5
|
+
assertNotTruncatedToolCall,
|
|
6
|
+
getTruncationStopReason,
|
|
7
|
+
} from '@/llm/truncation';
|
|
8
|
+
import { Providers } from '@/common';
|
|
9
|
+
|
|
10
|
+
describe('getTruncationStopReason', () => {
|
|
11
|
+
it('returns null when there is no response metadata', () => {
|
|
12
|
+
expect(getTruncationStopReason(new AIMessage('hi'))).toBeNull();
|
|
13
|
+
expect(getTruncationStopReason(undefined)).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('detects Bedrock Converse streaming shape (messageStop.stopReason)', () => {
|
|
17
|
+
const message = new AIMessageChunk({
|
|
18
|
+
content: '',
|
|
19
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
20
|
+
});
|
|
21
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects Bedrock Converse non-streaming shape (stopReason)', () => {
|
|
25
|
+
const message = new AIMessage({
|
|
26
|
+
content: '',
|
|
27
|
+
response_metadata: { stopReason: 'max_tokens' },
|
|
28
|
+
});
|
|
29
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('detects Anthropic shape (stop_reason)', () => {
|
|
33
|
+
const message = new AIMessage({
|
|
34
|
+
content: '',
|
|
35
|
+
response_metadata: { stop_reason: 'max_tokens' },
|
|
36
|
+
});
|
|
37
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('detects OpenAI shape (finish_reason: length)', () => {
|
|
41
|
+
const message = new AIMessage({
|
|
42
|
+
content: '',
|
|
43
|
+
response_metadata: { finish_reason: 'length' },
|
|
44
|
+
});
|
|
45
|
+
expect(getTruncationStopReason(message)).toBe('length');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('detects Google shape (finishReason: MAX_TOKENS, case-insensitive)', () => {
|
|
49
|
+
const message = new AIMessage({
|
|
50
|
+
content: '',
|
|
51
|
+
response_metadata: { finishReason: 'MAX_TOKENS' },
|
|
52
|
+
});
|
|
53
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('detects Anthropic streaming shape (additional_kwargs.stop_reason)', () => {
|
|
57
|
+
const message = new AIMessageChunk({
|
|
58
|
+
content: '',
|
|
59
|
+
additional_kwargs: { stop_reason: 'max_tokens' },
|
|
60
|
+
response_metadata: { model_provider: 'anthropic' },
|
|
61
|
+
});
|
|
62
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects OpenAI Responses shape (incomplete_details.reason)', () => {
|
|
66
|
+
const message = new AIMessage({
|
|
67
|
+
content: '',
|
|
68
|
+
response_metadata: {
|
|
69
|
+
status: 'incomplete',
|
|
70
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null for a non-token incomplete reason (content_filter)', () => {
|
|
77
|
+
const message = new AIMessage({
|
|
78
|
+
content: '',
|
|
79
|
+
response_metadata: {
|
|
80
|
+
status: 'incomplete',
|
|
81
|
+
incomplete_details: { reason: 'content_filter' },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
expect(getTruncationStopReason(message)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null for normal stop reasons', () => {
|
|
88
|
+
expect(
|
|
89
|
+
getTruncationStopReason(
|
|
90
|
+
new AIMessage({
|
|
91
|
+
content: 'x',
|
|
92
|
+
response_metadata: { stopReason: 'end_turn' },
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
).toBeNull();
|
|
96
|
+
expect(
|
|
97
|
+
getTruncationStopReason(
|
|
98
|
+
new AIMessage({
|
|
99
|
+
content: 'x',
|
|
100
|
+
response_metadata: { finish_reason: 'stop' },
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
).toBeNull();
|
|
104
|
+
expect(
|
|
105
|
+
getTruncationStopReason(
|
|
106
|
+
new AIMessage({
|
|
107
|
+
content: 'x',
|
|
108
|
+
response_metadata: { stopReason: 'tool_use' },
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('assertNotTruncatedToolCall', () => {
|
|
116
|
+
it('no-ops for a normal completion', () => {
|
|
117
|
+
expect(() =>
|
|
118
|
+
assertNotTruncatedToolCall(
|
|
119
|
+
new AIMessage({
|
|
120
|
+
content: 'done',
|
|
121
|
+
response_metadata: { stopReason: 'end_turn' },
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('no-ops for a truncated plain-text turn (no tool calls)', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
assertNotTruncatedToolCall(
|
|
130
|
+
new AIMessage({
|
|
131
|
+
content: 'partial...',
|
|
132
|
+
response_metadata: { stopReason: 'max_tokens' },
|
|
133
|
+
})
|
|
134
|
+
)
|
|
135
|
+
).not.toThrow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('no-ops for a complete tool call (normal stop reason)', () => {
|
|
139
|
+
const message = new AIMessage({
|
|
140
|
+
content: '',
|
|
141
|
+
tool_calls: [
|
|
142
|
+
{ id: '1', name: 'create_file', args: { path: 'a', content: 'b' } },
|
|
143
|
+
],
|
|
144
|
+
response_metadata: { stopReason: 'tool_use' },
|
|
145
|
+
});
|
|
146
|
+
expect(() => assertNotTruncatedToolCall(message)).not.toThrow();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws when a tool call is present and the turn was truncated', () => {
|
|
150
|
+
const message = new AIMessage({
|
|
151
|
+
content: '',
|
|
152
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
153
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
154
|
+
});
|
|
155
|
+
expect(() => assertNotTruncatedToolCall(message)).toThrow(
|
|
156
|
+
OutputTruncationError
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
assertNotTruncatedToolCall(message);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
expect(err).toBeInstanceOf(OutputTruncationError);
|
|
162
|
+
expect((err as OutputTruncationError).stopReason).toBe('max_tokens');
|
|
163
|
+
expect((err as OutputTruncationError).toolCallNames).toContain(
|
|
164
|
+
'create_file'
|
|
165
|
+
);
|
|
166
|
+
expect((err as OutputTruncationError).message).toMatch(
|
|
167
|
+
/max output tokens/i
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('throws when only incomplete tool_call_chunks survived truncation', () => {
|
|
173
|
+
const message = new AIMessageChunk({
|
|
174
|
+
content: '',
|
|
175
|
+
tool_call_chunks: [
|
|
176
|
+
{
|
|
177
|
+
name: 'create_file',
|
|
178
|
+
args: '{"path":"a"',
|
|
179
|
+
index: 0,
|
|
180
|
+
type: 'tool_call_chunk',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
184
|
+
});
|
|
185
|
+
expect(() => assertNotTruncatedToolCall(message)).toThrow(
|
|
186
|
+
OutputTruncationError
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('still throws for a streaming-arg provider (Anthropic)', () => {
|
|
191
|
+
const message = new AIMessage({
|
|
192
|
+
content: '',
|
|
193
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
194
|
+
response_metadata: { stop_reason: 'max_tokens' },
|
|
195
|
+
});
|
|
196
|
+
expect(() =>
|
|
197
|
+
assertNotTruncatedToolCall(message, Providers.ANTHROPIC)
|
|
198
|
+
).toThrow(OutputTruncationError);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('throws for an Anthropic streaming tool call truncated via additional_kwargs', () => {
|
|
202
|
+
const message = new AIMessageChunk({
|
|
203
|
+
content: '',
|
|
204
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
205
|
+
additional_kwargs: { stop_reason: 'max_tokens' },
|
|
206
|
+
response_metadata: { model_provider: 'anthropic' },
|
|
207
|
+
});
|
|
208
|
+
expect(() =>
|
|
209
|
+
assertNotTruncatedToolCall(message, Providers.ANTHROPIC)
|
|
210
|
+
).toThrow(OutputTruncationError);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('throws for an OpenAI Responses tool call truncated via incomplete_details', () => {
|
|
214
|
+
const message = new AIMessage({
|
|
215
|
+
content: '',
|
|
216
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
217
|
+
response_metadata: {
|
|
218
|
+
status: 'incomplete',
|
|
219
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
expect(() => assertNotTruncatedToolCall(message, Providers.OPENAI)).toThrow(
|
|
223
|
+
OutputTruncationError
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not throw for providers that deliver complete tool calls (Google/Vertex)', () => {
|
|
228
|
+
const message = new AIMessage({
|
|
229
|
+
content: '',
|
|
230
|
+
tool_calls: [
|
|
231
|
+
{ id: '1', name: 'create_file', args: { path: 'a', content: 'b' } },
|
|
232
|
+
],
|
|
233
|
+
response_metadata: { finishReason: 'MAX_TOKENS' },
|
|
234
|
+
});
|
|
235
|
+
expect(() =>
|
|
236
|
+
assertNotTruncatedToolCall(message, Providers.GOOGLE)
|
|
237
|
+
).not.toThrow();
|
|
238
|
+
expect(() =>
|
|
239
|
+
assertNotTruncatedToolCall(message, Providers.VERTEXAI)
|
|
240
|
+
).not.toThrow();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Providers whose adapters deliver tool calls as complete, atomic objects
|
|
6
|
+
* rather than streamed argument deltas. Google/Vertex (GenAI) emit each
|
|
7
|
+
* `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does
|
|
8
|
+
* NOT imply the arguments were cut off — the truncation guard must skip them.
|
|
9
|
+
*/
|
|
10
|
+
const ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set<Providers>([
|
|
11
|
+
Providers.GOOGLE,
|
|
12
|
+
Providers.VERTEXAI,
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalized truncation reasons across providers. Providers disagree on the
|
|
17
|
+
* key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,
|
|
18
|
+
* Google uses `MAX_TOKENS`.
|
|
19
|
+
*/
|
|
20
|
+
export type TruncationStopReason = 'max_tokens' | 'length';
|
|
21
|
+
|
|
22
|
+
const MAX_TOKEN_VALUES = new Set([
|
|
23
|
+
'max_tokens',
|
|
24
|
+
'max_token',
|
|
25
|
+
'maxtokens',
|
|
26
|
+
'max_output_tokens',
|
|
27
|
+
]);
|
|
28
|
+
const LENGTH_VALUES = new Set(['length']);
|
|
29
|
+
|
|
30
|
+
function normalizeStopValue(value: unknown): TruncationStopReason | null {
|
|
31
|
+
if (typeof value !== 'string') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const normalized = value.trim().toLowerCase();
|
|
35
|
+
if (MAX_TOKEN_VALUES.has(normalized)) {
|
|
36
|
+
return 'max_tokens';
|
|
37
|
+
}
|
|
38
|
+
if (LENGTH_VALUES.has(normalized)) {
|
|
39
|
+
return 'length';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
46
|
+
* shape we stream:
|
|
47
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
48
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
49
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
50
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
51
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
52
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
53
|
+
* - `response_metadata.finishReason` (Google)
|
|
54
|
+
*
|
|
55
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
56
|
+
* output token ceiling, otherwise `null`.
|
|
57
|
+
*/
|
|
58
|
+
export function getTruncationStopReason(
|
|
59
|
+
message:
|
|
60
|
+
| Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'>
|
|
61
|
+
| undefined
|
|
62
|
+
| null
|
|
63
|
+
): TruncationStopReason | null {
|
|
64
|
+
const meta = message?.response_metadata as
|
|
65
|
+
| Record<string, unknown>
|
|
66
|
+
| undefined;
|
|
67
|
+
const additionalKwargs = message?.additional_kwargs as
|
|
68
|
+
| Record<string, unknown>
|
|
69
|
+
| undefined;
|
|
70
|
+
if (meta == null && additionalKwargs == null) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const messageStop = meta?.messageStop as { stopReason?: unknown } | undefined;
|
|
75
|
+
const incompleteDetails = meta?.incomplete_details as
|
|
76
|
+
| { reason?: unknown }
|
|
77
|
+
| undefined;
|
|
78
|
+
const candidates: unknown[] = [
|
|
79
|
+
meta?.stopReason,
|
|
80
|
+
meta?.stop_reason,
|
|
81
|
+
meta?.finish_reason,
|
|
82
|
+
meta?.finishReason,
|
|
83
|
+
messageStop?.stopReason,
|
|
84
|
+
incompleteDetails?.reason,
|
|
85
|
+
additionalKwargs?.stop_reason,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
const normalized = normalizeStopValue(candidate);
|
|
90
|
+
if (normalized != null) {
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasOpenToolCall(message: AIMessageChunk | BaseMessage): boolean {
|
|
98
|
+
const m = message as AIMessageChunk & {
|
|
99
|
+
tool_calls?: unknown[];
|
|
100
|
+
tool_call_chunks?: unknown[];
|
|
101
|
+
invalid_tool_calls?: unknown[];
|
|
102
|
+
};
|
|
103
|
+
return (
|
|
104
|
+
(m.tool_calls?.length ?? 0) > 0 ||
|
|
105
|
+
(m.tool_call_chunks?.length ?? 0) > 0 ||
|
|
106
|
+
(m.invalid_tool_calls?.length ?? 0) > 0
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
112
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
113
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
114
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
115
|
+
*/
|
|
116
|
+
export class OutputTruncationError extends Error {
|
|
117
|
+
readonly stopReason: TruncationStopReason;
|
|
118
|
+
readonly toolCallNames: string[];
|
|
119
|
+
|
|
120
|
+
constructor(stopReason: TruncationStopReason, toolCallNames: string[]) {
|
|
121
|
+
const named =
|
|
122
|
+
toolCallNames.length > 0
|
|
123
|
+
? ` (tool call: ${toolCallNames.join(', ')})`
|
|
124
|
+
: '';
|
|
125
|
+
super(
|
|
126
|
+
'The model response was truncated at the maximum output token limit before ' +
|
|
127
|
+
`the tool call arguments were complete${named}. Increase the model's max ` +
|
|
128
|
+
'output tokens, or have the model produce smaller arguments — for large ' +
|
|
129
|
+
'files, write a lean main file and move bulky content into separate files.'
|
|
130
|
+
);
|
|
131
|
+
this.name = 'OutputTruncationError';
|
|
132
|
+
this.stopReason = stopReason;
|
|
133
|
+
this.toolCallNames = toolCallNames;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectToolCallNames(message: AIMessageChunk | BaseMessage): string[] {
|
|
138
|
+
const m = message as AIMessageChunk & {
|
|
139
|
+
tool_calls?: Array<{ name?: string }>;
|
|
140
|
+
tool_call_chunks?: Array<{ name?: string }>;
|
|
141
|
+
invalid_tool_calls?: Array<{ name?: string }>;
|
|
142
|
+
};
|
|
143
|
+
const names = [
|
|
144
|
+
...(m.tool_calls ?? []),
|
|
145
|
+
...(m.tool_call_chunks ?? []),
|
|
146
|
+
...(m.invalid_tool_calls ?? []),
|
|
147
|
+
]
|
|
148
|
+
.map((tc) => tc.name)
|
|
149
|
+
.filter((name): name is string => typeof name === 'string' && name !== '');
|
|
150
|
+
return [...new Set(names)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
155
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
156
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
157
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
158
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
159
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
160
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
161
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
162
|
+
*/
|
|
163
|
+
export function assertNotTruncatedToolCall(
|
|
164
|
+
message: AIMessageChunk | BaseMessage | undefined | null,
|
|
165
|
+
provider?: Providers
|
|
166
|
+
): void {
|
|
167
|
+
if (message == null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const stopReason = getTruncationStopReason(message);
|
|
174
|
+
if (stopReason == null || !hasOpenToolCall(message)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new OutputTruncationError(stopReason, collectToolCallNames(message));
|
|
178
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Bedrock tool-call truncation verification.
|
|
3
|
+
*
|
|
4
|
+
* Reproduces the production failure where a large tool argument (`content`)
|
|
5
|
+
* gets cut off by the max output token limit, leaving the handler with a
|
|
6
|
+
* well-formed-but-incomplete tool call and no truncation signal.
|
|
7
|
+
*
|
|
8
|
+
* Run with:
|
|
9
|
+
* RUN_BEDROCK_TRUNCATION_LIVE=1 npm test -- bedrock-truncation.live.test.ts --runInBand
|
|
10
|
+
*/
|
|
11
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
12
|
+
dotenvConfig();
|
|
13
|
+
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
16
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
17
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
18
|
+
import type * as t from '@/types';
|
|
19
|
+
import { GraphEvents, Providers } from '@/common';
|
|
20
|
+
import { ChatModelStreamHandler } from '@/stream';
|
|
21
|
+
import { ToolEndHandler, ModelEndHandler } from '@/events';
|
|
22
|
+
import { Run } from '@/run';
|
|
23
|
+
|
|
24
|
+
const shouldRunLive =
|
|
25
|
+
process.env.RUN_BEDROCK_TRUNCATION_LIVE === '1' &&
|
|
26
|
+
(process.env.BEDROCK_AWS_ACCESS_KEY_ID ?? '') !== '' &&
|
|
27
|
+
(process.env.BEDROCK_AWS_SECRET_ACCESS_KEY ?? '') !== '';
|
|
28
|
+
|
|
29
|
+
const describeIfLive = shouldRunLive ? describe : describe.skip;
|
|
30
|
+
|
|
31
|
+
const REGION =
|
|
32
|
+
[
|
|
33
|
+
process.env.BEDROCK_AWS_REGION,
|
|
34
|
+
process.env.BEDROCK_AWS_DEFAULT_REGION,
|
|
35
|
+
process.env.AWS_DEFAULT_REGION,
|
|
36
|
+
].find(
|
|
37
|
+
(value): value is string => typeof value === 'string' && value !== ''
|
|
38
|
+
) ?? 'us-east-1';
|
|
39
|
+
const MODEL = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
|
|
40
|
+
const MAX_TOKENS = Number(process.env.REPRO_MAX_TOKENS ?? 350);
|
|
41
|
+
|
|
42
|
+
const schema = z.object({
|
|
43
|
+
path: z.string().describe('Path to write.'),
|
|
44
|
+
content: z.string().describe('Complete file contents.'),
|
|
45
|
+
overwrite: z.boolean().optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describeIfLive('Bedrock tool-call truncation (live)', () => {
|
|
49
|
+
jest.setTimeout(120_000);
|
|
50
|
+
|
|
51
|
+
it('fails fast with a truncation error instead of looping', async () => {
|
|
52
|
+
const received: Array<{ keys: string[]; contentLen: number | null }> = [];
|
|
53
|
+
const stepToolCalls: string[][] = [];
|
|
54
|
+
let invocations = 0;
|
|
55
|
+
|
|
56
|
+
const createFile = new DynamicStructuredTool({
|
|
57
|
+
name: 'create_file',
|
|
58
|
+
description: 'Create a new file. Requires path and content.',
|
|
59
|
+
schema,
|
|
60
|
+
func: async (input: z.infer<typeof schema>) => {
|
|
61
|
+
invocations += 1;
|
|
62
|
+
const hasContent =
|
|
63
|
+
typeof input.content === 'string' && input.content.length > 0;
|
|
64
|
+
received.push({
|
|
65
|
+
keys: Object.keys(input as Record<string, unknown>),
|
|
66
|
+
contentLen: hasContent ? input.content.length : null,
|
|
67
|
+
});
|
|
68
|
+
if (!hasContent) {
|
|
69
|
+
return 'Error: content is required\n Please fix your mistakes.';
|
|
70
|
+
}
|
|
71
|
+
return `Wrote ${input.content.length} bytes to ${input.path}`;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const llmConfig = {
|
|
76
|
+
provider: Providers.BEDROCK,
|
|
77
|
+
model: MODEL,
|
|
78
|
+
region: REGION,
|
|
79
|
+
credentials: {
|
|
80
|
+
accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID!,
|
|
81
|
+
secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY!,
|
|
82
|
+
},
|
|
83
|
+
maxTokens: MAX_TOKENS,
|
|
84
|
+
streaming: true,
|
|
85
|
+
streamUsage: true,
|
|
86
|
+
} as t.LLMConfig;
|
|
87
|
+
|
|
88
|
+
const run = await Run.create<t.IState>({
|
|
89
|
+
runId: 'bedrock-truncation-live',
|
|
90
|
+
graphConfig: {
|
|
91
|
+
type: 'standard',
|
|
92
|
+
llmConfig,
|
|
93
|
+
tools: [createFile],
|
|
94
|
+
instructions: 'You are a ClickHouse assistant. Use tools when asked.',
|
|
95
|
+
maxContextTokens: 89000,
|
|
96
|
+
},
|
|
97
|
+
returnContent: true,
|
|
98
|
+
skipCleanup: true,
|
|
99
|
+
customHandlers: {
|
|
100
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(
|
|
101
|
+
async (toolEndData: t.ToolEndData) => {
|
|
102
|
+
const out = toolEndData.output as
|
|
103
|
+
| { content?: unknown; name?: string; status?: string }
|
|
104
|
+
| undefined;
|
|
105
|
+
const content =
|
|
106
|
+
typeof out?.content === 'string'
|
|
107
|
+
? out.content.slice(0, 120)
|
|
108
|
+
: JSON.stringify(out?.content).slice(0, 120);
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.log(
|
|
111
|
+
`TOOL_END name=${out?.name} status=${out?.status} content=${content}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
),
|
|
115
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
116
|
+
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
|
|
117
|
+
[GraphEvents.ON_RUN_STEP]: {
|
|
118
|
+
handle: (_event: string, data: t.StreamEventData): void => {
|
|
119
|
+
const step = data as unknown as {
|
|
120
|
+
stepDetails?: {
|
|
121
|
+
type?: string;
|
|
122
|
+
tool_calls?: Array<{ name?: string; args?: unknown }>;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const tcs = step.stepDetails?.tool_calls;
|
|
126
|
+
if ((tcs?.length ?? 0) > 0 && tcs != null) {
|
|
127
|
+
for (const tc of tcs) {
|
|
128
|
+
if (tc.name === 'create_file') {
|
|
129
|
+
stepToolCalls.push(
|
|
130
|
+
typeof tc.args === 'string'
|
|
131
|
+
? ['<raw-string>']
|
|
132
|
+
: Object.keys((tc.args ?? {}) as Record<string, unknown>)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// eslint-disable-next-line no-console
|
|
137
|
+
console.log(
|
|
138
|
+
'ON_RUN_STEP tool_calls=',
|
|
139
|
+
JSON.stringify(
|
|
140
|
+
tcs.map((tc) => ({
|
|
141
|
+
name: tc.name,
|
|
142
|
+
args:
|
|
143
|
+
typeof tc.args === 'string'
|
|
144
|
+
? tc.args.slice(0, 80)
|
|
145
|
+
: Object.keys(
|
|
146
|
+
(tc.args ?? {}) as Record<string, unknown>
|
|
147
|
+
),
|
|
148
|
+
}))
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const prompt =
|
|
158
|
+
'Call create_file once: path="skills/clickhouse-demo/SKILL.md", content=a complete ' +
|
|
159
|
+
'3000-word ClickHouse guide (schema design, MergeTree, partitioning, 10 example queries). ' +
|
|
160
|
+
'Put the entire guide in the content argument.';
|
|
161
|
+
|
|
162
|
+
let loopError: string | null = null;
|
|
163
|
+
try {
|
|
164
|
+
await run.processStream(
|
|
165
|
+
{ messages: [new HumanMessage(prompt)] },
|
|
166
|
+
{
|
|
167
|
+
configurable: { provider: Providers.BEDROCK, thread_id: 'trunc' },
|
|
168
|
+
version: 'v2',
|
|
169
|
+
recursionLimit: 8,
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
loopError = err instanceof Error ? err.message : String(err);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// eslint-disable-next-line no-console
|
|
177
|
+
console.log('LIVE RESULT', {
|
|
178
|
+
invocations,
|
|
179
|
+
received,
|
|
180
|
+
stepToolCalls,
|
|
181
|
+
loopError,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The run fails fast with the actionable truncation error...
|
|
185
|
+
expect(loopError).toMatch(/truncated at the maximum output token limit/i);
|
|
186
|
+
// ...instead of looping until the recursion limit.
|
|
187
|
+
expect(loopError).not.toMatch(/Recursion limit/i);
|
|
188
|
+
// No truncated create_file call ever carried a usable `content` arg.
|
|
189
|
+
expect(stepToolCalls.every((keys) => !keys.includes('content'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|