@librechat/agents 3.3.4 → 3.3.6
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/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +456 -7
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +268 -3
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +51 -6
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +31 -3
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/format.cjs +72 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +3 -0
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +34 -8
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +3 -1
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +19 -16
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ReadFile.cjs +8 -4
- package/dist/cjs/tools/ReadFile.cjs.map +1 -1
- package/dist/cjs/tools/SkillTool.cjs +2 -0
- package/dist/cjs/tools/SkillTool.cjs.map +1 -1
- package/dist/cjs/tools/SubagentTool.cjs +3 -0
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +91 -50
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +5 -1
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/intentArg.cjs +230 -0
- package/dist/cjs/tools/intentArg.cjs.map +1 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
- package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/schema.cjs +2 -0
- package/dist/cjs/tools/search/schema.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +40 -2
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +455 -6
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +270 -6
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +51 -6
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +11 -7
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/format.mjs +72 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +3 -0
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/JsonlSessionStore.mjs +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +33 -9
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +3 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +19 -16
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ReadFile.mjs +8 -4
- package/dist/esm/tools/ReadFile.mjs.map +1 -1
- package/dist/esm/tools/SkillTool.mjs +2 -0
- package/dist/esm/tools/SkillTool.mjs.map +1 -1
- package/dist/esm/tools/SubagentTool.mjs +3 -0
- package/dist/esm/tools/SubagentTool.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +91 -51
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +5 -1
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/intentArg.mjs +220 -0
- package/dist/esm/tools/intentArg.mjs.map +1 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
- package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
- package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/schema.mjs +2 -0
- package/dist/esm/tools/search/schema.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +40 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +41 -7
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +138 -1
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +2 -1
- package/dist/types/llm/invoke.d.ts +7 -0
- package/dist/types/llm/openai/index.d.ts +0 -11
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/BashExecutor.d.ts +18 -0
- package/dist/types/tools/CodeExecutor.d.ts +18 -0
- package/dist/types/tools/ReadFile.d.ts +18 -0
- package/dist/types/tools/SkillTool.d.ts +18 -0
- package/dist/types/tools/SubagentTool.d.ts +9 -0
- package/dist/types/tools/ToolNode.d.ts +37 -12
- package/dist/types/tools/ToolSearch.d.ts +18 -0
- package/dist/types/tools/intentArg.d.ts +119 -0
- package/dist/types/tools/ptcTimeout.d.ts +2 -0
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/schema.d.ts +18 -0
- package/dist/types/tools/search/tool.d.ts +18 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/tools/toolOutputReferences.d.ts +12 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +12 -25
- package/dist/types/types/tools.d.ts +27 -2
- package/package.json +1 -1
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +568 -3
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +2 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
- package/src/llm/bedrock/utils/message_inputs.ts +14 -11
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.ts +417 -8
- package/src/llm/openai/index.ts +74 -10
- package/src/llm/openai/llm.spec.ts +105 -0
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/format.ts +91 -0
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +48 -9
- package/src/tools/BashExecutor.ts +6 -0
- package/src/tools/BashProgrammaticToolCalling.ts +2 -0
- package/src/tools/CodeExecutor.ts +6 -0
- package/src/tools/ProgrammaticToolCalling.ts +2 -0
- package/src/tools/ReadFile.ts +2 -0
- package/src/tools/SkillTool.ts +2 -0
- package/src/tools/SubagentTool.ts +3 -0
- package/src/tools/ToolNode.ts +193 -74
- package/src/tools/ToolSearch.ts +3 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
- package/src/tools/__tests__/intentArg.test.ts +281 -0
- package/src/tools/__tests__/intentCoverage.test.ts +139 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
- package/src/tools/intentArg.ts +323 -0
- package/src/tools/local/CompileCheckTool.ts +4 -3
- package/src/tools/local/LocalCodingTools.ts +14 -13
- package/src/tools/ptcTimeout.ts +2 -0
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/outcome.test.ts +90 -0
- package/src/tools/search/schema.ts +3 -0
- package/src/tools/search/tool.ts +60 -3
- package/src/tools/search/types.ts +50 -3
- package/src/tools/subagent/SubagentExecutor.ts +2 -1
- package/src/tools/toolOutputReferences.ts +77 -9
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +12 -41
- package/src/types/tools.ts +24 -2
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
package/src/run.ts
CHANGED
|
@@ -49,8 +49,14 @@ import {
|
|
|
49
49
|
createTitleRunnable,
|
|
50
50
|
} from '@/utils/title';
|
|
51
51
|
import { createTokenCounter, encodingForModel } from '@/utils/tokens';
|
|
52
|
+
import { resolveMaxSeals } from '@/llm/preempt';
|
|
52
53
|
import { initializeLangfuseTracing } from './instrumentation';
|
|
53
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
Callback,
|
|
56
|
+
GraphEvents,
|
|
57
|
+
TitleMethod,
|
|
58
|
+
DEFAULT_RECURSION_LIMIT,
|
|
59
|
+
} from '@/common';
|
|
54
60
|
import { MultiAgentGraph } from '@/graphs/MultiAgentGraph';
|
|
55
61
|
import { getTraceIdSeed } from '@/langfuseRuntimeContext';
|
|
56
62
|
import { StandardGraph } from '@/graphs/Graph';
|
|
@@ -140,6 +146,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
140
146
|
private interruptingToolNames?: string[];
|
|
141
147
|
private toolExecution?: t.ToolExecutionConfig;
|
|
142
148
|
private subagentUsageSink?: t.SubagentUsageSink;
|
|
149
|
+
private preemption?: t.StreamPreemption;
|
|
143
150
|
private indexTokenCountMap?: Record<string, number>;
|
|
144
151
|
calibrationRatio: number = 1;
|
|
145
152
|
graphRunnable?: t.CompiledStateWorkflow;
|
|
@@ -201,6 +208,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
201
208
|
this.interruptingToolNames = config.interruptingToolNames;
|
|
202
209
|
this.toolExecution = config.toolExecution;
|
|
203
210
|
this.subagentUsageSink = config.subagentUsageSink;
|
|
211
|
+
this.preemption = config.preemption;
|
|
204
212
|
|
|
205
213
|
if (!config.graphConfig) {
|
|
206
214
|
throw new Error('Graph config not provided');
|
|
@@ -275,6 +283,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
275
283
|
indexTokenCountMap: this.indexTokenCountMap,
|
|
276
284
|
calibrationRatio: this.calibrationRatio,
|
|
277
285
|
subagentUsageSink: this.subagentUsageSink,
|
|
286
|
+
preemption: this.preemption,
|
|
278
287
|
});
|
|
279
288
|
/** Propagate compile options from graph config */
|
|
280
289
|
standardGraph.compileOptions = this.applyHITLCheckpointerFallback(
|
|
@@ -306,6 +315,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
306
315
|
indexTokenCountMap: this.indexTokenCountMap,
|
|
307
316
|
calibrationRatio: this.calibrationRatio,
|
|
308
317
|
subagentUsageSink: this.subagentUsageSink,
|
|
318
|
+
preemption: this.preemption,
|
|
309
319
|
});
|
|
310
320
|
|
|
311
321
|
multiAgentGraph.compileOptions =
|
|
@@ -541,6 +551,15 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
541
551
|
return this.Graph?.getResolvedInstructionOverhead();
|
|
542
552
|
}
|
|
543
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Cooperative-seal counters for this run. `emptyBoundaries` is the one to
|
|
556
|
+
* watch: it counts seals whose `PreemptBoundary` produced nothing to
|
|
557
|
+
* inject, which ends the turn early and leaves the answer unfinished.
|
|
558
|
+
*/
|
|
559
|
+
getPreemptStats(): t.PreemptStats {
|
|
560
|
+
return this.Graph?.getPreemptStats() ?? { seals: 0, emptyBoundaries: 0 };
|
|
561
|
+
}
|
|
562
|
+
|
|
544
563
|
getToolCount(): number {
|
|
545
564
|
return this.Graph?.getToolCount() ?? 0;
|
|
546
565
|
}
|
|
@@ -703,12 +722,38 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
703
722
|
const isResume = inputs instanceof Command;
|
|
704
723
|
const stateInputs = isResume ? undefined : (inputs as t.IState);
|
|
705
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Every honored seal costs one extra superstep, so a preemption-enabled
|
|
727
|
+
* run reserves headroom for its whole seal budget. Without it, a
|
|
728
|
+
* tool-heavy agent that gets preempted could hit `GraphRecursionError` —
|
|
729
|
+
* which surfaces as a thrown stream, setting `streamThrew`, firing
|
|
730
|
+
* `StopFailure`, and wiping via `clearHeavyState()` exactly the partial
|
|
731
|
+
* content the seal existed to preserve.
|
|
732
|
+
*/
|
|
733
|
+
const recursionLimit =
|
|
734
|
+
(callerConfig.recursionLimit ?? DEFAULT_RECURSION_LIMIT) +
|
|
735
|
+
(this.preemption != null ? resolveMaxSeals(this.preemption.maxSeals) : 0);
|
|
736
|
+
|
|
706
737
|
const config: t.RunStreamConfig = {
|
|
707
|
-
recursionLimit: 50,
|
|
708
738
|
...callerConfig,
|
|
739
|
+
recursionLimit,
|
|
709
740
|
configurable: { ...callerConfig.configurable },
|
|
710
741
|
};
|
|
711
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Cancellation can arrive either at graph construction or per-call through
|
|
745
|
+
* `callerConfig.signal`, and boundary hooks need to observe both — for a
|
|
746
|
+
* multi-agent run the construction signal does not exist at all, since
|
|
747
|
+
* `MultiAgentGraphConfig` exposes none. Carried on its own field, assigned
|
|
748
|
+
* unconditionally: writing into `graph.signal` would leak this call's
|
|
749
|
+
* controller into later calls (model-call config and subagent
|
|
750
|
+
* parentSignal read that field, and `clearHeavyState()` is skipped on
|
|
751
|
+
* HITL interrupts), while a conditional write would keep observing a
|
|
752
|
+
* stale controller the host has since aborted. The boundary dispatch
|
|
753
|
+
* composes both channels; see `dispatchPreemptBoundary`.
|
|
754
|
+
*/
|
|
755
|
+
graph.callerSignal = callerConfig.signal;
|
|
756
|
+
|
|
712
757
|
/**
|
|
713
758
|
* Skip `resetValues` on resume — we're continuing an in-flight
|
|
714
759
|
* run, not starting a fresh one. Resetting would wipe the
|
|
@@ -923,10 +968,17 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
923
968
|
* graph doesn't take another model turn after the halting
|
|
924
969
|
* operation completes.
|
|
925
970
|
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
971
|
+
* This `break` is NOT graceful, despite what a `continue: false`
|
|
972
|
+
* reading suggests. Leaving the `for await` calls the iterator's
|
|
973
|
+
* `return()`, which cancels the reader
|
|
974
|
+
* (`@langchain/core/utils/stream`), and langgraph's stream wrapper
|
|
975
|
+
* turns that cancel into `_abortController.abort()`
|
|
976
|
+
* (`pregel/stream.js`). The in-flight model call or tool batch is
|
|
977
|
+
* torn down where it stands — it does not finish first.
|
|
978
|
+
*
|
|
979
|
+
* A halt is therefore the wrong tool for "stop generating but keep
|
|
980
|
+
* what you have". That is what `RunConfig.preemption` is for: it
|
|
981
|
+
* seals the stream at a provider-safe boundary and keeps the run.
|
|
930
982
|
*/
|
|
931
983
|
const haltSignal = this.hookRegistry?.getHaltSignal(this.id);
|
|
932
984
|
if (haltSignal != null) {
|
|
@@ -959,6 +1011,16 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
959
1011
|
threadId,
|
|
960
1012
|
agentId: graph.defaultAgentId,
|
|
961
1013
|
messages: graph.getRunMessages() ?? stateInputs?.messages ?? [],
|
|
1014
|
+
/**
|
|
1015
|
+
* A seal whose boundary ended the turn early must say so. The
|
|
1016
|
+
* hook-supplied reason wins when a `PreemptBoundary` hook halted
|
|
1017
|
+
* with one — a persistence/audit `Stop` hook should record the
|
|
1018
|
+
* actual cause, not the generic label — and `preempt_incomplete`
|
|
1019
|
+
* is reserved for the boundary that simply had nothing to inject.
|
|
1020
|
+
*/
|
|
1021
|
+
stopReason:
|
|
1022
|
+
graph.preemptHaltReason ??
|
|
1023
|
+
(graph.preemptIncomplete ? 'preempt_incomplete' : undefined),
|
|
962
1024
|
stopHookActive: false, // will be true when stop is triggered by a hook (Phase 2)
|
|
963
1025
|
},
|
|
964
1026
|
sessionId: this.id,
|
|
@@ -966,6 +1028,29 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
966
1028
|
/* Stop hook errors must not masquerade as stream failures */
|
|
967
1029
|
});
|
|
968
1030
|
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* A `PreemptBoundary` hook that returned `preventContinuation` has its
|
|
1034
|
+
* registry halt cleared by the graph — that is what stops the halt from
|
|
1035
|
+
* cancelling the stream before the sealed turn commits — so the reason
|
|
1036
|
+
* is carried across on the graph instead. Surfaced here, AFTER the
|
|
1037
|
+
* `Stop` dispatch above, so the host still receives a completion signal
|
|
1038
|
+
* to persist the partial answer with while `getHaltReason()` correctly
|
|
1039
|
+
* reports that a hook stopped the run rather than the model finishing.
|
|
1040
|
+
*
|
|
1041
|
+
* An empty boundary — sealed, but nothing to inject because the host's
|
|
1042
|
+
* queue was drained or cancelled in the meantime — cut the answer short
|
|
1043
|
+
* just as surely, only without a hook-supplied reason. It surfaces
|
|
1044
|
+
* through the same channel under the same name the `Stop` dispatch
|
|
1045
|
+
* already used for its `stopReason`, so terminal consumers
|
|
1046
|
+
* (`AgentSession` emits `run.halted`, not `run.completed`) cannot
|
|
1047
|
+
* finalize a truncated answer as a natural finish.
|
|
1048
|
+
*/
|
|
1049
|
+
if (this._haltedReason == null && graph.preemptHaltReason != null) {
|
|
1050
|
+
this._haltedReason = graph.preemptHaltReason;
|
|
1051
|
+
} else if (this._haltedReason == null && graph.preemptIncomplete) {
|
|
1052
|
+
this._haltedReason = 'preempt_incomplete';
|
|
1053
|
+
}
|
|
969
1054
|
};
|
|
970
1055
|
|
|
971
1056
|
try {
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/scripts/preempt-probe.ts
|
|
2
|
+
//
|
|
3
|
+
// Live-provider probe for cooperative mid-generation preemption.
|
|
4
|
+
//
|
|
5
|
+
// npx tsx --env-file=.env src/scripts/preempt-probe.ts --provider anthropic
|
|
6
|
+
//
|
|
7
|
+
// (tsx rather than the repo's node-loader `script` runner: the import chain
|
|
8
|
+
// reaches @mistralai/mistralai, which ships ESM-only and defeats the loader.
|
|
9
|
+
// Keep live credentials OUT of the worktree's .env — jest loads it — and
|
|
10
|
+
// point --env-file at wherever they actually live.)
|
|
11
|
+
//
|
|
12
|
+
// Asks for a long, tool-free answer, arms `shouldPreempt` once text has
|
|
13
|
+
// started streaming, and asserts the full seal -> PreemptBoundary -> inject ->
|
|
14
|
+
// self-loop path against a real provider: the stream stops mid-answer, the
|
|
15
|
+
// injected user turn lands between two assistant turns, generation resumes in
|
|
16
|
+
// the same run, and CHAT_MODEL_END fires for the sealed turn so usage is still
|
|
17
|
+
// recorded. Prints a JSON verdict block as its last line.
|
|
18
|
+
import { config } from 'dotenv';
|
|
19
|
+
config();
|
|
20
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
21
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
22
|
+
import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
|
|
23
|
+
import type * as t from '@/types';
|
|
24
|
+
import { GraphEvents, Providers } from '@/common';
|
|
25
|
+
import { createTokenCounter } from '@/utils/tokens';
|
|
26
|
+
import { getLLMConfig } from '@/utils/llmConfig';
|
|
27
|
+
import { HookRegistry } from '@/hooks';
|
|
28
|
+
import { Run } from '@/run';
|
|
29
|
+
|
|
30
|
+
/** Deltas to let through before arming the seal. */
|
|
31
|
+
const ARM_AFTER_DELTAS = 15;
|
|
32
|
+
|
|
33
|
+
const PROMPT =
|
|
34
|
+
'Write a detailed history of the Byzantine Empire from Constantine to ' +
|
|
35
|
+
'1453. Cover the major emperors, the religious schisms, and the military ' +
|
|
36
|
+
'campaigns. Aim for at least 800 words of flowing prose. Do not use ' +
|
|
37
|
+
'headings or bullet points.';
|
|
38
|
+
|
|
39
|
+
const STEER =
|
|
40
|
+
'Stop. Forget the essay. Answer only this, in one short sentence: what ' +
|
|
41
|
+
'year did Constantinople fall?';
|
|
42
|
+
|
|
43
|
+
type Verdict = {
|
|
44
|
+
provider: string;
|
|
45
|
+
model: string;
|
|
46
|
+
ok: boolean;
|
|
47
|
+
sealed: boolean;
|
|
48
|
+
seals: number;
|
|
49
|
+
emptyBoundaries: number;
|
|
50
|
+
boundaryFired: boolean;
|
|
51
|
+
boundarySealCount: number | null;
|
|
52
|
+
modelEndEvents: number;
|
|
53
|
+
usageEventsWithTokens: number;
|
|
54
|
+
chatModelStarts: number;
|
|
55
|
+
llmEnds: number;
|
|
56
|
+
llmErrors: number;
|
|
57
|
+
runsLeftOpen: number;
|
|
58
|
+
sealedTextChars: number;
|
|
59
|
+
resumedTextChars: number;
|
|
60
|
+
sequence: string[];
|
|
61
|
+
injectedFound: boolean;
|
|
62
|
+
resumeAnswersSteer: boolean;
|
|
63
|
+
error: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Streamed assistant turns arrive as `AIMessageChunk`, which extends
|
|
68
|
+
* `BaseMessageChunk` rather than `AIMessage` — `instanceof AIMessage` is
|
|
69
|
+
* false for them. Type discrimination has to go through `getType()`.
|
|
70
|
+
*/
|
|
71
|
+
function isAssistant(message: BaseMessage | undefined): boolean {
|
|
72
|
+
return message?.getType() === 'ai';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function messageKind(message: BaseMessage): string {
|
|
76
|
+
if (message.getType() === 'human') {
|
|
77
|
+
const source = message.additional_kwargs.source;
|
|
78
|
+
return typeof source === 'string' ? `human(${source})` : 'human';
|
|
79
|
+
}
|
|
80
|
+
return message.getType();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function textOf(message: BaseMessage | undefined): string {
|
|
84
|
+
if (message == null) {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
if (typeof message.content === 'string') {
|
|
88
|
+
return message.content;
|
|
89
|
+
}
|
|
90
|
+
let text = '';
|
|
91
|
+
for (const block of message.content) {
|
|
92
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
93
|
+
text += block.text;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseProvider(): string {
|
|
100
|
+
const index = process.argv.indexOf('--provider');
|
|
101
|
+
if (index !== -1 && process.argv[index + 1] != null) {
|
|
102
|
+
return process.argv[index + 1];
|
|
103
|
+
}
|
|
104
|
+
return Providers.ANTHROPIC;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Control mode: identical run with no `preemption` config at all. */
|
|
108
|
+
function isControl(): boolean {
|
|
109
|
+
return process.argv.includes('--control');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function probe(): Promise<Verdict> {
|
|
113
|
+
const providerKey = parseProvider();
|
|
114
|
+
const llmConfig = getLLMConfig(providerKey);
|
|
115
|
+
if (llmConfig == null) {
|
|
116
|
+
throw new Error(`No llmConfig entry for provider "${providerKey}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let armed = false;
|
|
120
|
+
let armedOnce = false;
|
|
121
|
+
let deltaCount = 0;
|
|
122
|
+
let modelEndEvents = 0;
|
|
123
|
+
let usageEventsWithTokens = 0;
|
|
124
|
+
let chatModelStarts = 0;
|
|
125
|
+
let llmEnds = 0;
|
|
126
|
+
let llmErrors = 0;
|
|
127
|
+
let boundaryFired = false;
|
|
128
|
+
let boundarySealCount: number | null = null;
|
|
129
|
+
let injectedOnce = false;
|
|
130
|
+
const collectedUsage: UsageMetadata[] = [];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deliberately NOT a `ChatModelStreamHandler` instance: that would make
|
|
134
|
+
* `getRegisteredDefaultChatStreamHandler` return a handler and route the run
|
|
135
|
+
* down the registered-handler loop, which never seals. Plain-object handlers
|
|
136
|
+
* mirror how LibreChat registers, which is the sealable path.
|
|
137
|
+
*/
|
|
138
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
139
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
140
|
+
handle: (): void => {
|
|
141
|
+
deltaCount += 1;
|
|
142
|
+
if (!armedOnce && deltaCount >= ARM_AFTER_DELTAS) {
|
|
143
|
+
armed = true;
|
|
144
|
+
armedOnce = true;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
[GraphEvents.CHAT_MODEL_END]: {
|
|
149
|
+
handle: (_event: string, data: t.ModelEndData): void => {
|
|
150
|
+
modelEndEvents += 1;
|
|
151
|
+
const usage = data?.output?.usage_metadata;
|
|
152
|
+
if (usage != null) {
|
|
153
|
+
collectedUsage.push(usage);
|
|
154
|
+
if ((usage.output_tokens ?? 0) > 0) {
|
|
155
|
+
usageEventsWithTokens += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hooks = new HookRegistry();
|
|
163
|
+
hooks.register('PreemptBoundary', {
|
|
164
|
+
hooks: [
|
|
165
|
+
async (input) => {
|
|
166
|
+
boundaryFired = true;
|
|
167
|
+
boundarySealCount = input.sealCount;
|
|
168
|
+
/**
|
|
169
|
+
* Disarm on drain. The SDK polls `shouldPreempt` on every chunk and
|
|
170
|
+
* does not clear it for you — a host that leaves the request set
|
|
171
|
+
* armed re-seals the resumed turn immediately. LibreChat's real drain
|
|
172
|
+
* does this via `noteSteersRemoved`.
|
|
173
|
+
*/
|
|
174
|
+
armed = false;
|
|
175
|
+
if (injectedOnce) {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
injectedOnce = true;
|
|
179
|
+
return {
|
|
180
|
+
injectedMessages: [
|
|
181
|
+
{ role: 'user', content: STEER, source: 'steer' },
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A real host configures this. It is what `dispatchSealedModelEnd` falls
|
|
190
|
+
* back to when the provider never got to send its usage chunk, so running
|
|
191
|
+
* without one hides whether sealed-turn usage is recoverable. Pass
|
|
192
|
+
* `--no-token-counter` to probe the unconfigured case deliberately.
|
|
193
|
+
*/
|
|
194
|
+
const tokenCounter = process.argv.includes('--no-token-counter')
|
|
195
|
+
? undefined
|
|
196
|
+
: await createTokenCounter();
|
|
197
|
+
|
|
198
|
+
const run = await Run.create<t.IState>({
|
|
199
|
+
runId: uuidv4(),
|
|
200
|
+
graphConfig: {
|
|
201
|
+
type: 'standard',
|
|
202
|
+
llmConfig,
|
|
203
|
+
instructions: 'You are a knowledgeable history assistant.',
|
|
204
|
+
},
|
|
205
|
+
tokenCounter,
|
|
206
|
+
customHandlers,
|
|
207
|
+
hooks,
|
|
208
|
+
...(isControl()
|
|
209
|
+
? {}
|
|
210
|
+
: {
|
|
211
|
+
preemption: {
|
|
212
|
+
shouldPreempt: (): boolean => armed,
|
|
213
|
+
maxSeals: 2,
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
returnContent: true,
|
|
217
|
+
skipCleanup: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Counts the RAW LangChain model-run lifecycle, which is what a tracer
|
|
222
|
+
* (LangSmith, Langfuse) sees. A seal that fails to close its run shows up
|
|
223
|
+
* here as starts > ends — an span that would hang open forever.
|
|
224
|
+
*/
|
|
225
|
+
const streamConfig = {
|
|
226
|
+
runId: uuidv4(),
|
|
227
|
+
configurable: { user_id: 'probe-user', thread_id: 'preempt-probe' },
|
|
228
|
+
streamMode: 'values',
|
|
229
|
+
version: 'v2' as const,
|
|
230
|
+
callbacks: [
|
|
231
|
+
{
|
|
232
|
+
handleChatModelStart: (): void => {
|
|
233
|
+
chatModelStarts += 1;
|
|
234
|
+
},
|
|
235
|
+
handleLLMEnd: (): void => {
|
|
236
|
+
llmEnds += 1;
|
|
237
|
+
},
|
|
238
|
+
handleLLMError: (): void => {
|
|
239
|
+
llmErrors += 1;
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
await run.processStream(
|
|
246
|
+
{ messages: [new HumanMessage(PROMPT)] },
|
|
247
|
+
streamConfig
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const messages = run.getRunMessages() ?? [];
|
|
251
|
+
const sequence = messages.map(messageKind);
|
|
252
|
+
const injectedIndex = messages.findIndex(
|
|
253
|
+
(message) =>
|
|
254
|
+
message instanceof HumanMessage &&
|
|
255
|
+
message.additional_kwargs.source === 'steer'
|
|
256
|
+
);
|
|
257
|
+
const stats = run.getPreemptStats();
|
|
258
|
+
const sealedText = textOf(messages[injectedIndex - 1]);
|
|
259
|
+
const resumedText = textOf(messages[injectedIndex + 1]);
|
|
260
|
+
const resumeAnswersSteer = /1453/.test(resumedText);
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Control mode is the no-preemption baseline: one uninterrupted model run,
|
|
264
|
+
* nothing sealed, nothing injected. Its value is the contrast — the same
|
|
265
|
+
* lifecycle counters must balance WITHOUT the seal machinery — so it gets
|
|
266
|
+
* its own criteria instead of failing the seal-path ones by construction.
|
|
267
|
+
*/
|
|
268
|
+
const ok = isControl()
|
|
269
|
+
? stats.seals === 0 &&
|
|
270
|
+
!boundaryFired &&
|
|
271
|
+
injectedIndex === -1 &&
|
|
272
|
+
messages.length > 0 &&
|
|
273
|
+
textOf(messages[messages.length - 1]).trim().length > 0 &&
|
|
274
|
+
modelEndEvents >= 1 &&
|
|
275
|
+
usageEventsWithTokens >= 1 &&
|
|
276
|
+
llmErrors === 0 &&
|
|
277
|
+
chatModelStarts === llmEnds
|
|
278
|
+
: stats.seals === 1 &&
|
|
279
|
+
stats.emptyBoundaries === 0 &&
|
|
280
|
+
boundaryFired &&
|
|
281
|
+
injectedIndex > 0 &&
|
|
282
|
+
isAssistant(messages[injectedIndex - 1]) &&
|
|
283
|
+
isAssistant(messages[injectedIndex + 1]) &&
|
|
284
|
+
sealedText.trim().length > 0 &&
|
|
285
|
+
resumedText.trim().length > 0 &&
|
|
286
|
+
modelEndEvents >= 2 &&
|
|
287
|
+
usageEventsWithTokens >= 2 &&
|
|
288
|
+
chatModelStarts === llmEnds;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
provider: providerKey,
|
|
292
|
+
model: String((llmConfig as { model?: unknown }).model ?? 'unknown'),
|
|
293
|
+
ok,
|
|
294
|
+
sealed: stats.seals > 0,
|
|
295
|
+
seals: stats.seals,
|
|
296
|
+
emptyBoundaries: stats.emptyBoundaries,
|
|
297
|
+
boundaryFired,
|
|
298
|
+
boundarySealCount,
|
|
299
|
+
modelEndEvents,
|
|
300
|
+
usageEventsWithTokens,
|
|
301
|
+
chatModelStarts,
|
|
302
|
+
llmEnds,
|
|
303
|
+
llmErrors,
|
|
304
|
+
runsLeftOpen: chatModelStarts - llmEnds,
|
|
305
|
+
sealedTextChars: sealedText.length,
|
|
306
|
+
resumedTextChars: resumedText.length,
|
|
307
|
+
sequence,
|
|
308
|
+
injectedFound: injectedIndex >= 0,
|
|
309
|
+
resumeAnswersSteer,
|
|
310
|
+
error: null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
probe()
|
|
315
|
+
.then((verdict) => {
|
|
316
|
+
console.log('\n===== SEALED TAIL / RESUMED HEAD =====');
|
|
317
|
+
console.log(JSON.stringify(verdict, null, 2));
|
|
318
|
+
console.log(`\nVERDICT_JSON ${JSON.stringify(verdict)}`);
|
|
319
|
+
process.exit(verdict.ok ? 0 : 1);
|
|
320
|
+
})
|
|
321
|
+
.catch((error: unknown) => {
|
|
322
|
+
const verdict: Partial<Verdict> = {
|
|
323
|
+
provider: parseProvider(),
|
|
324
|
+
ok: false,
|
|
325
|
+
error: error instanceof Error ? error.message : String(error),
|
|
326
|
+
};
|
|
327
|
+
console.error(error);
|
|
328
|
+
console.log(`\nVERDICT_JSON ${JSON.stringify(verdict)}`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
});
|