@librechat/agents 3.3.13 → 3.4.1
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/graphs/Graph.cjs +121 -27
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +83 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +60 -14
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +2 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +35 -206
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +121 -241
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/google/index.cjs +19 -12
- package/dist/cjs/llm/google/index.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +2 -2
- package/dist/cjs/llm/mistral/index.cjs +26 -0
- package/dist/cjs/llm/mistral/index.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +82 -80
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +19 -19
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
- package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
- package/dist/cjs/llm/stream/smoother.cjs +369 -0
- package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
- package/dist/cjs/llm/vertexai/index.cjs +13 -1
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +25 -16
- package/dist/cjs/messages/format.cjs +40 -23
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +82 -15
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +37 -2
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/summarization/node.cjs +6 -3
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
- package/dist/cjs/tools/ToolNode.cjs +316 -94
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
- package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/cjs/utils/tokens.cjs +76 -29
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +121 -27
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +83 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +60 -14
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +2 -1
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +34 -205
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +120 -240
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/google/index.mjs +19 -12
- package/dist/esm/llm/google/index.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +2 -2
- package/dist/esm/llm/mistral/index.mjs +26 -0
- package/dist/esm/llm/mistral/index.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +82 -80
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +3 -3
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
- package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
- package/dist/esm/llm/stream/smoother.mjs +365 -0
- package/dist/esm/llm/stream/smoother.mjs.map +1 -0
- package/dist/esm/llm/vertexai/index.mjs +13 -1
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +13 -11
- package/dist/esm/messages/format.mjs +40 -23
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +83 -16
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +37 -2
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/summarization/node.mjs +6 -3
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
- package/dist/esm/tools/ToolNode.mjs +317 -95
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
- package/dist/esm/tools/toolOutputReferences.mjs +20 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/esm/utils/tokens.mjs +76 -30
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +19 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -1
- package/dist/types/hooks/executeHooks.d.ts +5 -1
- package/dist/types/hooks/index.d.ts +2 -2
- package/dist/types/hooks/types.d.ts +12 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/llm/google/index.d.ts +2 -0
- package/dist/types/llm/mistral/index.d.ts +11 -0
- package/dist/types/llm/openai/index.d.ts +20 -4
- package/dist/types/llm/openrouter/index.d.ts +4 -1
- package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
- package/dist/types/llm/stream/smoother.d.ts +95 -0
- package/dist/types/llm/vertexai/index.d.ts +2 -0
- package/dist/types/run.d.ts +4 -1
- package/dist/types/session/AgentSession.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +23 -15
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
- package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
- package/dist/types/tools/toolOutputReferences.d.ts +12 -0
- package/dist/types/types/graph.d.ts +5 -5
- package/dist/types/types/hitl.d.ts +15 -0
- package/dist/types/types/llm.d.ts +21 -14
- package/dist/types/utils/tokens.d.ts +10 -0
- package/package.json +6 -2
- package/src/graphs/Graph.ts +257 -52
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
- package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
- package/src/hooks/HookRegistry.ts +151 -1
- package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
- package/src/hooks/__tests__/executeHooks.test.ts +160 -0
- package/src/hooks/executeHooks.ts +133 -15
- package/src/hooks/index.ts +3 -1
- package/src/hooks/types.ts +16 -0
- package/src/index.ts +8 -0
- package/src/llm/anthropic/index.ts +85 -354
- package/src/llm/bedrock/index.ts +174 -390
- package/src/llm/bedrock/llm.spec.ts +2 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
- package/src/llm/custom-chat-models.smoke.test.ts +16 -1
- package/src/llm/google/index.ts +17 -18
- package/src/llm/google/streamSmoothing.test.ts +121 -0
- package/src/llm/mistral/index.ts +33 -0
- package/src/llm/mistral/streamSmoothing.test.ts +97 -0
- package/src/llm/openai/deepseek.test.ts +56 -0
- package/src/llm/openai/index.ts +119 -126
- package/src/llm/openrouter/index.ts +4 -1
- package/src/llm/providers.ts +3 -3
- package/src/llm/stream/chunkAdapters.test.ts +202 -0
- package/src/llm/stream/chunkAdapters.ts +317 -0
- package/src/llm/stream/reassembly.test.ts +241 -0
- package/src/llm/stream/smoother.bench.test.ts +155 -0
- package/src/llm/stream/smoother.test.ts +519 -0
- package/src/llm/stream/smoother.ts +574 -0
- package/src/llm/vertexai/index.ts +17 -1
- package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
- package/src/messages/format.ts +54 -26
- package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
- package/src/messages/formatAgentMessages.steer.test.ts +16 -11
- package/src/messages/formatAgentMessages.test.ts +7 -2
- package/src/run.ts +164 -18
- package/src/session/AgentSession.ts +52 -2
- package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
- package/src/specs/subagent.test.ts +44 -0
- package/src/specs/tokens.test.ts +159 -14
- package/src/summarization/__tests__/node.test.ts +32 -14
- package/src/summarization/node.ts +15 -11
- package/src/tools/ToolNode.ts +631 -157
- package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
- package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
- package/src/tools/__tests__/hitl.test.ts +234 -5
- package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
- package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
- package/src/tools/subagent/SubagentExecutor.ts +1438 -136
- package/src/tools/subagent/SubagentReplay.ts +575 -0
- package/src/tools/toolOutputReferences.ts +40 -1
- package/src/types/graph.ts +5 -5
- package/src/types/hitl.ts +16 -0
- package/src/types/llm.ts +53 -36
- package/src/utils/tokens.ts +115 -30
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect } from '@jest/globals';
|
|
1
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
2
2
|
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
3
|
import type * as t from '@/types';
|
|
4
4
|
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
5
|
-
import { Providers } from '@/common';
|
|
6
5
|
import { StandardGraph } from '../Graph';
|
|
6
|
+
import { Providers } from '@/common';
|
|
7
7
|
|
|
8
8
|
const makeAgent = (agentId: string): t.AgentInputs => ({
|
|
9
9
|
agentId,
|
|
@@ -25,7 +25,51 @@ const makeTrip = (): StreamLimitExceededError =>
|
|
|
25
25
|
toolName: 'db_query',
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
type CompiledRegistrationInternals = {
|
|
29
|
+
_compiledToolNodes: Set<{ clearDirectPathTurns(): void }>;
|
|
30
|
+
_subagentExecutors: Set<{
|
|
31
|
+
clearHeavyState(): void;
|
|
32
|
+
resetCheckpointThreadIds(): void;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
|
|
28
36
|
describe('run breaker lifecycle', () => {
|
|
37
|
+
it('retains compiled cleanup registrations across graph reuse', () => {
|
|
38
|
+
const graph = makeGraph();
|
|
39
|
+
const internals = graph as unknown as CompiledRegistrationInternals;
|
|
40
|
+
const clearDirectPathTurns = jest.fn();
|
|
41
|
+
const clearSubagentState = jest.fn();
|
|
42
|
+
const resetCheckpointThreadIds = jest.fn();
|
|
43
|
+
internals._compiledToolNodes.add({ clearDirectPathTurns });
|
|
44
|
+
internals._subagentExecutors.add({
|
|
45
|
+
clearHeavyState: clearSubagentState,
|
|
46
|
+
resetCheckpointThreadIds,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
graph.clearHeavyState();
|
|
50
|
+
graph.clearHeavyState();
|
|
51
|
+
|
|
52
|
+
expect(clearDirectPathTurns).toHaveBeenCalledTimes(2);
|
|
53
|
+
expect(clearSubagentState).toHaveBeenCalledTimes(2);
|
|
54
|
+
expect(internals._compiledToolNodes.size).toBe(1);
|
|
55
|
+
expect(internals._subagentExecutors.size).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('resets subagent checkpoint ownership at each fresh run start', () => {
|
|
59
|
+
const graph = makeGraph();
|
|
60
|
+
const internals = graph as unknown as CompiledRegistrationInternals;
|
|
61
|
+
const resetCheckpointThreadIds = jest.fn();
|
|
62
|
+
internals._subagentExecutors.add({
|
|
63
|
+
clearHeavyState: jest.fn(),
|
|
64
|
+
resetCheckpointThreadIds,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
graph.resetValues();
|
|
68
|
+
graph.resetValues();
|
|
69
|
+
|
|
70
|
+
expect(resetCheckpointThreadIds).toHaveBeenCalledTimes(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
29
73
|
it('replaces the breaker at every run start, even when un-aborted', () => {
|
|
30
74
|
const graph = makeGraph();
|
|
31
75
|
const previous = graph.breakerAbort;
|
|
@@ -115,9 +159,7 @@ describe('run breaker lifecycle', () => {
|
|
|
115
159
|
model: model as unknown as t.ChatModel,
|
|
116
160
|
messages: [new HumanMessage('hi')],
|
|
117
161
|
provider: Providers.OPENAI,
|
|
118
|
-
context: graph as Parameters<
|
|
119
|
-
typeof attemptInvoke
|
|
120
|
-
>[0]['context'],
|
|
162
|
+
context: graph as Parameters<typeof attemptInvoke>[0]['context'],
|
|
121
163
|
onChunk: () => {
|
|
122
164
|
/* producer loop enforcement is under test */
|
|
123
165
|
},
|
|
@@ -225,10 +267,7 @@ describe('run breaker lifecycle', () => {
|
|
|
225
267
|
/** With a live breaker the entry guard must not fire; the node then
|
|
226
268
|
* fails later, on the missing config, proving it got past the guard. */
|
|
227
269
|
await expect(
|
|
228
|
-
node(
|
|
229
|
-
{ messages: [] } as unknown as t.AgentSubgraphState,
|
|
230
|
-
undefined
|
|
231
|
-
)
|
|
270
|
+
node({ messages: [] } as unknown as t.AgentSubgraphState, undefined)
|
|
232
271
|
).rejects.toThrow('No config provided');
|
|
233
272
|
});
|
|
234
273
|
});
|
|
@@ -1338,7 +1338,7 @@ describe('context overflow recovery', () => {
|
|
|
1338
1338
|
if (agentContext == null) {
|
|
1339
1339
|
throw new Error('Expected default agent context');
|
|
1340
1340
|
}
|
|
1341
|
-
expect(run.Graph.compileOptions?.checkpointer).
|
|
1341
|
+
expect(run.Graph.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
|
|
1342
1342
|
expect(run.Graph.hasCompiledCheckpointer).toBe(true);
|
|
1343
1343
|
run.Graph.resetValues(undefined, 'checkpoint-scope');
|
|
1344
1344
|
agentContext.preserveOriginalToolContent(new Map([[2, 'full output']]));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import type { SubagentToolNodeResumeState } from '@/tools/subagent/SubagentReplay';
|
|
3
|
+
import { StandardGraph } from '../Graph';
|
|
4
|
+
import { Providers } from '@/common';
|
|
5
|
+
|
|
6
|
+
type ResumeToolNode = {
|
|
7
|
+
createSubagentResumeState(): SubagentToolNodeResumeState;
|
|
8
|
+
restoreSubagentResumeState(state: SubagentToolNodeResumeState): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const makeGraph = (runId: string): StandardGraph =>
|
|
12
|
+
new StandardGraph({
|
|
13
|
+
runId,
|
|
14
|
+
agents: [
|
|
15
|
+
{
|
|
16
|
+
agentId: 'child-agent',
|
|
17
|
+
provider: Providers.OPENAI,
|
|
18
|
+
instructions: 'Test child state restoration.',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const initializeToolNode = (graph: StandardGraph): ResumeToolNode => {
|
|
24
|
+
const agentContext = graph.agentContexts.get('child-agent');
|
|
25
|
+
return graph.initializeTools({
|
|
26
|
+
currentTools: [],
|
|
27
|
+
agentContext,
|
|
28
|
+
}) as unknown as ResumeToolNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('subagent graph resume state', () => {
|
|
32
|
+
it('restores tool identity, turns, references, and sessions into a branch', () => {
|
|
33
|
+
const source = makeGraph('source');
|
|
34
|
+
source.toolOutputReferences = { enabled: true };
|
|
35
|
+
const sourceNode = initializeToolNode(source);
|
|
36
|
+
source.toolCallStepIds.set('call_tool', 'step_tool');
|
|
37
|
+
source.sessions.set('execute_code', {
|
|
38
|
+
session_id: 'sandbox-session',
|
|
39
|
+
lastUpdated: 42,
|
|
40
|
+
});
|
|
41
|
+
source
|
|
42
|
+
.getOrCreateToolOutputRegistry()
|
|
43
|
+
?.set('source-scope', 'tool0turn0', 'raw output');
|
|
44
|
+
source.getEagerEventToolUsageCount('child-agent').set('calculator', 3);
|
|
45
|
+
source.eagerEventToolSuppressions.add('unstable_search');
|
|
46
|
+
const sourceNodeState = sourceNode.createSubagentResumeState();
|
|
47
|
+
sourceNode.restoreSubagentResumeState({
|
|
48
|
+
...sourceNodeState,
|
|
49
|
+
toolUsageCounts: [{ toolName: 'calculator', count: 3 }],
|
|
50
|
+
directPathTurns: [{ toolCallId: 'call_tool', turn: 2 }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const target = makeGraph('target');
|
|
54
|
+
target.toolOutputReferences = { enabled: true };
|
|
55
|
+
const targetNode = initializeToolNode(target);
|
|
56
|
+
target.restoreSubagentResumeState(
|
|
57
|
+
source.createSubagentResumeState('source-scope'),
|
|
58
|
+
'target-scope'
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(target.toolCallStepIds.get('call_tool')).toBe('step_tool');
|
|
62
|
+
expect(target.sessions.get('execute_code')).toMatchObject({
|
|
63
|
+
session_id: 'sandbox-session',
|
|
64
|
+
lastUpdated: 42,
|
|
65
|
+
});
|
|
66
|
+
expect(
|
|
67
|
+
target.getOrCreateToolOutputRegistry()?.get('target-scope', 'tool0turn0')
|
|
68
|
+
).toBe('raw output');
|
|
69
|
+
expect(targetNode.createSubagentResumeState()).toMatchObject({
|
|
70
|
+
toolUsageCounts: [{ toolName: 'calculator', count: 3 }],
|
|
71
|
+
directPathTurns: [{ toolCallId: 'call_tool', turn: 2 }],
|
|
72
|
+
});
|
|
73
|
+
expect(
|
|
74
|
+
target.getEagerEventToolUsageCount('child-agent').get('calculator')
|
|
75
|
+
).toBe(3);
|
|
76
|
+
expect(target.eagerEventToolSuppressions).toEqual(
|
|
77
|
+
new Set(['unstable_search'])
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
// src/hooks/HookRegistry.ts
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
HookEvent,
|
|
4
|
+
HookMatcher,
|
|
5
|
+
ToolApprovalReplayKey,
|
|
6
|
+
ToolApprovalReplaySnapshot,
|
|
7
|
+
AggregatedHookResult,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
function serializeApprovalKey(key: ToolApprovalReplayKey): string {
|
|
11
|
+
return JSON.stringify([key.executionScope, key.agentId, key.toolUseId]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function deserializeApprovalKey(value: string): ToolApprovalReplayKey | null {
|
|
15
|
+
const parsed: unknown = JSON.parse(value);
|
|
16
|
+
if (
|
|
17
|
+
!Array.isArray(parsed) ||
|
|
18
|
+
parsed.length !== 3 ||
|
|
19
|
+
parsed.some((part) => typeof part !== 'string')
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
executionScope: parsed[0],
|
|
25
|
+
agentId: parsed[1],
|
|
26
|
+
toolUseId: parsed[2],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
/**
|
|
5
31
|
* Internal matcher storage type.
|
|
@@ -71,6 +97,11 @@ export class HookRegistry {
|
|
|
71
97
|
* O(1) insertion in hot paths, no spread-on-write.
|
|
72
98
|
*/
|
|
73
99
|
private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
|
|
100
|
+
/** One-shot hook contributions retained until approval is consumed. */
|
|
101
|
+
private readonly pendingToolApprovals = new Map<
|
|
102
|
+
string,
|
|
103
|
+
Map<string, AggregatedHookResult>
|
|
104
|
+
>();
|
|
74
105
|
|
|
75
106
|
/**
|
|
76
107
|
* Register a matcher for the lifetime of this registry (= one Run).
|
|
@@ -160,6 +191,125 @@ export class HookRegistry {
|
|
|
160
191
|
*/
|
|
161
192
|
clearSession(sessionId: string): void {
|
|
162
193
|
this.sessions.delete(sessionId);
|
|
194
|
+
this.pendingToolApprovals.delete(sessionId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Copies session-scoped policy into a rebuilt or branched Run. */
|
|
198
|
+
copySession(sourceSessionId: string, targetSessionId: string): void {
|
|
199
|
+
if (sourceSessionId === targetSessionId) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const source = this.sessions.get(sourceSessionId);
|
|
203
|
+
if (source != null) {
|
|
204
|
+
const target = this.ensureSessionBucket(targetSessionId);
|
|
205
|
+
for (const event of Object.keys(source) as HookEvent[]) {
|
|
206
|
+
const targetList = ensureList(target, event);
|
|
207
|
+
for (const matcher of readList(source, event)) {
|
|
208
|
+
if (!targetList.includes(matcher)) {
|
|
209
|
+
targetList.push(matcher);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const pending = this.pendingToolApprovals.get(sourceSessionId);
|
|
215
|
+
if (pending == null) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
let targetPending = this.pendingToolApprovals.get(targetSessionId);
|
|
219
|
+
if (targetPending == null) {
|
|
220
|
+
targetPending = new Map();
|
|
221
|
+
this.pendingToolApprovals.set(targetSessionId, targetPending);
|
|
222
|
+
}
|
|
223
|
+
for (const [toolUseId, result] of pending) {
|
|
224
|
+
if (!targetPending.has(toolUseId)) {
|
|
225
|
+
targetPending.set(toolUseId, result);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
getPendingToolApproval(
|
|
231
|
+
sessionId: string,
|
|
232
|
+
key: ToolApprovalReplayKey
|
|
233
|
+
): AggregatedHookResult | undefined {
|
|
234
|
+
return this.pendingToolApprovals
|
|
235
|
+
.get(sessionId)
|
|
236
|
+
?.get(serializeApprovalKey(key));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
setPendingToolApproval(
|
|
240
|
+
sessionId: string,
|
|
241
|
+
key: ToolApprovalReplayKey,
|
|
242
|
+
result: AggregatedHookResult
|
|
243
|
+
): void {
|
|
244
|
+
let pending = this.pendingToolApprovals.get(sessionId);
|
|
245
|
+
if (pending == null) {
|
|
246
|
+
pending = new Map();
|
|
247
|
+
this.pendingToolApprovals.set(sessionId, pending);
|
|
248
|
+
}
|
|
249
|
+
pending.set(serializeApprovalKey(key), result);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
clearPendingToolApproval(
|
|
253
|
+
sessionId: string,
|
|
254
|
+
key: ToolApprovalReplayKey
|
|
255
|
+
): void {
|
|
256
|
+
const pending = this.pendingToolApprovals.get(sessionId);
|
|
257
|
+
if (pending == null) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
pending.delete(serializeApprovalKey(key));
|
|
261
|
+
if (pending.size === 0) {
|
|
262
|
+
this.pendingToolApprovals.delete(sessionId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
snapshotPendingToolApprovals(
|
|
267
|
+
sessionId: string,
|
|
268
|
+
executionScope: string
|
|
269
|
+
): ToolApprovalReplaySnapshot[] {
|
|
270
|
+
const pending = this.pendingToolApprovals.get(sessionId);
|
|
271
|
+
if (pending == null) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
const snapshots: ToolApprovalReplaySnapshot[] = [];
|
|
275
|
+
for (const [serializedKey, result] of pending) {
|
|
276
|
+
const key = deserializeApprovalKey(serializedKey);
|
|
277
|
+
if (key == null || key.executionScope !== executionScope) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
snapshots.push({ key, result });
|
|
281
|
+
}
|
|
282
|
+
return snapshots;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
restorePendingToolApprovals(
|
|
286
|
+
sessionId: string,
|
|
287
|
+
targetExecutionScope: string,
|
|
288
|
+
snapshots: ReadonlyArray<ToolApprovalReplaySnapshot>
|
|
289
|
+
): void {
|
|
290
|
+
const restored = new Map<string, AggregatedHookResult>();
|
|
291
|
+
for (const [serializedKey, result] of this.pendingToolApprovals.get(
|
|
292
|
+
sessionId
|
|
293
|
+
) ?? []) {
|
|
294
|
+
const key = deserializeApprovalKey(serializedKey);
|
|
295
|
+
if (key?.executionScope !== targetExecutionScope) {
|
|
296
|
+
restored.set(serializedKey, result);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const snapshot of snapshots) {
|
|
300
|
+
restored.set(
|
|
301
|
+
serializeApprovalKey({
|
|
302
|
+
...snapshot.key,
|
|
303
|
+
executionScope: targetExecutionScope,
|
|
304
|
+
}),
|
|
305
|
+
snapshot.result
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (restored.size === 0) {
|
|
309
|
+
this.pendingToolApprovals.delete(sessionId);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.pendingToolApprovals.set(sessionId, restored);
|
|
163
313
|
}
|
|
164
314
|
|
|
165
315
|
/**
|
|
@@ -164,6 +164,102 @@ describe('HookRegistry', () => {
|
|
|
164
164
|
expect(registry.hasHookFor('PreToolUse', 'run-a')).toBe(true);
|
|
165
165
|
expect(registry.hasHookFor('PreToolUse', 'run-b')).toBe(false);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('copies session policy without consuming the source or sibling branches', () => {
|
|
169
|
+
const registry = new HookRegistry();
|
|
170
|
+
const matcher = makePreToolUseMatcher('subagent');
|
|
171
|
+
registry.registerSession('source', 'PreToolUse', matcher);
|
|
172
|
+
|
|
173
|
+
registry.copySession('source', 'branch-a');
|
|
174
|
+
registry.copySession('source', 'branch-b');
|
|
175
|
+
|
|
176
|
+
expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
|
|
177
|
+
expect(registry.getMatchers('PreToolUse', 'branch-a')).toEqual([matcher]);
|
|
178
|
+
expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
|
|
179
|
+
|
|
180
|
+
registry.clearSession('branch-a');
|
|
181
|
+
expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
|
|
182
|
+
expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('copies pending one-shot approvals and clears them with the target session', () => {
|
|
186
|
+
const registry = new HookRegistry();
|
|
187
|
+
const approval = {
|
|
188
|
+
decision: 'ask' as const,
|
|
189
|
+
reason: 'review tool',
|
|
190
|
+
additionalContexts: [],
|
|
191
|
+
injectedMessages: [],
|
|
192
|
+
errors: [],
|
|
193
|
+
};
|
|
194
|
+
const key = {
|
|
195
|
+
executionScope: 'child-a',
|
|
196
|
+
agentId: 'researcher',
|
|
197
|
+
toolUseId: 'call_1',
|
|
198
|
+
};
|
|
199
|
+
const siblingKey = { ...key, executionScope: 'child-b' };
|
|
200
|
+
registry.setPendingToolApproval('source', key, approval);
|
|
201
|
+
registry.setPendingToolApproval('source', siblingKey, {
|
|
202
|
+
...approval,
|
|
203
|
+
reason: 'review sibling',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
registry.copySession('source', 'branch');
|
|
207
|
+
|
|
208
|
+
expect(registry.getPendingToolApproval('source', key)).toBe(approval);
|
|
209
|
+
expect(registry.getPendingToolApproval('branch', key)).toBe(approval);
|
|
210
|
+
expect(
|
|
211
|
+
registry.getPendingToolApproval('branch', siblingKey)?.reason
|
|
212
|
+
).toBe('review sibling');
|
|
213
|
+
registry.clearPendingToolApproval('branch', key);
|
|
214
|
+
expect(registry.getPendingToolApproval('branch', key)).toBeUndefined();
|
|
215
|
+
expect(
|
|
216
|
+
registry.getPendingToolApproval('branch', siblingKey)?.reason
|
|
217
|
+
).toBe('review sibling');
|
|
218
|
+
const checkpointSnapshot = registry.snapshotPendingToolApprovals(
|
|
219
|
+
'source',
|
|
220
|
+
'child-a'
|
|
221
|
+
);
|
|
222
|
+
expect(checkpointSnapshot).toEqual([{ key, result: approval }]);
|
|
223
|
+
registry.clearSession('branch');
|
|
224
|
+
expect(
|
|
225
|
+
registry.getPendingToolApproval('branch', siblingKey)
|
|
226
|
+
).toBeUndefined();
|
|
227
|
+
expect(registry.getPendingToolApproval('source', key)).toBe(approval);
|
|
228
|
+
registry.setPendingToolApproval('restored', key, {
|
|
229
|
+
...approval,
|
|
230
|
+
reason: 'stale approval',
|
|
231
|
+
});
|
|
232
|
+
registry.setPendingToolApproval('restored', siblingKey, {
|
|
233
|
+
...approval,
|
|
234
|
+
reason: 'preserved sibling',
|
|
235
|
+
});
|
|
236
|
+
registry.restorePendingToolApprovals(
|
|
237
|
+
'restored',
|
|
238
|
+
'child-a',
|
|
239
|
+
checkpointSnapshot
|
|
240
|
+
);
|
|
241
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
242
|
+
expect(
|
|
243
|
+
registry.getPendingToolApproval('restored', siblingKey)?.reason
|
|
244
|
+
).toBe('preserved sibling');
|
|
245
|
+
registry.restorePendingToolApprovals('restored', 'child-b', []);
|
|
246
|
+
expect(
|
|
247
|
+
registry.getPendingToolApproval('restored', siblingKey)
|
|
248
|
+
).toBeUndefined();
|
|
249
|
+
|
|
250
|
+
const branchKey = { ...key, executionScope: 'child-a-attempt-2' };
|
|
251
|
+
registry.restorePendingToolApprovals(
|
|
252
|
+
'restored',
|
|
253
|
+
branchKey.executionScope,
|
|
254
|
+
checkpointSnapshot
|
|
255
|
+
);
|
|
256
|
+
expect(registry.getPendingToolApproval('restored', branchKey)).toBe(
|
|
257
|
+
approval
|
|
258
|
+
);
|
|
259
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
260
|
+
registry.clearPendingToolApproval('restored', branchKey);
|
|
261
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
262
|
+
});
|
|
167
263
|
});
|
|
168
264
|
|
|
169
265
|
describe('session isolation under parallel registration', () => {
|
|
@@ -764,6 +764,166 @@ describe('executeHooks', () => {
|
|
|
764
764
|
});
|
|
765
765
|
|
|
766
766
|
describe('once: true self-removal', () => {
|
|
767
|
+
it('replays a one-shot approval until the tool decision is consumed', async () => {
|
|
768
|
+
const registry = new HookRegistry();
|
|
769
|
+
let calls = 0;
|
|
770
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
771
|
+
once: true,
|
|
772
|
+
hooks: [
|
|
773
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
774
|
+
calls += 1;
|
|
775
|
+
return { decision: 'ask', reason: 'review once' };
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
});
|
|
779
|
+
const options = {
|
|
780
|
+
registry,
|
|
781
|
+
input: preToolUseInput('Bash'),
|
|
782
|
+
matchQuery: 'Bash',
|
|
783
|
+
sessionId: 'run-1',
|
|
784
|
+
onceReplayKey: {
|
|
785
|
+
executionScope: 'child-a',
|
|
786
|
+
agentId: 'researcher',
|
|
787
|
+
toolUseId: 'call_1',
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
await executeHooks(options);
|
|
792
|
+
const replay = await executeHooks(options);
|
|
793
|
+
|
|
794
|
+
expect(replay).toMatchObject({
|
|
795
|
+
decision: 'ask',
|
|
796
|
+
reason: 'review once',
|
|
797
|
+
});
|
|
798
|
+
expect(calls).toBe(1);
|
|
799
|
+
expect(registry.getMatchers('PreToolUse', 'run-1')).toHaveLength(0);
|
|
800
|
+
|
|
801
|
+
const sibling = await executeHooks({
|
|
802
|
+
...options,
|
|
803
|
+
onceReplayKey: {
|
|
804
|
+
...options.onceReplayKey,
|
|
805
|
+
executionScope: 'child-b',
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
expect(sibling.decision).toBeUndefined();
|
|
809
|
+
|
|
810
|
+
registry.clearPendingToolApproval('run-1', options.onceReplayKey);
|
|
811
|
+
const afterDecision = await executeHooks(options);
|
|
812
|
+
expect(afterDecision.decision).toBeUndefined();
|
|
813
|
+
expect(calls).toBe(1);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('replays only one-shot contributions while rerunning ordinary hooks', async () => {
|
|
817
|
+
const registry = new HookRegistry();
|
|
818
|
+
let onceCalls = 0;
|
|
819
|
+
let ordinaryCalls = 0;
|
|
820
|
+
let ordinaryDecision: 'allow' | 'deny' = 'allow';
|
|
821
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
822
|
+
once: true,
|
|
823
|
+
hooks: [
|
|
824
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
825
|
+
onceCalls += 1;
|
|
826
|
+
return {
|
|
827
|
+
decision: 'ask',
|
|
828
|
+
reason: 'review once',
|
|
829
|
+
additionalContext: 'once-context',
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
});
|
|
834
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
835
|
+
hooks: [
|
|
836
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
837
|
+
ordinaryCalls += 1;
|
|
838
|
+
return {
|
|
839
|
+
decision: ordinaryDecision,
|
|
840
|
+
reason: `ordinary-${ordinaryDecision}`,
|
|
841
|
+
additionalContext: `ordinary-${ordinaryCalls}`,
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
});
|
|
846
|
+
const options = {
|
|
847
|
+
registry,
|
|
848
|
+
input: preToolUseInput('Bash'),
|
|
849
|
+
matchQuery: 'Bash',
|
|
850
|
+
sessionId: 'run-1',
|
|
851
|
+
onceReplayKey: {
|
|
852
|
+
executionScope: 'child-a',
|
|
853
|
+
agentId: 'researcher',
|
|
854
|
+
toolUseId: 'call_1',
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const first = await executeHooks(options);
|
|
859
|
+
ordinaryDecision = 'deny';
|
|
860
|
+
const replay = await executeHooks(options);
|
|
861
|
+
|
|
862
|
+
expect(first.decision).toBe('ask');
|
|
863
|
+
expect(replay).toMatchObject({
|
|
864
|
+
decision: 'deny',
|
|
865
|
+
reason: 'ordinary-deny',
|
|
866
|
+
additionalContexts: ['once-context', 'ordinary-2'],
|
|
867
|
+
});
|
|
868
|
+
expect(onceCalls).toBe(1);
|
|
869
|
+
expect(ordinaryCalls).toBe(2);
|
|
870
|
+
expect(
|
|
871
|
+
registry.getPendingToolApproval('run-1', options.onceReplayKey)
|
|
872
|
+
).toBeUndefined();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('retains a consumed one-shot rewrite when an ordinary hook asks', async () => {
|
|
876
|
+
const registry = new HookRegistry();
|
|
877
|
+
let onceCalls = 0;
|
|
878
|
+
let ordinaryCalls = 0;
|
|
879
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
880
|
+
once: true,
|
|
881
|
+
hooks: [
|
|
882
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
883
|
+
onceCalls += 1;
|
|
884
|
+
return {
|
|
885
|
+
decision: 'allow',
|
|
886
|
+
updatedInput: { command: 'sanitized' },
|
|
887
|
+
};
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
});
|
|
891
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
892
|
+
hooks: [
|
|
893
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
894
|
+
ordinaryCalls += 1;
|
|
895
|
+
return { decision: 'ask', reason: 'ordinary approval' };
|
|
896
|
+
},
|
|
897
|
+
],
|
|
898
|
+
});
|
|
899
|
+
const options = {
|
|
900
|
+
registry,
|
|
901
|
+
input: preToolUseInput('Bash'),
|
|
902
|
+
matchQuery: 'Bash',
|
|
903
|
+
sessionId: 'run-1',
|
|
904
|
+
onceReplayKey: {
|
|
905
|
+
executionScope: 'child-a',
|
|
906
|
+
agentId: 'researcher',
|
|
907
|
+
toolUseId: 'call_1',
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const first = await executeHooks(options);
|
|
912
|
+
const replay = await executeHooks(options);
|
|
913
|
+
|
|
914
|
+
expect(first).toMatchObject({
|
|
915
|
+
decision: 'ask',
|
|
916
|
+
updatedInput: { command: 'sanitized' },
|
|
917
|
+
});
|
|
918
|
+
expect(replay).toMatchObject({
|
|
919
|
+
decision: 'ask',
|
|
920
|
+
reason: 'ordinary approval',
|
|
921
|
+
updatedInput: { command: 'sanitized' },
|
|
922
|
+
});
|
|
923
|
+
expect(onceCalls).toBe(1);
|
|
924
|
+
expect(ordinaryCalls).toBe(2);
|
|
925
|
+
});
|
|
926
|
+
|
|
767
927
|
it('removes the matcher after a successful fire', async () => {
|
|
768
928
|
const registry = new HookRegistry();
|
|
769
929
|
let calls = 0;
|