Skip to content
Merged
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,38 @@ const customHandlers = composeEventHandlers(
);
```

### Accepted tool-call projection (opt-in)

`createOpenAIToolCallStream` from `@librechat/agents/openai` formats finalized
calls accepted by the graph, not provider fragments. Existing handlers are unchanged.

- Register `projection.handlers` in `Run.create({ customHandlers })`.
- For streaming, share `{ tracker, emit }` with the text/reasoning handlers and
`sendOpenAIFinalChunk`. Do not also register legacy raw tool-call handlers.
Map-only `{ toolCalls }` supports JSON/custom finalization. Use fresh output state.
- Call `finish()` only after natural completion: no error, aborted signal,
`getInterrupt()` or `getHaltReason()`. Otherwise call `abort()` and discard it.
A resolved `processStream()` alone does not mean success.
- Calls execute in the graph by default. To hand a complete call to the OpenAI
client instead, set `clientDelegatedToolNames: ['my_tool']` in `Run.create`
for a single-agent run and register its model-facing schema. The graph ends
without executing that call. Batches mixing delegated and graph/provider
tools fail closed; make separate model turns. ToolNode claims remain an
additional guard, never proof that an unclaimed call belongs to the client.
- `emit` is synchronous. Failed/partial writes cannot be retried; the host owns
HTTP backpressure. This helper does not provide durable resume or undo tool effects.

Projected arguments must be JSON data: primitives, plain objects and dense arrays.
Getters, custom objects, cycles and nesting beyond 64 levels are rejected before projection.
Observers receive isolated snapshots; provider IDs are reserved before synthetic IDs.
Accepted-event snapshots cap each response at **1,024 calls / 4 MiB**. Projection
uses those defaults for pending calls (`maxToolCalls` / `maxBufferedBytes`). These
formatting limits do not apply to ordinary runs without accepted-event handlers;
descriptor safety checks still apply before stream accounting and dispatch.
These are output limits, not bounds on provider memory or tool execution.

See [the design decision](docs/adr/0010-project-accepted-model-results.md).

## Development

```bash
Expand Down
42 changes: 42 additions & 0 deletions docs/adr/0010-project-accepted-model-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR 0010: Project Accepted Model Results, Not Provider Fragments

## Status

Proposed in SDK PR #560. Not released or adopted by LibreChat.

## Context

Run-step events mix partial data, snapshots and failed attempts. They cannot
reliably identify the accepted result across streaming, invoke-only and fallback
paths. More formatter heuristics cannot recover missing execution information.

## Decision

The graph emits an awaited, registry-only `ON_MODEL_RESPONSE` after acceptance,
fallback/overflow recovery and usage accounting. Model outputs validate and
detach parsed calls and raw fragments before stream accounting or dispatch,
without imposing projection budgets on ordinary runs. Composed observers receive
isolated snapshots. Provider/tool custom callbacks cannot impersonate acceptance. Handler errors
propagate outside provider retry logic.

The opt-in OpenAI projector buffers only calls explicitly marked client-owned
by the trusted graph, and formats after host-confirmed natural completion.
Provider/SDK calls never reach the client, even if ToolNode is bypassed; ToolNode
claims are a secondary guard. Single-agent `clientDelegatedToolNames` routes
pure client batches to END, while mixed client/graph batches fail closed.
Partial string arguments are not executable, even with an eager seal. Stream
and invoke use SDK-owned snapshots so frozen provider messages remain intact.
Bounded pending call count, encoded bytes and depth prevent unbounded retention. See the
[README](../../README.md#accepted-tool-call-projection-opt-in) for registration,
limits and failure handling.

## Trade-offs and verification

This removes fragment/attempt reconstruction but delays tool-call output. It does
not replace provider normalization, sandbox callbacks, roll back eager tools or
provide durable delivery. Existing default handlers remain unchanged; LibreChat
integration and release are separate gates.

Verify through real `Run.processStream` and the public finalizer: streaming/invoke,
fallback/overflow, final-answer ordering, observer isolation, cancellation,
malformed arguments, output limits, subagents, usage and tracing.
63 changes: 63 additions & 0 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AIMessageChunk } from '@langchain/core/messages';
import { describe, it, expect, jest, afterEach } from '@jest/globals';
import type { AgentContext } from '@/agents/AgentContext';
import type { StandardGraph } from '@/graphs';
Expand Down Expand Up @@ -5180,3 +5181,65 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
expect(graph.eagerEventToolExecutions.has('call_edit')).toBe(false);
});
});

