Skip to content

Commit 9226c0a

Browse files
🚦 feat: Gate Provider Text Before SDK Release (#593)
* 🚦 feat: Gate Provider Text Before SDK Release * fix: Preserve Provider Controls and Bound Wait Retention * fix: Certify Provider Composition and Invoke Routes * fix: Validate Provider Transform and Effective Streaming * test: Select the Guarded Stream in the Allowed Control * fix: Preserve Tool Input Deltas and Enforce Elapsed Deadlines * fix: Stop Queued Consumers After Protection Trips * fix: Propagate Protection Trips Through Tools and Children * fix: Preserve Native Invalid Tool Call Diagnostics * fix: Honor Inherited Trips and Keep Protected Streams Lazy * fix: Normalize Bounded OpenRouter Prose Replays * fix: Publish Canonical Prose After Tool Controls * fix: Certify Provider Controls and Preserve Safety Trips * fix: Preserve Responses Merge Semantics and Inert Controls * chore: Remove Provider Protection Documentation * fix: Preserve Effective Provider Signal Safety Reasons --------- Co-authored-by: Lia <lia@librechat.ai>
1 parent bc5e34b commit 9226c0a

29 files changed

Lines changed: 3488 additions & 96 deletions

‎src/__tests__/stream.eagerArgsDivergence.test.ts‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
STREAM_LIMIT_EPOCH_KEY,
4343
StreamLimitExceededError,
4444
} from '@/llm/streamLimits';
45+
import { ProviderTextProtectionError } from '@/protection/providerText';
4546
import { GraphEvents, Providers, StepTypes } from '@/common';
4647
import * as eagerArgs from '@/tools/eagerEventExecution';
4748
import { ChatModelStreamHandler } from '@/stream';
@@ -664,14 +665,13 @@ describe('eager args divergence (LibreChat#14371)', () => {
664665
expect(toolExecuteCalls).toHaveLength(0);
665666
});
666667

