Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5549,11 +5549,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
if (this.summarizeOnlyAgentId != null) {
return END;
}
const { messages } = state as t.BaseGraphState;
const last = messages[messages.length - 1] as AIMessageChunk | undefined;
const calls = last?.getType() === 'ai' ? last.tool_calls ?? [] : [];
const hasOnlyExternalCalls =
calls.length > 0 &&
calls.every((call) => (call as t.CustomToolCall).execution?.mode === 'external');
if (hasOnlyExternalCalls) {
return END;
}
const delegatedNames = this.clientDelegatedToolNames;
if (delegatedNames != null && delegatedNames.size > 0) {
const { messages } = state as t.BaseGraphState;
const last = messages[messages.length - 1] as AIMessageChunk | undefined;
const calls = last?.getType() === 'ai' ? last.tool_calls ?? [] : [];
if (calls.some((call) => delegatedNames.has(call.name))) {
if (
calls.some(
Expand Down
51 changes: 49 additions & 2 deletions src/llm/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,13 @@ type OpenAICompletionParam =
type OpenAIClientConfig = NonNullable<
ConstructorParameters<typeof OpenAIClient>[0]
>;
type ExternalToolExecutionConfig = {
enabled: boolean;
acceptedProviders?: string[];
};
type LibreChatOpenAIFields = t.ChatOpenAIFields & {
_lc_stream_delay?: number;
externalToolExecution?: ExternalToolExecutionConfig;
firstPartyEndpoint?: boolean;
includeReasoningContent?: boolean;
includeReasoningDetails?: boolean;
Expand All @@ -147,6 +152,10 @@ type OpenAIDeltaWithLibreChatFields = Record<string, unknown> & {
reasoning_details?: unknown;
provider_specific_fields?: unknown;
};
type OpenAIToolCallWithExecution = {
index?: number;
execution?: { mode?: unknown; provider?: unknown };
};
type OpenAIClientOwner = {
client?: OpenAIClient;
clientConfig: OpenAIClientConfig;
Expand Down Expand Up @@ -1329,7 +1338,8 @@ async function completionWithFilteredOpenAIStream(

function attachLibreChatDeltaFields(
chunk: BaseMessageChunk,
delta: Record<string, unknown>
delta: Record<string, unknown>,
externalToolExecution?: ExternalToolExecutionConfig
): BaseMessageChunk {
if (!AIMessageChunk.isInstance(chunk)) {
return chunk;
Expand All @@ -1350,6 +1360,40 @@ function attachLibreChatDeltaFields(
chunk.additional_kwargs.provider_specific_fields =
libreChatDelta.provider_specific_fields;
}
const toolCalls = libreChatDelta.tool_calls as OpenAIToolCallWithExecution[] | undefined;
const toolCallChunks = (chunk as BaseMessageChunk & {
tool_call_chunks?: Array<Record<string, unknown>>;
}).tool_call_chunks;
if (externalToolExecution?.enabled !== true || !Array.isArray(toolCalls) || !Array.isArray(toolCallChunks)) {
return chunk;
}
for (const toolCall of toolCalls) {
const execution = toolCall.execution;
if (
execution?.mode !== 'external' ||
(externalToolExecution.acceptedProviders != null &&
(typeof execution.provider !== 'string' ||
!externalToolExecution.acceptedProviders.includes(execution.provider)))
) {
continue;
}
const index = toolCall.index ?? 0;
const normalizedExecution = {
mode: 'external' as const,
...(typeof execution.provider === 'string' ? { provider: execution.provider } : {}),
};
for (const toolCallChunk of toolCallChunks) {
if (toolCallChunk.index === index) {
toolCallChunk.execution = normalizedExecution;
}
}
const parsedToolCall = (chunk as BaseMessageChunk & {
tool_calls?: Array<Record<string, unknown>>;
}).tool_calls?.[index];
if (parsedToolCall != null) {
parsedToolCall.execution = normalizedExecution;
}
}
return chunk;
}

Expand Down Expand Up @@ -1859,6 +1903,7 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
return astraRulesApply(this.model, this.firstPartyEndpoint);
}

private externalToolExecution?: ExternalToolExecutionConfig;
private includeReasoningContent?: boolean;
private includeReasoningDetails?: boolean;
private convertReasoningDetailsToContent?: boolean;
Expand All @@ -1868,6 +1913,7 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {

constructor(fields?: LibreChatOpenAIFields) {
super(fields);
this.externalToolExecution = fields?.externalToolExecution;
this.includeReasoningContent = fields?.includeReasoningContent;
this.includeReasoningDetails = fields?.includeReasoningDetails;
this.convertReasoningDetailsToContent =
Expand Down Expand Up @@ -1940,7 +1986,8 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
rawResponse,
defaultRole
),
delta
delta,
this.externalToolExecution
);
if (isOfficialOpenAIBaseURL(this.clientConfig.baseURL)) {
return stampSequentialStreamedToolCallAdapter(message);
Expand Down
56 changes: 56 additions & 0 deletions src/llm/openai/sequentialToolCallSeals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import {
} from '@/tools/streamedToolCallSeals';
import { ChatOpenAI, AzureChatOpenAI } from './index';

type ExternalExecution = { mode: 'external'; provider?: string };

type ToolCallRecord = {
index?: number;
execution?: ExternalExecution;
};

type ToolCallMessage = AIMessageChunk & {
tool_calls?: ToolCallRecord[];
tool_call_chunks?: ToolCallRecord[];
};

type DeltaConverter = {
_convertCompletionsDeltaToBaseMessageChunk(
delta: Record<string, unknown>,
Expand Down Expand Up @@ -53,6 +65,10 @@ function adapterOf(message: AIMessageChunk): unknown {
];
}

function executionAt(message: AIMessageChunk, index: number): ExternalExecution | undefined {
return (message as ToolCallMessage).tool_calls?.[index]?.execution;
}

describe('Chat Completions sequential tool-call seal stamping', () => {
// Both the implementation (OPENAI_BASE_URL fallback) and the Azure
// constructor (AZURE_OPENAI_BASE_PATH fallback) read the environment, so
Expand Down Expand Up @@ -138,6 +154,46 @@ describe('Chat Completions sequential tool-call seal stamping', () => {
expect(adapterOf(message)).toBeUndefined();
});

test('propagates approved external execution ownership into parsed tool calls', () => {
const model = new ChatOpenAI({
model: 'custom-model',
apiKey: 'test',
configuration: { baseURL: 'https://example.invalid/v1' },
externalToolExecution: { enabled: true, acceptedProviders: ['remote-runtime'] },
});
const message = convertDelta(model, {
...toolCallDelta,
tool_calls: [
{
...toolCallDelta.tool_calls[0],
execution: { mode: 'external', provider: 'remote-runtime' },
},
],
});

expect(executionAt(message, 0)).toEqual({ mode: 'external', provider: 'remote-runtime' });
});

test('ignores external execution ownership from an unapproved provider', () => {
const model = new ChatOpenAI({
model: 'custom-model',
apiKey: 'test',
configuration: { baseURL: 'https://example.invalid/v1' },
externalToolExecution: { enabled: true, acceptedProviders: ['remote-runtime'] },
});
const message = convertDelta(model, {
...toolCallDelta,
tool_calls: [
{
...toolCallDelta.tool_calls[0],
execution: { mode: 'external', provider: 'unapproved-runtime' },
},
],
});

expect(executionAt(message, 0)).toBeUndefined();
});

test('does not stamp text-only deltas', () => {
const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
const message = convertDelta(model, {
Expand Down
28 changes: 23 additions & 5 deletions src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2052,13 +2052,29 @@ hasToolCallChunks: ${hasToolCallChunks}
return;
}

/* Note: tool call chunks may have non-empty content that matches the current tool chunk generation */
/** A custom OpenAI-compatible endpoint can emit final assistant text after
* an externally executed tool call in the same response. Keep actual text,
* while still discarding chunks that merely mirror streamed tool arguments. */
const contentMatchesToolCallArgs =
hasToolCallChunks && (chunk.tool_call_chunks?.some((tc) => tc.args === content) ?? false);
if (typeof content === 'string' && runStep.type === StepTypes.TOOL_CALLS) {
if (contentMatchesToolCallArgs) {
return;
}
stepId = await dispatchMessageCreationStep({ graph, stepKey, content, metadata });
runStep = graph.getRunStep(stepId);
if (runStep == null) {
return;
}
await graph.dispatchMessageDelta(
stepId,
{
content: [{ type: ContentTypes.TEXT, text: content }],
},
metadata
);
return;
} else if (
hasToolCallChunks &&
(chunk.tool_call_chunks?.some((tc) => tc.args === content) ?? false)
) {
} else if (contentMatchesToolCallArgs) {
return;
} else if (typeof content === 'string') {
if (agentContext.currentTokenType === ContentTypes.TEXT) {
Expand Down Expand Up @@ -2723,6 +2739,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
args: toolCall.args,
name: toolCall.name,
id: toolCallId,
execution: (toolCall as t.CustomToolCall).execution,
},
};

Expand Down Expand Up @@ -2851,6 +2868,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
args: toolCallDelta.args ?? '',
name: toolCallDelta.name,
id: toolCallId,
execution: (toolCallDelta as t.CustomToolCall).execution,
auth: runStepDelta.delta.auth,
expires_at: runStepDelta.delta.expires_at,
},
Expand Down
9 changes: 9 additions & 0 deletions src/tools/ToolNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1805,6 +1805,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
config: RunnableConfig,
batchContext: RunToolBatchContext<T> = {}
): Promise<BaseMessage | Command> {
const execution = (call as t.CustomToolCall).execution;
if (execution?.mode === 'external') {
return new ToolMessage({
content: '',
name: call.name,
tool_call_id: call.id!,
});
}
const {
batchIndex,
turn,
Expand Down Expand Up @@ -3443,6 +3451,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
id: toolCallId,
output: contentString,
progress: 1,
execution: (call as t.CustomToolCall).execution,
...(outcome != null && { outcome }),
};

Expand Down
3 changes: 2 additions & 1 deletion src/types/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type Anthropic from '@anthropic-ai/sdk';
import type { AnthropicContentBlock } from '@/llm/anthropic/types';
import type { AssistantTextPhase } from '@/types/assistantPhase';
import type { SummarizeCompleteEvent } from '@/types/summarize';
import type { ToolEndEvent } from '@/types/tools';
import type { ToolEndEvent, ToolExecution } from '@/types/tools';
import { StepTypes, ContentTypes, GraphEvents } from '@/common/enum';

/** One accepted model result, detached from execution state before host dispatch.
Expand Down Expand Up @@ -236,6 +236,7 @@ export type ToolEndCallback = (

export type ProcessedToolCall = {
name: string;
execution?: ToolExecution;
args: string | Record<string, unknown>;
id: string;
output: string;
Expand Down
7 changes: 7 additions & 0 deletions src/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import type { HandoffRouting } from '@/graphs/handoff';
import type { HumanInTheLoopConfig } from './hitl';
import type { HookRegistry } from '@/hooks';

/** Execution owner for a tool call. Omitted values use the local executor. */
export type ToolExecution = {
mode: 'local' | 'external';
provider?: string;
};

/** Replacement type for `import type { ToolCall } from '@langchain/core/messages/tool'` in order to have stringified args typed */
export type CustomToolCall = {
name: string;
Expand All @@ -26,6 +32,7 @@ export type CustomToolCall = {
id?: string;
type?: 'tool_call';
output?: string;
execution?: ToolExecution;
};

export type GenericTool = (StructuredToolInterface | RunnableToolLike) & {
Expand Down