@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
|
@@ -1059,13 +1059,24 @@ describe('context overflow recovery', () => {
|
|
|
1059
1059
|
const humanMessages = model.calls[0].filter(
|
|
1060
1060
|
(message) => message instanceof HumanMessage
|
|
1061
1061
|
);
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1062
|
+
/**
|
|
1063
|
+
* Bedrock is a strict-alternation provider: after compaction shrinks the
|
|
1064
|
+
* synthetic context, `coalesceAdjacentUserTurns` merges it into the
|
|
1065
|
+
* adjacent user query — Converse rejects consecutive user turns, and the
|
|
1066
|
+
* `@langchain/aws` converter only merges toolResult-bearing ones. So one
|
|
1067
|
+
* user turn reaches the provider, carrying both the query and the
|
|
1068
|
+
* compacted note, and it must still be small.
|
|
1069
|
+
*/
|
|
1070
|
+
expect(humanMessages).toHaveLength(1);
|
|
1071
|
+
const coalescedContent = JSON.stringify(humanMessages[0].content);
|
|
1072
|
+
expect(coalescedContent).toContain('query the table');
|
|
1073
|
+
/**
|
|
1074
|
+
* No closing bracket: compaction truncates at a character budget, and the
|
|
1075
|
+
* cut can land inside the `[Previous tool interaction]` header itself.
|
|
1076
|
+
*/
|
|
1077
|
+
expect(coalescedContent).toContain('[Previous tool interaction');
|
|
1078
|
+
expect(coalescedContent.length).toBeLessThan(150);
|
|
1079
|
+
expect(coalescedContent).not.toContain('x'.repeat(1_000));
|
|
1069
1080
|
});
|
|
1070
1081
|
|
|
1071
1082
|
it('counts unresolved-reference annotations before invoking the provider', async () => {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/graphs/__tests__/Graph.preemptSignal.test.ts
|
|
2
|
+
import type * as t from '@/types';
|
|
3
|
+
import { Providers } from '@/common';
|
|
4
|
+
import { HookRegistry } from '@/hooks/HookRegistry';
|
|
5
|
+
import { StandardGraph } from '../Graph';
|
|
6
|
+
|
|
7
|
+
const makeAgent = (agentId: string): t.AgentInputs => ({
|
|
8
|
+
agentId,
|
|
9
|
+
provider: Providers.OPENAI,
|
|
10
|
+
instructions: `You are ${agentId}.`,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
type DispatchResult = { messages: unknown[]; preventContinuation: boolean };
|
|
14
|
+
|
|
15
|
+
const dispatchBoundary = (
|
|
16
|
+
graph: StandardGraph,
|
|
17
|
+
agentId = 'agent'
|
|
18
|
+
): Promise<DispatchResult> =>
|
|
19
|
+
(
|
|
20
|
+
graph as unknown as {
|
|
21
|
+
dispatchPreemptBoundary: (
|
|
22
|
+
agentId: string,
|
|
23
|
+
config: { configurable: { run_id: string } } | undefined
|
|
24
|
+
) => Promise<DispatchResult>;
|
|
25
|
+
}
|
|
26
|
+
).dispatchPreemptBoundary(agentId, {
|
|
27
|
+
configurable: { run_id: 'run_1' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registers a PreemptBoundary hook that aborts `controller` from inside its
|
|
32
|
+
* own body, then reports whether the signal `executeHooks` handed it observed
|
|
33
|
+
* that abort. Racing against a short timer keeps a broken composition from
|
|
34
|
+
* stalling the suite for the 120-second boundary hook timeout.
|
|
35
|
+
*/
|
|
36
|
+
function registerAbortProbe(
|
|
37
|
+
registry: HookRegistry,
|
|
38
|
+
controller: AbortController
|
|
39
|
+
): { outcome: () => string | undefined } {
|
|
40
|
+
let outcome: string | undefined;
|
|
41
|
+
registry.register('PreemptBoundary', {
|
|
42
|
+
hooks: [
|
|
43
|
+
async (_input, signal): Promise<Record<string, never>> => {
|
|
44
|
+
const aborted = new Promise<string>((resolve) => {
|
|
45
|
+
if (signal.aborted) {
|
|
46
|
+
resolve('aborted');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
signal.addEventListener('abort', () => resolve('aborted'), {
|
|
50
|
+
once: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const timer = new Promise<string>((resolve) =>
|
|
54
|
+
setTimeout(() => resolve('not-observed'), 200)
|
|
55
|
+
);
|
|
56
|
+
controller.abort();
|
|
57
|
+
outcome = await Promise.race([aborted, timer]);
|
|
58
|
+
return {};
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
return { outcome: () => outcome };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('PreemptBoundary abort-signal composition', () => {
|
|
66
|
+
it('observes a per-call caller abort even when a construction signal exists', async () => {
|
|
67
|
+
const constructionController = new AbortController();
|
|
68
|
+
const callerController = new AbortController();
|
|
69
|
+
const graph = new StandardGraph({
|
|
70
|
+
runId: 'run_1',
|
|
71
|
+
signal: constructionController.signal,
|
|
72
|
+
agents: [makeAgent('agent')],
|
|
73
|
+
});
|
|
74
|
+
const registry = new HookRegistry();
|
|
75
|
+
const probe = registerAbortProbe(registry, callerController);
|
|
76
|
+
graph.hookRegistry = registry;
|
|
77
|
+
graph.callerSignal = callerController.signal;
|
|
78
|
+
|
|
79
|
+
await dispatchBoundary(graph);
|
|
80
|
+
|
|
81
|
+
expect(probe.outcome()).toBe('aborted');
|
|
82
|
+
expect(constructionController.signal.aborted).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('observes the caller abort when no construction signal exists (multi-agent shape)', async () => {
|
|
86
|
+
const callerController = new AbortController();
|
|
87
|
+
const graph = new StandardGraph({
|
|
88
|
+
runId: 'run_1',
|
|
89
|
+
agents: [makeAgent('agent')],
|
|
90
|
+
});
|
|
91
|
+
const registry = new HookRegistry();
|
|
92
|
+
const probe = registerAbortProbe(registry, callerController);
|
|
93
|
+
graph.hookRegistry = registry;
|
|
94
|
+
graph.callerSignal = callerController.signal;
|
|
95
|
+
|
|
96
|
+
await dispatchBoundary(graph);
|
|
97
|
+
|
|
98
|
+
expect(probe.outcome()).toBe('aborted');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('still observes the construction signal when no caller signal was supplied', async () => {
|
|
102
|
+
const constructionController = new AbortController();
|
|
103
|
+
const graph = new StandardGraph({
|
|
104
|
+
runId: 'run_1',
|
|
105
|
+
signal: constructionController.signal,
|
|
106
|
+
agents: [makeAgent('agent')],
|
|
107
|
+
});
|
|
108
|
+
const registry = new HookRegistry();
|
|
109
|
+
const probe = registerAbortProbe(registry, constructionController);
|
|
110
|
+
graph.hookRegistry = registry;
|
|
111
|
+
|
|
112
|
+
await dispatchBoundary(graph);
|
|
113
|
+
|
|
114
|
+
expect(probe.outcome()).toBe('aborted');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('drops the caller signal reference on clearHeavyState', () => {
|
|
118
|
+
const graph = new StandardGraph({
|
|
119
|
+
runId: 'run_1',
|
|
120
|
+
agents: [makeAgent('agent')],
|
|
121
|
+
});
|
|
122
|
+
graph.callerSignal = new AbortController().signal;
|
|
123
|
+
graph.clearHeavyState();
|
|
124
|
+
expect(graph.callerSignal).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -247,6 +247,34 @@ export class HookRegistry {
|
|
|
247
247
|
return readList(bucket, event).length > 0;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/**
|
|
251
|
+
* True when at least one matcher for `event` would actually DISPATCH on a
|
|
252
|
+
* query-less call — a wildcard pattern with a non-empty `hooks` array.
|
|
253
|
+
*
|
|
254
|
+
* `hasHookFor` answers "is one registered", which is not the same question:
|
|
255
|
+
* a matcher carrying a pattern is inert for events that supply no
|
|
256
|
+
* `matchQuery`, so a caller using registration as a proxy for "something
|
|
257
|
+
* will run" can act on a hook that never fires. Mirrors the two skips in
|
|
258
|
+
* `executeHooks` (pattern mismatch, empty `hooks`).
|
|
259
|
+
*
|
|
260
|
+
* Non-allocating on purpose — `StandardGraph.canClaimPreemptSeal` reads it
|
|
261
|
+
* once per streamed chunk, where `getMatchers`' defensive `slice()` would
|
|
262
|
+
* allocate on every delta.
|
|
263
|
+
*/
|
|
264
|
+
hasDispatchableHookFor(event: HookEvent, sessionId?: string): boolean {
|
|
265
|
+
if (hasDispatchableInList(readList(this.global, event))) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (sessionId === undefined) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
const bucket = this.sessions.get(sessionId);
|
|
272
|
+
if (bucket === undefined) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
return hasDispatchableInList(readList(bucket, event));
|
|
276
|
+
}
|
|
277
|
+
|
|
250
278
|
private ensureSessionBucket(sessionId: string): MatcherBucket {
|
|
251
279
|
const existing = this.sessions.get(sessionId);
|
|
252
280
|
if (existing !== undefined) {
|
|
@@ -278,6 +306,18 @@ function readList(
|
|
|
278
306
|
return bucket[event] ?? [];
|
|
279
307
|
}
|
|
280
308
|
|
|
309
|
+
function hasDispatchableInList(list: HookMatcher<HookEvent>[]): boolean {
|
|
310
|
+
for (const matcher of list) {
|
|
311
|
+
if (
|
|
312
|
+
(matcher.pattern === undefined || matcher.pattern === '') &&
|
|
313
|
+
matcher.hooks.length > 0
|
|
314
|
+
) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
281
321
|
function hasResultAlteringInBucket(bucket: MatcherBucket): boolean {
|
|
282
322
|
for (const event of RESULT_ALTERING_HOOK_EVENTS) {
|
|
283
323
|
if (readList(bucket, event).length > 0) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/hooks/__tests__/preemptBoundary.test.ts
|
|
2
|
+
import type { HookMatcher, HookCallback, HookOutput } from '../types';
|
|
3
|
+
import { HOOK_EVENTS } from '../types';
|
|
4
|
+
import { HookRegistry } from '../HookRegistry';
|
|
5
|
+
import { executeHooks } from '../executeHooks';
|
|
6
|
+
import {
|
|
7
|
+
HOOK_PREEMPT_BOUNDARY_CAPABLE,
|
|
8
|
+
HOOK_INJECTED_MESSAGES_CAPABLE,
|
|
9
|
+
} from '../index';
|
|
10
|
+
|
|
11
|
+
const noop: HookCallback<
|
|
12
|
+
'PreemptBoundary'
|
|
13
|
+
> = async (): Promise<HookOutput> => ({});
|
|
14
|
+
|
|
15
|
+
function makeMatcher(
|
|
16
|
+
hook: HookCallback<'PreemptBoundary'> = noop
|
|
17
|
+
): HookMatcher<'PreemptBoundary'> {
|
|
18
|
+
return { hooks: [hook] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('PreemptBoundary hook event', () => {
|
|
22
|
+
it('is part of the closed event set', () => {
|
|
23
|
+
expect(HOOK_EVENTS).toContain('PreemptBoundary');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('advertises its own capability flag, separate from injectedMessages', () => {
|
|
27
|
+
expect(HOOK_PREEMPT_BOUNDARY_CAPABLE).toBe(true);
|
|
28
|
+
expect(HOOK_INJECTED_MESSAGES_CAPABLE).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load-bearing: `hasResultAlteringHooks` gates eager tool execution. If
|
|
33
|
+
* `PreemptBoundary` ever counted as result-altering, registering a steering
|
|
34
|
+
* drain would silently disable eager dispatch for every steering run.
|
|
35
|
+
*/
|
|
36
|
+
it('is not result-altering, so eager tool execution stays enabled', () => {
|
|
37
|
+
const registry = new HookRegistry();
|
|
38
|
+
registry.register('PreemptBoundary', makeMatcher());
|
|
39
|
+
expect(registry.hasResultAlteringHooks()).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('still reports result-altering when a tool hook is also registered', () => {
|
|
43
|
+
const registry = new HookRegistry();
|
|
44
|
+
registry.register('PreemptBoundary', makeMatcher());
|
|
45
|
+
registry.register('PreToolUse', { hooks: [async () => ({})] });
|
|
46
|
+
expect(registry.hasResultAlteringHooks()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `hasHookFor` answers "is one registered"; the seal gate needs "will one
|
|
51
|
+
* fire". A pattern-scoped matcher is inert for a query-less dispatch, so
|
|
52
|
+
* treating registration as a proxy would seal into a boundary that injects
|
|
53
|
+
* nothing and cut the answer short.
|
|
54
|
+
*/
|
|
55
|
+
describe('hasDispatchableHookFor', () => {
|
|
56
|
+
it('is true for a wildcard matcher with a callback', () => {
|
|
57
|
+
const registry = new HookRegistry();
|
|
58
|
+
registry.register('PreemptBoundary', makeMatcher());
|
|
59
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('is false for a pattern-scoped matcher that can never match', () => {
|
|
63
|
+
const registry = new HookRegistry();
|
|
64
|
+
registry.register('PreemptBoundary', { pattern: 'Bash', hooks: [noop] });
|
|
65
|
+
expect(registry.hasHookFor('PreemptBoundary')).toBe(true);
|
|
66
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is false for a matcher carrying no callbacks', () => {
|
|
70
|
+
const registry = new HookRegistry();
|
|
71
|
+
registry.register('PreemptBoundary', { hooks: [] });
|
|
72
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is false when nothing is registered at all', () => {
|
|
76
|
+
const registry = new HookRegistry();
|
|
77
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('finds a session-scoped dispatchable matcher', () => {
|
|
81
|
+
const registry = new HookRegistry();
|
|
82
|
+
registry.registerSession('run_1', 'PreemptBoundary', makeMatcher());
|
|
83
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
|
|
84
|
+
expect(registry.hasDispatchableHookFor('PreemptBoundary', 'run_1')).toBe(
|
|
85
|
+
true
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps registration isolated from the tool boundary event', () => {
|
|
91
|
+
const registry = new HookRegistry();
|
|
92
|
+
const matcher = makeMatcher();
|
|
93
|
+
registry.register('PreemptBoundary', matcher);
|
|
94
|
+
expect(registry.getMatchers('PreemptBoundary')).toEqual([matcher]);
|
|
95
|
+
expect(registry.getMatchers('PostToolBatch')).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('aggregates injectedMessages from the boundary dispatch', async () => {
|
|
99
|
+
const registry = new HookRegistry();
|
|
100
|
+
registry.register(
|
|
101
|
+
'PreemptBoundary',
|
|
102
|
+
makeMatcher(async () => ({
|
|
103
|
+
injectedMessages: [
|
|
104
|
+
{ role: 'user', content: 'Skip phase two.', source: 'steer' },
|
|
105
|
+
],
|
|
106
|
+
}))
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = await executeHooks({
|
|
110
|
+
registry,
|
|
111
|
+
input: {
|
|
112
|
+
hook_event_name: 'PreemptBoundary',
|
|
113
|
+
runId: 'run_1',
|
|
114
|
+
executingAgentId: 'agent_1',
|
|
115
|
+
sealCount: 1,
|
|
116
|
+
},
|
|
117
|
+
sessionId: 'run_1',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.injectedMessages).toEqual([
|
|
121
|
+
{ role: 'user', content: 'Skip phase two.', source: 'steer' },
|
|
122
|
+
]);
|
|
123
|
+
expect(result.errors).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reports the 1-based seal index to the hook', async () => {
|
|
127
|
+
const registry = new HookRegistry();
|
|
128
|
+
const seen: number[] = [];
|
|
129
|
+
registry.register(
|
|
130
|
+
'PreemptBoundary',
|
|
131
|
+
makeMatcher(async (input) => {
|
|
132
|
+
seen.push(input.sealCount);
|
|
133
|
+
return {};
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
for (const sealCount of [1, 2]) {
|
|
138
|
+
await executeHooks({
|
|
139
|
+
registry,
|
|
140
|
+
input: {
|
|
141
|
+
hook_event_name: 'PreemptBoundary',
|
|
142
|
+
runId: 'run_1',
|
|
143
|
+
executingAgentId: 'agent_1',
|
|
144
|
+
sealCount,
|
|
145
|
+
},
|
|
146
|
+
sessionId: 'run_1',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(seen).toEqual([1, 2]);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// `src/index.ts` and consumed by `Run.processStream` (RunStart,
|
|
5
5
|
// UserPromptSubmit, Stop, StopFailure), `ToolNode.dispatchToolEvents`
|
|
6
6
|
// (PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied),
|
|
7
|
-
// `createSummarizeNode` (PreCompact, PostCompact),
|
|
8
|
-
// `SubagentExecutor.execute` (SubagentStart, SubagentStop)
|
|
7
|
+
// `createSummarizeNode` (PreCompact, PostCompact),
|
|
8
|
+
// `SubagentExecutor.execute` (SubagentStart, SubagentStop), and
|
|
9
|
+
// `StandardGraph.createCallModel` (PreemptBoundary).
|
|
9
10
|
export { HookRegistry } from './HookRegistry';
|
|
10
11
|
export type { HookHaltSignal } from './HookRegistry';
|
|
11
12
|
export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
|
|
@@ -16,6 +17,17 @@ export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
|
|
|
16
17
|
* be consumed by an SDK version that would silently drop it.
|
|
17
18
|
*/
|
|
18
19
|
export const HOOK_INJECTED_MESSAGES_CAPABLE = true;
|
|
20
|
+
/**
|
|
21
|
+
* Feature probe for hosts: this SDK dispatches `PreemptBoundary`, so a
|
|
22
|
+
* cooperative mid-generation seal can drain into the run.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately separate from {@link HOOK_INJECTED_MESSAGES_CAPABLE} — an SDK
|
|
25
|
+
* version can support `injectedMessages` at the tool boundary and know
|
|
26
|
+
* nothing about preemption. A host that probed the wrong flag would arm an
|
|
27
|
+
* interrupt control whose seal request is silently ignored, which reads to
|
|
28
|
+
* the user as a dead button rather than as an unsupported feature.
|
|
29
|
+
*/
|
|
30
|
+
export const HOOK_PREEMPT_BOUNDARY_CAPABLE = true;
|
|
19
31
|
export {
|
|
20
32
|
matchesQuery,
|
|
21
33
|
hasNestedQuantifier,
|
|
@@ -52,6 +64,7 @@ export type {
|
|
|
52
64
|
PostToolUseFailureHookInput,
|
|
53
65
|
PostToolBatchHookInput,
|
|
54
66
|
PostToolBatchEntry,
|
|
67
|
+
PreemptBoundaryHookInput,
|
|
55
68
|
PermissionDeniedHookInput,
|
|
56
69
|
SubagentStartHookInput,
|
|
57
70
|
SubagentStopHookInput,
|
|
@@ -65,6 +78,7 @@ export type {
|
|
|
65
78
|
PostToolUseHookOutput,
|
|
66
79
|
PostToolUseFailureHookOutput,
|
|
67
80
|
PostToolBatchHookOutput,
|
|
81
|
+
PreemptBoundaryHookOutput,
|
|
68
82
|
PermissionDeniedHookOutput,
|
|
69
83
|
SubagentStartHookOutput,
|
|
70
84
|
SubagentStopHookOutput,
|
package/src/hooks/types.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const HOOK_EVENTS = [
|
|
|
17
17
|
'PostToolUse',
|
|
18
18
|
'PostToolUseFailure',
|
|
19
19
|
'PostToolBatch',
|
|
20
|
+
'PreemptBoundary',
|
|
20
21
|
'PermissionDenied',
|
|
21
22
|
'SubagentStart',
|
|
22
23
|
'SubagentStop',
|
|
@@ -145,6 +146,39 @@ export interface PostToolBatchHookInput extends BaseHookInput {
|
|
|
145
146
|
entries: PostToolBatchEntry[];
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Fires when a cooperative preemption seals the model stream mid-generation
|
|
151
|
+
* — the second injection boundary, and the only one that exists during a
|
|
152
|
+
* long text answer with no tool calls in it.
|
|
153
|
+
*
|
|
154
|
+
* Order: fires after the model stream is sealed and BEFORE the next model
|
|
155
|
+
* call. The `injectedMessages` a hook returns here are appended verbatim and
|
|
156
|
+
* the agent node self-loops. Returning nothing is a valid outcome (the host's
|
|
157
|
+
* queue was cancelled or already drained): the run stops honestly rather than
|
|
158
|
+
* self-looping into an empty turn.
|
|
159
|
+
*
|
|
160
|
+
* The sealed turn is NOT yet observable from graph state when this fires.
|
|
161
|
+
* Dispatch happens inside the agent node, and only the outer graph's reducer
|
|
162
|
+
* writes `StandardGraph.messages` — which cannot run until the node returns.
|
|
163
|
+
* A hook calling `Run.getRunMessages()` here sees the state as of the last
|
|
164
|
+
* completed superstep, so the sealed text is absent. `PostToolBatch` behaves
|
|
165
|
+
* the same way: a tool-boundary hook cannot see the assistant turn that
|
|
166
|
+
* requested the tool. This is a property of the single-node outer graph, not
|
|
167
|
+
* of preemption, and committing first would mean returning from the node and
|
|
168
|
+
* re-entering — precisely what the self-loop exists to avoid.
|
|
169
|
+
*
|
|
170
|
+
* A hook that needs the sealed text should therefore not go looking for it in
|
|
171
|
+
* graph state. Decide from the host's own queue, or read it after the run.
|
|
172
|
+
*
|
|
173
|
+
* Requires `RunConfig.preemption`. This event is deliberately NOT
|
|
174
|
+
* result-altering, so registering it never disables eager tool execution.
|
|
175
|
+
*/
|
|
176
|
+
export interface PreemptBoundaryHookInput extends BaseHookInput {
|
|
177
|
+
hook_event_name: 'PreemptBoundary';
|
|
178
|
+
/** 1-based index of this seal within the run. */
|
|
179
|
+
sealCount: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
148
182
|
export interface PermissionDeniedHookInput extends BaseHookInput {
|
|
149
183
|
hook_event_name: 'PermissionDenied';
|
|
150
184
|
toolName: string;
|
|
@@ -217,6 +251,7 @@ export type HookInput =
|
|
|
217
251
|
| PostToolUseHookInput
|
|
218
252
|
| PostToolUseFailureHookInput
|
|
219
253
|
| PostToolBatchHookInput
|
|
254
|
+
| PreemptBoundaryHookInput
|
|
220
255
|
| PermissionDeniedHookInput
|
|
221
256
|
| SubagentStartHookInput
|
|
222
257
|
| SubagentStopHookInput
|
|
@@ -233,6 +268,7 @@ export type HookInputByEvent = {
|
|
|
233
268
|
PostToolUse: PostToolUseHookInput;
|
|
234
269
|
PostToolUseFailure: PostToolUseFailureHookInput;
|
|
235
270
|
PostToolBatch: PostToolBatchHookInput;
|
|
271
|
+
PreemptBoundary: PreemptBoundaryHookInput;
|
|
236
272
|
PermissionDenied: PermissionDeniedHookInput;
|
|
237
273
|
SubagentStart: SubagentStartHookInput;
|
|
238
274
|
SubagentStop: SubagentStopHookInput;
|
|
@@ -251,14 +287,18 @@ export interface BaseHookOutput {
|
|
|
251
287
|
additionalContext?: string;
|
|
252
288
|
/**
|
|
253
289
|
* Messages to inject into graph state, one `HumanMessage` per entry
|
|
254
|
-
* (converted via `
|
|
290
|
+
* (converted via `convertInjectedMessages`, which preserves
|
|
255
291
|
* `role`/`source`/`isMeta` in `additional_kwargs`). Unlike
|
|
256
292
|
* `additionalContext` — which is consolidated across hooks into a single
|
|
257
293
|
* system-flavored message — each entry keeps its own identity and role,
|
|
258
294
|
* making this the channel for injecting verbatim user speech (e.g. a
|
|
259
295
|
* mid-run steering message). Accumulated across hooks in registration
|
|
260
|
-
* order.
|
|
261
|
-
*
|
|
296
|
+
* order.
|
|
297
|
+
*
|
|
298
|
+
* Consumed at exactly two dispatch sites, both of which run the same
|
|
299
|
+
* converter so the emitted shapes cannot drift: `PostToolBatch` (the tool
|
|
300
|
+
* boundary) and `PreemptBoundary` (a cooperative mid-generation seal).
|
|
301
|
+
* Every other event ignores the field.
|
|
262
302
|
*/
|
|
263
303
|
injectedMessages?: InjectedMessage[];
|
|
264
304
|
/** True to prevent the next model turn. Any hook can set this. */
|
|
@@ -367,6 +407,8 @@ export type PostToolUseFailureHookOutput = BaseHookOutput;
|
|
|
367
407
|
|
|
368
408
|
export type PostToolBatchHookOutput = BaseHookOutput;
|
|
369
409
|
|
|
410
|
+
export type PreemptBoundaryHookOutput = BaseHookOutput;
|
|
411
|
+
|
|
370
412
|
export type PermissionDeniedHookOutput = BaseHookOutput;
|
|
371
413
|
|
|
372
414
|
export interface SubagentStartHookOutput extends BaseHookOutput {
|
|
@@ -395,6 +437,7 @@ export type HookOutputByEvent = {
|
|
|
395
437
|
PostToolUse: PostToolUseHookOutput;
|
|
396
438
|
PostToolUseFailure: PostToolUseFailureHookOutput;
|
|
397
439
|
PostToolBatch: PostToolBatchHookOutput;
|
|
440
|
+
PreemptBoundary: PreemptBoundaryHookOutput;
|
|
398
441
|
PermissionDenied: PermissionDeniedHookOutput;
|
|
399
442
|
SubagentStart: SubagentStartHookOutput;
|
|
400
443
|
SubagentStop: SubagentStopHookOutput;
|
|
@@ -412,6 +455,7 @@ export type HookOutput =
|
|
|
412
455
|
| PostToolUseHookOutput
|
|
413
456
|
| PostToolUseFailureHookOutput
|
|
414
457
|
| PostToolBatchHookOutput
|
|
458
|
+
| PreemptBoundaryHookOutput
|
|
415
459
|
| PermissionDeniedHookOutput
|
|
416
460
|
| SubagentStartHookOutput
|
|
417
461
|
| SubagentStopHookOutput
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* Main Operations */
|
|
2
2
|
export * from './run';
|
|
3
3
|
export * from './stream';
|
|
4
|
-
export * from './splitStream';
|
|
5
4
|
export * from './events';
|
|
6
5
|
export * from './messages';
|
|
7
6
|
|
|
@@ -27,6 +26,7 @@ export * from './tools/ReadFile';
|
|
|
27
26
|
export * from './tools/skillCatalog';
|
|
28
27
|
export * from './tools/ToolSearch';
|
|
29
28
|
export * from './tools/ToolNode';
|
|
29
|
+
export * from './tools/intentArg';
|
|
30
30
|
export * from './tools/schema';
|
|
31
31
|
export * from './tools/handlers';
|
|
32
32
|
export * from './tools/local';
|
|
@@ -80,4 +80,5 @@ export { getChatModelClass } from './llm/providers';
|
|
|
80
80
|
export { FakeChatModel, createFakeStreamingLLM } from './llm/fake';
|
|
81
81
|
export { initializeModel } from './llm/init';
|
|
82
82
|
export { attemptInvoke, tryFallbackProviders } from './llm/invoke';
|
|
83
|
+
export { canSealPreempt } from './llm/preempt';
|
|
83
84
|
export { isThinkingEnabled, getMaxOutputTokensKey } from './llm/request';
|
package/src/langfuse.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CallbackHandler } from '@langfuse/langchain';
|
|
2
|
-
import { isParentCommand } from '@langchain/langgraph';
|
|
3
2
|
import { context as otelContext } from '@opentelemetry/api';
|
|
4
3
|
import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
|
|
4
|
+
import { isGraphInterrupt, isParentCommand } from '@langchain/langgraph';
|
|
5
5
|
import {
|
|
6
6
|
getLangfuseTracerProvider,
|
|
7
7
|
propagateAttributes,
|
|
@@ -27,6 +27,10 @@ import { isPresent, parseBooleanEnv } from '@/utils/misc';
|
|
|
27
27
|
|
|
28
28
|
const TRACE_METADATA_MAX_LENGTH = 200;
|
|
29
29
|
const LANGFUSE_FORCE_FLUSH_ON_DISPOSE = 'LANGFUSE_FORCE_FLUSH_ON_DISPOSE';
|
|
30
|
+
const GRAPH_INTERRUPT_CONTROL_FLOW = { controlFlow: 'GraphInterrupt' } as const;
|
|
31
|
+
const GRAPH_INTERRUPT_TOOL_OUTPUT = JSON.stringify(
|
|
32
|
+
GRAPH_INTERRUPT_CONTROL_FLOW
|
|
33
|
+
);
|
|
30
34
|
|
|
31
35
|
export type LangfuseTraceMetadata = Record<string, string>;
|
|
32
36
|
export type LangfuseTraceAttributes = Record<string, string | number | boolean>;
|
|
@@ -209,6 +213,13 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
|
|
|
209
213
|
...args: Parameters<CallbackHandler['handleChainError']>
|
|
210
214
|
): ReturnType<CallbackHandler['handleChainError']> {
|
|
211
215
|
const [error, runId, parentRunId] = args;
|
|
216
|
+
if (error != null && parentRunId != null && isGraphInterrupt(error)) {
|
|
217
|
+
return super.handleChainEnd(
|
|
218
|
+
GRAPH_INTERRUPT_CONTROL_FLOW,
|
|
219
|
+
runId,
|
|
220
|
+
parentRunId
|
|
221
|
+
);
|
|
222
|
+
}
|
|
212
223
|
if (error != null && parentRunId != null && isParentCommand(error)) {
|
|
213
224
|
return super.handleChainEnd(
|
|
214
225
|
{ controlFlow: 'ParentCommand' },
|
|
@@ -261,6 +272,20 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
|
|
|
261
272
|
return this.withRuntimeContext(() => super.handleToolStart(...args));
|
|
262
273
|
}
|
|
263
274
|
|
|
275
|
+
override handleToolError(
|
|
276
|
+
...args: Parameters<CallbackHandler['handleToolError']>
|
|
277
|
+
): ReturnType<CallbackHandler['handleToolError']> {
|
|
278
|
+
const [error, runId, parentRunId] = args;
|
|
279
|
+
if (error != null && parentRunId != null && isGraphInterrupt(error)) {
|
|
280
|
+
return super.handleToolEnd(
|
|
281
|
+
GRAPH_INTERRUPT_TOOL_OUTPUT,
|
|
282
|
+
runId,
|
|
283
|
+
parentRunId
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return super.handleToolError(...args);
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
override handleRetrieverStart(
|
|
265
290
|
...args: Parameters<CallbackHandler['handleRetrieverStart']>
|
|
266
291
|
): ReturnType<CallbackHandler['handleRetrieverStart']> {
|
|
@@ -450,3 +450,85 @@ describe('convertToConverseMessages — v1 reasoning serialization', () => {
|
|
|
450
450
|
expect(content.some((b) => b.text === 'answer')).toBe(true);
|
|
451
451
|
});
|
|
452
452
|
});
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Converse rejects consecutive same-role messages categorically, and both
|
|
456
|
+
* `ToolMessage` and `HumanMessage` convert to `role: 'user'` — so a hook
|
|
457
|
+
* injection landing a text turn directly after tool results (`PostToolBatch`
|
|
458
|
+
* / `PreemptBoundary` drains) must merge into one user message here, with
|
|
459
|
+
* block order preserved so the toolUse/toolResult pairing stays intact.
|
|
460
|
+
*/
|
|
461
|
+
describe('convertToConverseMessages — user-role run merging', () => {
|
|
462
|
+
it('merges a tool result followed by an injected text turn into one user message', () => {
|
|
463
|
+
const messages: BaseMessage[] = [
|
|
464
|
+
new HumanMessage('run the search'),
|
|
465
|
+
new AIMessage({
|
|
466
|
+
content: '',
|
|
467
|
+
tool_calls: [
|
|
468
|
+
{ id: 'call_1', name: 'search', args: {}, type: 'tool_call' },
|
|
469
|
+
],
|
|
470
|
+
}),
|
|
471
|
+
new ToolMessage({
|
|
472
|
+
content: 'search output',
|
|
473
|
+
tool_call_id: 'call_1',
|
|
474
|
+
name: 'search',
|
|
475
|
+
}),
|
|
476
|
+
new HumanMessage({
|
|
477
|
+
content: 'Actually, focus on the second result.',
|
|
478
|
+
additional_kwargs: { source: 'steer' },
|
|
479
|
+
}),
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
const { converseMessages } = convertToConverseMessages(messages);
|
|
483
|
+
|
|
484
|
+
expect(converseMessages.map((m) => m.role)).toEqual([
|
|
485
|
+
'user',
|
|
486
|
+
'assistant',
|
|
487
|
+
'user',
|
|
488
|
+
]);
|
|
489
|
+
const merged = converseMessages[2];
|
|
490
|
+
const blockKinds = (merged.content ?? []).map((block) =>
|
|
491
|
+
'toolResult' in block ? 'toolResult' : 'text'
|
|
492
|
+
);
|
|
493
|
+
expect(blockKinds).toEqual(['toolResult', 'text']);
|
|
494
|
+
expect(
|
|
495
|
+
(merged.content ?? []).find((block) => 'text' in block)?.text
|
|
496
|
+
).toBe('Actually, focus on the second result.');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('still merges adjacent tool-result-only turns', () => {
|
|
500
|
+
const messages: BaseMessage[] = [
|
|
501
|
+
new AIMessage({
|
|
502
|
+
content: '',
|
|
503
|
+
tool_calls: [
|
|
504
|
+
{ id: 'call_1', name: 'a', args: {}, type: 'tool_call' },
|
|
505
|
+
{ id: 'call_2', name: 'b', args: {}, type: 'tool_call' },
|
|
506
|
+
],
|
|
507
|
+
}),
|
|
508
|
+
new ToolMessage({ content: 'one', tool_call_id: 'call_1', name: 'a' }),
|
|
509
|
+
new ToolMessage({ content: 'two', tool_call_id: 'call_2', name: 'b' }),
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
const { converseMessages } = convertToConverseMessages(messages);
|
|
513
|
+
|
|
514
|
+
expect(converseMessages.map((m) => m.role)).toEqual(['assistant', 'user']);
|
|
515
|
+
const toolResultIds = (converseMessages[1].content ?? [])
|
|
516
|
+
.map((block) => ('toolResult' in block ? block.toolResult?.toolUseId : undefined))
|
|
517
|
+
.filter(Boolean);
|
|
518
|
+
expect(toolResultIds).toEqual(['call_1', 'call_2']);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('does not merge across an assistant turn', () => {
|
|
522
|
+
const messages: BaseMessage[] = [
|
|
523
|
+
new HumanMessage('first'),
|
|
524
|
+
new AIMessage('answer'),
|
|
525
|
+
new HumanMessage('second'),
|
|
526
|
+
];
|
|
527
|
+
const { converseMessages } = convertToConverseMessages(messages);
|
|
528
|
+
expect(converseMessages.map((m) => m.role)).toEqual([
|
|
529
|
+
'user',
|
|
530
|
+
'assistant',
|
|
531
|
+
'user',
|
|
532
|
+
]);
|
|
533
|
+
});
|
|
534
|
+
});
|