describe('executable argument readiness', () => {
it('prevents eager host execution for string-valued parsed calls even with a final seal', async () => {
const graph = createGraph();
const requests: t.ToolExecuteBatchRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) return;
const request = data as t.ToolExecuteBatchRequest;
requests.push(request);
request.resolve([
{ toolCallId: 'call_weather', status: 'success', content: 'sunny' },
]);
});
const raw = new AIMessageChunk({
content: '',
tool_calls: [
{
id: 'call_weather',
name: 'weather',
args: '{"city":"NYC"}' as unknown as Record<string, unknown>,
},
],
response_metadata: finalToolCallResponseMetadata,
});
await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{ chunk: raw },
{ langgraph_node: 'agent' },
graph
);
expect(requests).toHaveLength(0);
});
});

describe('client delegation batch barrier', () => {
it('does not pre-execute an SDK call before a possible client-owned call arrives', async () => {
const graph = createGraph();
graph.clientDelegatedToolNames = new Set(['client_tool']);
const requests: t.ToolExecuteBatchRequest[] = [];
jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) return;
const request = data as t.ToolExecuteBatchRequest;
requests.push(request);
request.resolve([{ toolCallId: 'call_weather', status: 'success', content: 'sunny' }]);
}
);
await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{ chunk: new AIMessageChunk({
content: '',
tool_calls: [{ id: 'call_weather', name: 'weather', args: { city: 'NYC' } }],
response_metadata: finalToolCallResponseMetadata,
}) },
{ langgraph_node: 'agent' },
graph
);
expect(requests).toHaveLength(0);
});
});
5 changes: 5 additions & 0 deletions src/common/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
export enum GraphEvents {
/* Custom Events */

/** Accepted graph model result, after fallback selection. Registry-only, not a provider callback. */
ON_MODEL_RESPONSE = 'on_model_response',
/** Registry-only: ToolNode has taken ownership of an accepted message's calls. */
ON_MODEL_TOOLS_CLAIMED = 'on_model_tools_claimed',

/** [Custom] Agent update event in multi-agent graph/workflow */
ON_AGENT_UPDATE = 'on_agent_update',
/** [Custom] Delta event for run steps (message creation and tool calls) */
Expand Down
25 changes: 24 additions & 1 deletion src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { Logger } from 'winston';
import type { MultiAgentGraph, StandardGraph } from '@/graphs';
import type * as t from '@/types';
import { dispatchesChatModelStream, SDK_STREAM_DISPATCH } from '@/stream';
import { Constants } from '@/common';
import { Constants, GraphEvents } from '@/common';

export class HandlerRegistry {
private handlers: Map<string, t.EventHandler> = new Map();
Expand All @@ -26,12 +26,23 @@ export function composeEventHandlers(
...handlerSets: Array<Record<string, t.EventHandler> | undefined>
): Record<string, t.EventHandler> {
const composed: Partial<Record<string, t.EventHandler>> = {};
const acceptedObservers = new Map<string, t.EventHandler[]>();

for (const handlerSet of handlerSets) {
if (!handlerSet) {
continue;
}
for (const [eventType, handler] of Object.entries(handlerSet)) {
if (
eventType === GraphEvents.ON_MODEL_RESPONSE ||
eventType === GraphEvents.ON_MODEL_TOOLS_CLAIMED
) {
const observers = acceptedObservers.get(eventType) ?? [];
observers.push(handler);
acceptedObservers.set(eventType, observers);
composed[eventType] = handler;
continue;
}
const previous = composed[eventType];
if (previous === undefined) {
composed[eventType] = handler;
Expand Down Expand Up @@ -61,6 +72,18 @@ export function composeEventHandlers(
}
}

for (const [eventType, observers] of acceptedObservers) {
composed[eventType] = {
handle: async (event, data, metadata, graph): Promise<void> => {
// Clone from the graph-owned snapshot, not from any preceding observer's
// possibly mutated argument tree. One bounded copy per observer.
for (const observer of observers) {
await observer.handle(event, structuredClone(data), metadata, graph);
}
},
};
}

return composed as Record<string, t.EventHandler>;
}

Expand Down
95 changes: 95 additions & 0 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ import {
annotateMessagesForLLM,
ToolOutputReferenceRegistry,
} from '@/tools/toolOutputReferences';
import {
InvalidModelToolCallError,
snapshotAcceptedModelResponse,
} from './acceptedModelResponse';
import {
prepareProviderRequest,
usesNativeOpenAIResponses,
Expand Down Expand Up @@ -831,6 +835,8 @@ export abstract class Graph<
callerSignal?: AbortSignal;
/** Set of invoked tool call IDs from non-message run steps completed mid-run, if any */
invokedToolIds?: Set<string>;
/** Explicit host policy. Never inferred from missing ToolNode claims. */
clientDelegatedToolNames?: ReadonlySet<string>;
handlerRegistry: HandlerRegistry | undefined;
/** Host registry retained only for forwarding tools from nested child graphs. */
protected parentToolHandlerRegistry: HandlerRegistry | undefined;
Expand Down Expand Up @@ -1543,6 +1549,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
preemption,
streamLimits,
toolExecution,
clientDelegatedToolNames,
}: t.StandardGraphInput,
dependencies?: GraphFactoryDependencies
) {
Expand Down Expand Up @@ -1570,6 +1577,15 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.preemption = preemption;
this.streamLimits = resolveStreamLimits(streamLimits);
this.toolExecution = toolExecution;
if (clientDelegatedToolNames != null && clientDelegatedToolNames.length > 0) {
if (agents.length !== 1) {
throw new Error('Client tool delegation requires a single-agent graph');
}
if (clientDelegatedToolNames.some((name) => !name.trim())) {
throw new Error('Client delegated tool names must be nonempty');
}
this.clientDelegatedToolNames = new Set(clientDelegatedToolNames);
}

if (agents.length === 0) {
throw new Error('At least one agent configuration is required');
Expand Down Expand Up @@ -2833,6 +2849,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
currentToolMap?: t.ToolMap;
agentContext?: AgentContext;
}): CustomToolNode<t.BaseGraphState> | ToolNode<t.BaseGraphState> {
const onToolCallsClaimed = async (
messageId: string,
config: RunnableConfig
): Promise<void> => {
const handler = this.handlerRegistry?.getHandler(
GraphEvents.ON_MODEL_TOOLS_CLAIMED
);
if (handler == null) return;
await handler.handle(
GraphEvents.ON_MODEL_TOOLS_CLAIMED,
{
type: 'model_tools_claimed',
agentId: agentContext?.agentId ?? this.defaultAgentId,
messageId,
},
config.metadata,
this
);
};
const toolDefinitions = agentContext?.toolDefinitions;
const eventDrivenMode =
toolDefinitions != null && toolDefinitions.length > 0;
Expand Down Expand Up @@ -2920,6 +2955,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.config = config;
this.restoreRunStepResumeState(state);
},
onToolCallsClaimed,
createRunStepResumeState: (): t.RunStepResumeState =>
this.createRunStepResumeState(),
errorHandler: (data, metadata): Promise<boolean> =>
Expand Down Expand Up @@ -3002,6 +3038,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.config = config;
this.restoreRunStepResumeState(state);
},
onToolCallsClaimed,
createRunStepResumeState: (): t.RunStepResumeState =>
this.createRunStepResumeState(),
});
Expand Down Expand Up @@ -4363,6 +4400,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
* succeeding fallback would resolve a run the public contract says
* must reject. Rethrow before any recovery path.
*/
if (primaryError instanceof InvalidModelToolCallError) {
throw primaryError;
}
if (
primaryError instanceof StreamLimitExceededError ||
primaryError instanceof PreparedSubagentError
Expand Down Expand Up @@ -4703,6 +4743,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
})
);
} catch (fallbackError) {
if (fallbackError instanceof InvalidModelToolCallError) {
throw fallbackError;
}
if (
fallbackError instanceof StreamLimitExceededError ||
fallbackError instanceof PreparedSubagentError
Expand Down Expand Up @@ -5007,6 +5050,36 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.preemptIncomplete = true;
}

const responseHandler = this.handlerRegistry?.getHandler(
GraphEvents.ON_MODEL_RESPONSE
);
if (responseHandler != null && responseMessage?.getType() === 'ai') {
try {
// One graph-owned accepted result after all primary/fallback/overflow paths.
// No inference from provider chunks, run-step IDs, or attempt callback metadata.
invokeConfig.signal?.throwIfAborted();
const accepted = snapshotAcceptedModelResponse(
responseMessage as AIMessageChunk,
v4(),
agentId,
this.invokedToolIds,
this.clientDelegatedToolNames
);
Comment thread
lia-by-librechat[bot] marked this conversation as resolved.
// Awaited, registry-only: no trace replay, usage recording or side effects.
// Detached calls prevent a consumer from changing tools about to execute.
await responseHandler.handle(
GraphEvents.ON_MODEL_RESPONSE,
accepted,
Comment thread
lia-by-librechat[bot] marked this conversation as resolved.
metadata,
this
);
invokeConfig.signal?.throwIfAborted();
} catch (error) {
this.cleanupSignalListener();
throw error;
}
}

this.cleanupSignalListener();
return result;
};
Expand Down Expand Up @@ -5475,6 +5548,28 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
if (this.summarizeOnlyAgentId != null) {
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(
(call) =>
!delegatedNames.has(call.name) ||
(call.id != null && this.invokedToolIds?.has(call.id) === true)
) ||
(last?.invalid_tool_calls?.length ?? 0) > 0 ||
getTruncationStopReason(last) != null
) {
throw new InvalidModelToolCallError(
'Mixed client and graph-owned tool calls require separate model turns'
);
}
return END;
}
}
const decision = toolsCondition(
state as t.BaseGraphState,
toolNode,
Expand Down
Loading
Loading