667-
it('does not prestart eager tools when the breaker trips during tool-call handling', async () => {
668+
it.each([
669+
{ label: 'stream limit', trip: new StreamLimitExceededError({ kind: 'tool_call_args', limit: 10, observed: 11, toolName: 'db_query' }) },
670+
{ label: 'blocked', trip: new ProviderTextProtectionError('blocked') },
671+
{ label: 'timeout', trip: new ProviderTextProtectionError('timeout') },
672+
{ label: 'cancelled', trip: new ProviderTextProtectionError('cancelled') },
673+
])('does not prestart eager tools after an awaited $label trip', async ({ trip }) => {
668674
const graph = createGraph();
669-
const trip = new StreamLimitExceededError({
670-
kind: 'tool_call_args',
671-
limit: 10,
672-
observed: 11,
673-
toolName: 'db_query',
674-
});
675675
const originalDispatch = graph.dispatchRunStep;
676676
graph.dispatchRunStep = (async (
677677
stepKey: string,
@@ -712,6 +712,70 @@ describe('eager args divergence (LibreChat#14371)', () => {
712712
expect(toolExecuteCalls).toHaveLength(0);
713713
});
714714

715+
it.each(['blocked', 'timeout'] as const)('stops streamed seals when protection trips during awaited publication (%s)', async (code) => {
716+
const graph = createGraph();
717+
const trip = new ProviderTextProtectionError(code);
718+
const originalDispatch = graph.dispatchRunStep.bind(graph);
719+
graph.dispatchRunStep = async (key, details, metadata): Promise<string> => {
720+
const id = await originalDispatch(key, details, metadata);
721+
graph.breakerAbort.abort(trip);
722+
return id;
723+
};
724+
const { toolExecuteCalls } = installToolExecuteResponder();
725+
const chunk = new AIMessageChunk({ content: '', tool_call_chunks: [{ index: 0, id: 'sealed-call', name: 'db_query', args: '{"sql":"SELECT 1;"}' }], response_metadata: {
726+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]: BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
727+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'single', index: 0 },
728+
} });
729+
chunk.tool_calls = [];
730+
await expect(new ChatModelStreamHandler().handle(GraphEvents.CHAT_MODEL_STREAM, { chunk }, { langgraph_node: 'agent' }, graph)).rejects.toBe(trip);
731+
expect(toolExecuteCalls).toHaveLength(0);
732+
expect(graph.eagerEventToolExecutions.size).toBe(0);
733+
expect(graph.eagerEventToolCallChunks.size).toBe(1);
734+
});
735+
736+
it('drops already queued stream events after a protection trip', async () => {
737+
const graph = createGraph();
738+
const trip = new ProviderTextProtectionError('blocked');
739+
graph.breakerAbort.abort(trip);
740+
const { toolExecuteCalls } = installToolExecuteResponder();
741+
await expect(new ChatModelStreamHandler().handle(GraphEvents.CHAT_MODEL_STREAM, {
742+
chunk: new AIMessageChunk({ content: '', tool_calls: [{ id: 'queued-call', name: 'db_query', args: { sql: 'SELECT 1;' }, type: 'tool_call' }], response_metadata: { finish_reason: 'tool_calls' } }),
743+
}, { langgraph_node: 'agent' }, graph)).rejects.toBe(trip);
744+
expect(graph.dispatchRunStep).not.toHaveBeenCalled();
745+
expect(toolExecuteCalls).toHaveLength(0);
746+
});
747+
748+
it.each([
749+
{ signalSource: 'constructor', shape: 'parsed' },
750+
{ signalSource: 'constructor', shape: 'seal' },
751+
{ signalSource: 'call', shape: 'parsed' },
752+
{ signalSource: 'call', shape: 'seal' },
753+
])('honors inherited $signalSource trips after awaited $shape handling', async ({ signalSource, shape }) => {
754+
const parent = new AbortController();
755+
const graph = createGraph(signalSource === 'constructor' ? { signal: parent.signal } : {});
756+
if (signalSource === 'call') graph.config = { ...graph.config, signal: parent.signal };
757+
const trip = new ProviderTextProtectionError('blocked');
758+
const originalDispatch = graph.dispatchRunStep.bind(graph);
759+
graph.dispatchRunStep = async (key, details, metadata): Promise<string> => {
760+
const id = await originalDispatch(key, details, metadata);
761+
parent.abort(trip);
762+
graph.config = { ...graph.config, signal: new AbortController().signal };
763+
return id;
764+
};
765+
const { toolExecuteCalls } = installToolExecuteResponder();
766+
const chunk = shape === 'parsed'
767+
? new AIMessageChunk({ content: '', tool_calls: [{ id: 'inherited-call', name: 'db_query', args: { sql: 'SELECT 1;' }, type: 'tool_call' }], response_metadata: { finish_reason: 'tool_calls' } })
768+
: new AIMessageChunk({ content: '', tool_call_chunks: [{ index: 0, id: 'inherited-call', name: 'db_query', args: '{"sql":"SELECT 1;"}' }], response_metadata: {
769+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]: BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
770+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'single', index: 0 },
771+
} });
772+
if (shape === 'seal') chunk.tool_calls = [];
773+
await expect(new ChatModelStreamHandler().handle(GraphEvents.CHAT_MODEL_STREAM, { chunk }, { langgraph_node: 'agent' }, graph)).rejects.toBe(trip);
774+
expect(graph.breakerAbort.signal.aborted).toBe(false);
775+
expect(toolExecuteCalls).toHaveLength(0);
776+
expect(graph.eagerEventToolExecutions.size).toBe(0);
777+
});
778+
715779
it('sends a breaker-composed abort signal with eager prestart requests', async () => {
716780
const graph = createGraph();
717781
const { toolExecuteCalls } = installToolExecuteResponder();

‎src/agents/AgentContext.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
import { isTokenCounterCacheCompatible } from '@/llm/tokenCounterCacheCompatibility';
5959
import { snapshotCompactionSemanticIndex } from '@/summarization/semanticIndex';
6060
import { createExactTokenCountCache } from '@/llm/contextPressureMeter';
61+
import { markProviderTextInput } from '@/protection/providerTextInput';
6162
import { buildSummaryCarrierText } from '@/summarization/shared';
6263
import { createSchemaOnlyTools } from '@/tools/schema';
6364
import { apportionTokenCounts } from '@/utils/tokens';
@@ -960,7 +961,7 @@ export class AgentContext {
960961
: 0;
961962
}
962963

963-
return RunnableLambda.from((messages: BaseMessage[]) => {
964+
return markProviderTextInput(RunnableLambda.from((messages: BaseMessage[]) => {
964965
const prefix: BaseMessage[] = systemMessage ? [systemMessage] : [];
965966

966967
// Build the non-system portion (summary + conversation), then apply
@@ -998,7 +999,7 @@ export class AgentContext {
998999
);
9991000
}
10001001
return [...prefix, ...body];
1001-
}).withConfig({ runName: 'prompt' });
1002+
})).withConfig({ runName: 'prompt' });
10021003
}
10031004

10041005
private buildSummaryHumanMessage(

‎src/graphs/Graph.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ import {
131131
resolveLangfuseConfig,
132132
resolveToolOutputTracingConfig,
133133
} from '@/langfuseConfig';
134+
import {
135+
ProviderTextProtectionError,
136+
validateProviderTextProtection,
137+
} from '@/protection/providerText';
134138
import {
135139
annotateMessagesForLLM,
136140
ToolOutputReferenceRegistry,
@@ -1378,6 +1382,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
13781382
* these on every streamed chunk event.
13791383
*/
13801384
streamLimits: ResolvedStreamLimits;
1385+
/** Default-off mandatory provider prose release. */
1386+
providerTextProtection?: t.StandardGraphInput['providerTextProtection'];
13811387
/**
13821388
* Cumulative streamed argument bytes per in-flight tool call, keyed by
13831389
* generation key + chunk index (see `resolveGenerationKey`). Per-run
@@ -1423,25 +1429,27 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
14231429
* attempt lease; spans resets on purpose. */
14241430
activeStreamLimitGenerations?: Set<string>;
14251431

1426-
/** The stream-limit error behind an already-fired breaker, whether this
1432+
/** The safety error behind an already-fired breaker, whether this
14271433
* graph's own controller tripped or a parent run's breaker arrived through
14281434
* the composed constructor signal (child graphs own separate controllers).
14291435
* Providers can translate either abort into a generic error, and recovery
14301436
* paths must not run in that state. */
14311437
protected resolveTrippedBreakerReason(
14321438
breakerSignal: AbortSignal = this.breakerAbort.signal
1433-
): StreamLimitExceededError | PreparedSubagentError | undefined {
1439+
): StreamLimitExceededError | PreparedSubagentError | ProviderTextProtectionError | undefined {
14341440
if (
14351441
breakerSignal.aborted &&
14361442
(breakerSignal.reason instanceof StreamLimitExceededError ||
1437-
breakerSignal.reason instanceof PreparedSubagentError)
1443+
breakerSignal.reason instanceof PreparedSubagentError ||
1444+
breakerSignal.reason instanceof ProviderTextProtectionError)
14381445
) {
14391446
return breakerSignal.reason;
14401447
}
14411448
if (
14421449
this.signal?.aborted === true &&
14431450
(this.signal.reason instanceof StreamLimitExceededError ||
1444-
this.signal.reason instanceof PreparedSubagentError)
1451+
this.signal.reason instanceof PreparedSubagentError ||
1452+
this.signal.reason instanceof ProviderTextProtectionError)
14451453
) {
14461454
return this.signal.reason;
14471455
}
@@ -1551,6 +1559,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
15511559
onSubagentResolutionFailure,
15521560
preemption,
15531561
streamLimits,
1562+
providerTextProtection,
15541563
toolExecution,
15551564
clientDelegatedToolNames,
15561565
}: t.StandardGraphInput,
@@ -1580,6 +1589,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
15801589
this.onSubagentResolutionFailure = onSubagentResolutionFailure;
15811590
this.preemption = preemption;
15821591
this.streamLimits = resolveStreamLimits(streamLimits);
1592+
if (providerTextProtection != null)
1593+
validateProviderTextProtection(providerTextProtection);
1594+
this.providerTextProtection = providerTextProtection;
15831595
this.toolExecution = toolExecution;
15841596
if (clientDelegatedToolNames != null && clientDelegatedToolNames.length > 0) {
15851597
if (agents.length !== 1) {
@@ -4404,7 +4416,11 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
44044416
* succeeding fallback would resolve a run the public contract says
44054417
* must reject. Rethrow before any recovery path.
44064418
*/
4407-
if (primaryError instanceof InvalidModelToolCallError) {
4419+
if (
4420+
primaryError instanceof InvalidModelToolCallError ||
4421+
primaryError instanceof ProviderTextProtectionError
4422+
) {
4423+
if (primaryError instanceof ProviderTextProtectionError) attemptBreaker.abort(primaryError);
44084424
throw primaryError;
44094425
}
44104426
if (
@@ -4747,7 +4763,11 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
47474763
})
47484764
);
47494765
} catch (fallbackError) {
4750-
if (fallbackError instanceof InvalidModelToolCallError) {
4766+
if (
4767+
fallbackError instanceof InvalidModelToolCallError ||
4768+
fallbackError instanceof ProviderTextProtectionError
4769+
) {
4770+
if (fallbackError instanceof ProviderTextProtectionError) attemptBreaker.abort(fallbackError);
47514771
throw fallbackError;
47524772
}
47534773
if (
@@ -5347,6 +5367,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
53475367
subagentContext: this.subagentContext,
53485368
onResolutionFailure: this.onSubagentResolutionFailure,
53495369
streamLimits: this.streamLimits,
5370+
providerTextProtection: this.providerTextProtection,
53505371
humanInTheLoop: this.humanInTheLoop,
53515372
checkpointer: this.compileOptions?.checkpointer,
53525373
maxDepth: effectiveSubagentDepth,

‎src/graphs/__tests__/Graph.breakerLifecycle.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, jest } from '@jest/globals';
22
import type { RunnableConfig } from '@langchain/core/runnables';
33
import type * as t from '@/types';
4+
import { ProviderTextProtectionError } from '@/protection/providerText';
45
import { StreamLimitExceededError } from '@/llm/streamLimits';
56
import { StandardGraph } from '../Graph';
67
import { Providers } from '@/common';
@@ -261,6 +262,15 @@ describe('run breaker lifecycle', () => {
261262
).rejects.toBe(trip);
262263
});
263264

265+
it.each(['own', 'parent'])('rejects model entry after a protection trip in the %s breaker', async (scope) => {
266+
const controller = new AbortController();
267+
const trip = new ProviderTextProtectionError('blocked');
268+
const graph = new StandardGraph({ runId: 'protection-entry', agents: [makeAgent('agent')], signal: scope === 'parent' ? controller.signal : undefined });
269+
if (scope === 'own') graph.breakerAbort.abort(trip);
270+
else controller.abort(trip);
271+
await expect(graph.createCallModel('agent')({ messages: [] } as t.AgentSubgraphState, {})).rejects.toBe(trip);
272+
});
273+
264274
it('lets a model node proceed past entry when the breaker is live', async () => {
265275
const graph = makeGraph();
266276
const node = graph.createCallModel('agent');

‎src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,10 @@ export type {
121121
} from './llm/streamLimits';
122122
export * from './decisions';
123123
export * from './rerank';
124+
125+
export { PROVIDER_TEXT_PROTECTION_VERSION, ProviderTextProtectionError } from './protection/providerText';
126+
export type {
127+
ProviderTextProtection,
128+
ProviderTextProtectionResult,
129+
ProviderTextProtectionErrorCode,
130+
} from './protection/providerText';

0 commit comments

Comments
 (0)