@librechat/agents 3.1.75 → 3.1.77-dev.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 +22 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +54 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +40 -1
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langchain/google-common.cjs +3 -0
- package/dist/cjs/langchain/google-common.cjs.map +1 -0
- package/dist/cjs/langchain/index.cjs +86 -0
- package/dist/cjs/langchain/index.cjs.map +1 -0
- package/dist/cjs/langchain/language_models/chat_models.cjs +3 -0
- package/dist/cjs/langchain/language_models/chat_models.cjs.map +1 -0
- package/dist/cjs/langchain/messages/tool.cjs +3 -0
- package/dist/cjs/langchain/messages/tool.cjs.map +1 -0
- package/dist/cjs/langchain/messages.cjs +51 -0
- package/dist/cjs/langchain/messages.cjs.map +1 -0
- package/dist/cjs/langchain/openai.cjs +3 -0
- package/dist/cjs/langchain/openai.cjs.map +1 -0
- package/dist/cjs/langchain/prompts.cjs +11 -0
- package/dist/cjs/langchain/prompts.cjs.map +1 -0
- package/dist/cjs/langchain/runnables.cjs +19 -0
- package/dist/cjs/langchain/runnables.cjs.map +1 -0
- package/dist/cjs/langchain/tools.cjs +23 -0
- package/dist/cjs/langchain/tools.cjs.map +1 -0
- package/dist/cjs/langchain/utils/env.cjs +11 -0
- package/dist/cjs/langchain/utils/env.cjs.map +1 -0
- package/dist/cjs/llm/anthropic/index.cjs +145 -52
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +21 -14
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +84 -70
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +1 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +213 -3
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +2 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +5 -4
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +519 -655
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +20 -458
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +57 -175
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/vertexai/index.cjs +5 -3
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +112 -3
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +2 -1
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +7 -6
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +73 -15
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/langchain.cjs +26 -0
- package/dist/cjs/messages/langchain.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +7 -6
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +400 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +556 -56
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +55 -66
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs +189 -0
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tavily-search.cjs +372 -0
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +26 -4
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/search/utils.cjs +10 -3
- package/dist/cjs/tools/search/utils.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +22 -3
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +65 -0
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +54 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +40 -1
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langchain/google-common.mjs +2 -0
- package/dist/esm/langchain/google-common.mjs.map +1 -0
- package/dist/esm/langchain/index.mjs +5 -0
- package/dist/esm/langchain/index.mjs.map +1 -0
- package/dist/esm/langchain/language_models/chat_models.mjs +2 -0
- package/dist/esm/langchain/language_models/chat_models.mjs.map +1 -0
- package/dist/esm/langchain/messages/tool.mjs +2 -0
- package/dist/esm/langchain/messages/tool.mjs.map +1 -0
- package/dist/esm/langchain/messages.mjs +2 -0
- package/dist/esm/langchain/messages.mjs.map +1 -0
- package/dist/esm/langchain/openai.mjs +2 -0
- package/dist/esm/langchain/openai.mjs.map +1 -0
- package/dist/esm/langchain/prompts.mjs +2 -0
- package/dist/esm/langchain/prompts.mjs.map +1 -0
- package/dist/esm/langchain/runnables.mjs +2 -0
- package/dist/esm/langchain/runnables.mjs.map +1 -0
- package/dist/esm/langchain/tools.mjs +2 -0
- package/dist/esm/langchain/tools.mjs.map +1 -0
- package/dist/esm/langchain/utils/env.mjs +2 -0
- package/dist/esm/langchain/utils/env.mjs.map +1 -0
- package/dist/esm/llm/anthropic/index.mjs +146 -54
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +21 -14
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs +84 -71
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +1 -1
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +214 -4
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs +2 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +5 -4
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +520 -656
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +23 -459
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +57 -175
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/vertexai/index.mjs +5 -3
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +7 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +2 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +7 -6
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +73 -15
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/langchain.mjs +23 -0
- package/dist/esm/messages/langchain.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +7 -6
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +400 -42
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +557 -57
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +55 -66
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs +186 -0
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tavily-search.mjs +370 -0
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +26 -4
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/search/utils.mjs +10 -3
- package/dist/esm/tools/search/utils.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +7 -0
- package/dist/types/hitl/askUserQuestion.d.ts +55 -0
- package/dist/types/hitl/index.d.ts +6 -0
- package/dist/types/hooks/HookRegistry.d.ts +58 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
- package/dist/types/hooks/index.d.ts +4 -1
- package/dist/types/hooks/types.d.ts +109 -3
- package/dist/types/index.d.ts +10 -0
- package/dist/types/langchain/google-common.d.ts +1 -0
- package/dist/types/langchain/index.d.ts +8 -0
- package/dist/types/langchain/language_models/chat_models.d.ts +1 -0
- package/dist/types/langchain/messages/tool.d.ts +1 -0
- package/dist/types/langchain/messages.d.ts +2 -0
- package/dist/types/langchain/openai.d.ts +1 -0
- package/dist/types/langchain/prompts.d.ts +1 -0
- package/dist/types/langchain/runnables.d.ts +2 -0
- package/dist/types/langchain/tools.d.ts +2 -0
- package/dist/types/langchain/utils/env.d.ts +1 -0
- package/dist/types/llm/anthropic/index.d.ts +22 -9
- package/dist/types/llm/anthropic/types.d.ts +5 -1
- package/dist/types/llm/anthropic/utils/message_outputs.d.ts +13 -6
- package/dist/types/llm/anthropic/utils/output_parsers.d.ts +1 -1
- package/dist/types/llm/openai/index.d.ts +21 -24
- package/dist/types/llm/openrouter/index.d.ts +11 -9
- package/dist/types/llm/vertexai/index.d.ts +1 -0
- package/dist/types/messages/cache.d.ts +4 -1
- package/dist/types/messages/format.d.ts +4 -1
- package/dist/types/messages/langchain.d.ts +27 -0
- package/dist/types/run.d.ts +117 -1
- package/dist/types/tools/ToolNode.d.ts +26 -1
- package/dist/types/tools/search/tavily-scraper.d.ts +19 -0
- package/dist/types/tools/search/tavily-search.d.ts +4 -0
- package/dist/types/tools/search/types.d.ts +99 -5
- package/dist/types/tools/search/utils.d.ts +2 -2
- package/dist/types/types/graph.d.ts +23 -37
- package/dist/types/types/hitl.d.ts +272 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/llm.d.ts +3 -3
- package/dist/types/types/run.d.ts +33 -0
- package/dist/types/types/stream.d.ts +1 -1
- package/dist/types/types/tools.d.ts +19 -0
- package/package.json +80 -17
- package/src/graphs/Graph.ts +33 -4
- package/src/graphs/__tests__/composition.smoke.test.ts +188 -0
- package/src/hitl/askUserQuestion.ts +72 -0
- package/src/hitl/index.ts +7 -0
- package/src/hooks/HookRegistry.ts +71 -0
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
- package/src/hooks/createToolPolicyHook.ts +184 -0
- package/src/hooks/executeHooks.ts +50 -1
- package/src/hooks/index.ts +6 -0
- package/src/hooks/types.ts +112 -0
- package/src/index.ts +22 -0
- package/src/langchain/google-common.ts +1 -0
- package/src/langchain/index.ts +8 -0
- package/src/langchain/language_models/chat_models.ts +1 -0
- package/src/langchain/messages/tool.ts +5 -0
- package/src/langchain/messages.ts +21 -0
- package/src/langchain/openai.ts +1 -0
- package/src/langchain/prompts.ts +1 -0
- package/src/langchain/runnables.ts +7 -0
- package/src/langchain/tools.ts +8 -0
- package/src/langchain/utils/env.ts +1 -0
- package/src/llm/anthropic/index.ts +252 -84
- package/src/llm/anthropic/llm.spec.ts +751 -102
- package/src/llm/anthropic/types.ts +9 -1
- package/src/llm/anthropic/utils/message_inputs.ts +37 -19
- package/src/llm/anthropic/utils/message_outputs.ts +119 -101
- package/src/llm/bedrock/index.ts +2 -2
- package/src/llm/bedrock/llm.spec.ts +341 -0
- package/src/llm/bedrock/utils/message_inputs.ts +303 -4
- package/src/llm/bedrock/utils/message_outputs.ts +2 -1
- package/src/llm/custom-chat-models.smoke.test.ts +836 -0
- package/src/llm/google/llm.spec.ts +339 -57
- package/src/llm/google/utils/common.ts +53 -48
- package/src/llm/openai/contentBlocks.test.ts +346 -0
- package/src/llm/openai/index.ts +856 -833
- package/src/llm/openai/utils/index.ts +107 -78
- package/src/llm/openai/utils/messages.test.ts +159 -0
- package/src/llm/openrouter/index.ts +124 -247
- package/src/llm/openrouter/reasoning.test.ts +8 -1
- package/src/llm/vertexai/index.ts +11 -5
- package/src/llm/vertexai/llm.spec.ts +28 -1
- package/src/messages/cache.test.ts +4 -3
- package/src/messages/cache.ts +3 -2
- package/src/messages/core.ts +16 -9
- package/src/messages/format.ts +96 -16
- package/src/messages/formatAgentMessages.test.ts +166 -1
- package/src/messages/langchain.ts +39 -0
- package/src/messages/prune.ts +12 -8
- package/src/run.ts +456 -47
- package/src/scripts/caching.ts +2 -3
- package/src/specs/summarization.test.ts +51 -58
- package/src/tools/ToolNode.ts +706 -63
- package/src/tools/__tests__/hitl.test.ts +3593 -0
- package/src/tools/search/search.ts +83 -73
- package/src/tools/search/tavily-scraper.ts +235 -0
- package/src/tools/search/tavily-search.ts +424 -0
- package/src/tools/search/tavily.test.ts +965 -0
- package/src/tools/search/tool.ts +36 -26
- package/src/tools/search/types.ts +133 -8
- package/src/tools/search/utils.ts +13 -5
- package/src/types/graph.ts +32 -87
- package/src/types/hitl.ts +303 -0
- package/src/types/index.ts +1 -0
- package/src/types/llm.ts +3 -3
- package/src/types/run.ts +33 -0
- package/src/types/stream.ts +1 -1
- package/src/types/tools.ts +19 -0
- package/src/utils/llmConfig.ts +1 -6
package/src/graphs/Graph.ts
CHANGED
|
@@ -129,6 +129,13 @@ export abstract class Graph<
|
|
|
129
129
|
invokedToolIds?: Set<string>;
|
|
130
130
|
handlerRegistry: HandlerRegistry | undefined;
|
|
131
131
|
hookRegistry: HookRegistry | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* Run-scoped HITL configuration. When `humanInTheLoop?.enabled` is
|
|
134
|
+
* `true`, `ToolNode` raises a real `interrupt()` for `PreToolUse`
|
|
135
|
+
* `ask` decisions instead of treating them as a synchronous deny.
|
|
136
|
+
* Threaded from `RunConfig.humanInTheLoop`.
|
|
137
|
+
*/
|
|
138
|
+
humanInTheLoop: t.HumanInTheLoopConfig | undefined;
|
|
132
139
|
/**
|
|
133
140
|
* Run-scoped config for the tool output reference registry. Threaded
|
|
134
141
|
* from `RunConfig.toolOutputReferences` down into every ToolNode this
|
|
@@ -167,6 +174,7 @@ export abstract class Graph<
|
|
|
167
174
|
this.invokedToolIds = undefined;
|
|
168
175
|
this.handlerRegistry = undefined;
|
|
169
176
|
this.hookRegistry = undefined;
|
|
177
|
+
this.humanInTheLoop = undefined;
|
|
170
178
|
this.toolOutputReferences = undefined;
|
|
171
179
|
/**
|
|
172
180
|
* ToolNodes compiled from this graph captured the registry
|
|
@@ -399,12 +407,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
399
407
|
): (string | number | undefined)[] {
|
|
400
408
|
if (!metadata) return [];
|
|
401
409
|
|
|
410
|
+
const configurable = this.config?.configurable;
|
|
411
|
+
const runId =
|
|
412
|
+
(metadata.run_id as string | undefined) ??
|
|
413
|
+
(configurable?.run_id as string | undefined) ??
|
|
414
|
+
this.runId;
|
|
415
|
+
const threadId =
|
|
416
|
+
(metadata.thread_id as string | undefined) ??
|
|
417
|
+
(configurable?.thread_id as string | undefined) ??
|
|
418
|
+
runId;
|
|
419
|
+
const checkpointNs =
|
|
420
|
+
(metadata.checkpoint_ns as string | undefined) ??
|
|
421
|
+
(metadata.langgraph_checkpoint_ns as string | undefined) ??
|
|
422
|
+
'';
|
|
402
423
|
const keyList = [
|
|
403
|
-
|
|
404
|
-
|
|
424
|
+
runId,
|
|
425
|
+
threadId,
|
|
405
426
|
metadata.langgraph_node as string,
|
|
406
427
|
metadata.langgraph_step as number,
|
|
407
|
-
|
|
428
|
+
checkpointNs,
|
|
408
429
|
];
|
|
409
430
|
|
|
410
431
|
const agentContext = this.getAgentContext(metadata);
|
|
@@ -566,6 +587,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
566
587
|
toolCallStepIds: this.toolCallStepIds,
|
|
567
588
|
toolRegistry: agentContext?.toolRegistry,
|
|
568
589
|
hookRegistry: this.hookRegistry,
|
|
590
|
+
humanInTheLoop: this.humanInTheLoop,
|
|
569
591
|
directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
|
|
570
592
|
maxContextTokens: agentContext?.maxContextTokens,
|
|
571
593
|
maxToolResultChars: agentContext?.maxToolResultChars,
|
|
@@ -1461,7 +1483,14 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
1461
1483
|
}),
|
|
1462
1484
|
});
|
|
1463
1485
|
const workflow = new StateGraph(StateAnnotation)
|
|
1464
|
-
.addNode(
|
|
1486
|
+
.addNode(
|
|
1487
|
+
this.defaultAgentId,
|
|
1488
|
+
agentNode as Runnable<
|
|
1489
|
+
t.AgentSubgraphState,
|
|
1490
|
+
Partial<t.AgentSubgraphState>
|
|
1491
|
+
>,
|
|
1492
|
+
{ ends: [END] }
|
|
1493
|
+
)
|
|
1465
1494
|
.addEdge(START, this.defaultAgentId)
|
|
1466
1495
|
// LangGraph compile() types are overly strict for opt-in options
|
|
1467
1496
|
.compile(this.compileOptions as unknown as never);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
3
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
4
|
+
import type * as t from '@/types';
|
|
5
|
+
import { MultiAgentGraph } from '../MultiAgentGraph';
|
|
6
|
+
import { Constants, Providers } from '@/common';
|
|
7
|
+
import { StandardGraph } from '../Graph';
|
|
8
|
+
|
|
9
|
+
const makeAgent = (agentId: string): t.AgentInputs => ({
|
|
10
|
+
agentId,
|
|
11
|
+
provider: Providers.OPENAI,
|
|
12
|
+
instructions: `You are ${agentId}.`,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const makeConfig = (threadId: string): RunnableConfig => ({
|
|
16
|
+
configurable: {
|
|
17
|
+
thread_id: threadId,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const makeStreamConfig = (threadId: string): t.WorkflowValuesStreamConfig => ({
|
|
22
|
+
...makeConfig(threadId),
|
|
23
|
+
streamMode: 'values' as const,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const getAiContents = (messages: t.BaseGraphState['messages']): string[] =>
|
|
27
|
+
messages
|
|
28
|
+
.filter((message) => message.getType() === 'ai')
|
|
29
|
+
.map((message) => message.content)
|
|
30
|
+
.filter((content): content is string => typeof content === 'string');
|
|
31
|
+
|
|
32
|
+
const expectCompiledWorkflow = (
|
|
33
|
+
workflow: t.CompiledWorkflow | t.CompiledMultiAgentWorkflow
|
|
34
|
+
): void => {
|
|
35
|
+
expect(typeof workflow.invoke).toBe('function');
|
|
36
|
+
expect(typeof workflow.stream).toBe('function');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('LangGraph composition smoke tests', () => {
|
|
40
|
+
it('compiles and invokes the standard single-agent graph', async () => {
|
|
41
|
+
const graph = new StandardGraph({
|
|
42
|
+
runId: 'standard-smoke',
|
|
43
|
+
agents: [makeAgent('agent')],
|
|
44
|
+
});
|
|
45
|
+
graph.overrideTestModel(['standard ok']);
|
|
46
|
+
|
|
47
|
+
const workflow = graph.createWorkflow();
|
|
48
|
+
expectCompiledWorkflow(workflow);
|
|
49
|
+
|
|
50
|
+
const result = await workflow.invoke(
|
|
51
|
+
{ messages: [new HumanMessage('hello')] },
|
|
52
|
+
makeConfig('standard-smoke')
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(getAiContents(result.messages)).toEqual(['standard ok']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('streams values from the standard single-agent graph', async () => {
|
|
59
|
+
const graph = new StandardGraph({
|
|
60
|
+
runId: 'standard-stream-smoke',
|
|
61
|
+
agents: [makeAgent('agent')],
|
|
62
|
+
});
|
|
63
|
+
graph.overrideTestModel(['standard stream ok']);
|
|
64
|
+
|
|
65
|
+
const workflow = graph.createWorkflow();
|
|
66
|
+
const stream = (await workflow.stream(
|
|
67
|
+
{ messages: [new HumanMessage('hello')] },
|
|
68
|
+
makeStreamConfig('standard-stream-smoke')
|
|
69
|
+
)) as AsyncIterable<t.BaseGraphState>;
|
|
70
|
+
const chunks: t.BaseGraphState[] = [];
|
|
71
|
+
|
|
72
|
+
for await (const chunk of stream) {
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
77
|
+
expect(
|
|
78
|
+
chunks.some((chunk) =>
|
|
79
|
+
getAiContents(chunk.messages).includes('standard stream ok')
|
|
80
|
+
)
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('compiles and invokes a multi-agent graph with one agent and no edges', async () => {
|
|
85
|
+
const graph = new MultiAgentGraph({
|
|
86
|
+
runId: 'multi-single-smoke',
|
|
87
|
+
agents: [makeAgent('A')],
|
|
88
|
+
edges: [],
|
|
89
|
+
});
|
|
90
|
+
graph.overrideTestModel(['multi ok']);
|
|
91
|
+
|
|
92
|
+
const workflow = graph.createWorkflow();
|
|
93
|
+
expectCompiledWorkflow(workflow);
|
|
94
|
+
|
|
95
|
+
const result = await workflow.invoke(
|
|
96
|
+
{ messages: [new HumanMessage('hello')] },
|
|
97
|
+
makeConfig('multi-single-smoke')
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(getAiContents(result.messages)).toEqual(['multi ok']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('compiles and invokes direct sequential edges', async () => {
|
|
104
|
+
const graph = new MultiAgentGraph({
|
|
105
|
+
runId: 'direct-chain-smoke',
|
|
106
|
+
agents: [makeAgent('A'), makeAgent('B')],
|
|
107
|
+
edges: [{ from: 'A', to: 'B', edgeType: 'direct' }],
|
|
108
|
+
});
|
|
109
|
+
graph.overrideTestModel(['from A', 'from B']);
|
|
110
|
+
|
|
111
|
+
const workflow = graph.createWorkflow();
|
|
112
|
+
expectCompiledWorkflow(workflow);
|
|
113
|
+
|
|
114
|
+
const result = await workflow.invoke(
|
|
115
|
+
{ messages: [new HumanMessage('start')] },
|
|
116
|
+
makeConfig('direct-chain-smoke')
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(getAiContents(result.messages)).toEqual(['from A', 'from B']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('compiles and invokes a handoff edge using graph-managed transfer tools', async () => {
|
|
123
|
+
const transferToolCall: ToolCall = {
|
|
124
|
+
id: 'call_transfer_to_B',
|
|
125
|
+
name: `${Constants.LC_TRANSFER_TO_}B`,
|
|
126
|
+
args: { instructions: 'Take over from here.' },
|
|
127
|
+
type: 'tool_call',
|
|
128
|
+
};
|
|
129
|
+
const graph = new MultiAgentGraph({
|
|
130
|
+
runId: 'handoff-smoke',
|
|
131
|
+
agents: [makeAgent('A'), makeAgent('B')],
|
|
132
|
+
edges: [{ from: 'A', to: 'B', edgeType: 'handoff' }],
|
|
133
|
+
});
|
|
134
|
+
graph.overrideTestModel(['routing to B', 'handoff complete'], undefined, [
|
|
135
|
+
transferToolCall,
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
const workflow = graph.createWorkflow();
|
|
139
|
+
expectCompiledWorkflow(workflow);
|
|
140
|
+
|
|
141
|
+
const result = await workflow.invoke(
|
|
142
|
+
{ messages: [new HumanMessage('start')] },
|
|
143
|
+
makeConfig('handoff-smoke')
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(getAiContents(result.messages)).toContain('handoff complete');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('compiles fan-out/fan-in direct composition with prompt wrapping', () => {
|
|
150
|
+
const graph = new MultiAgentGraph({
|
|
151
|
+
runId: 'fan-in-smoke',
|
|
152
|
+
agents: [
|
|
153
|
+
makeAgent('root'),
|
|
154
|
+
makeAgent('left'),
|
|
155
|
+
makeAgent('right'),
|
|
156
|
+
makeAgent('final'),
|
|
157
|
+
],
|
|
158
|
+
edges: [
|
|
159
|
+
{ from: 'root', to: ['left', 'right'], edgeType: 'direct' },
|
|
160
|
+
{
|
|
161
|
+
from: ['left', 'right'],
|
|
162
|
+
to: 'final',
|
|
163
|
+
edgeType: 'direct',
|
|
164
|
+
prompt: 'Summarize these results:\n{results}',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expectCompiledWorkflow(graph.createWorkflow());
|
|
170
|
+
expect(graph.getParallelGroupId('root')).toBeUndefined();
|
|
171
|
+
expect(graph.getParallelGroupId('left')).toBe(1);
|
|
172
|
+
expect(graph.getParallelGroupId('right')).toBe(1);
|
|
173
|
+
expect(graph.getParallelGroupId('final')).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('compiles mixed handoff and direct routing from the same agent', () => {
|
|
177
|
+
const graph = new MultiAgentGraph({
|
|
178
|
+
runId: 'mixed-routing-smoke',
|
|
179
|
+
agents: [makeAgent('router'), makeAgent('handoff'), makeAgent('direct')],
|
|
180
|
+
edges: [
|
|
181
|
+
{ from: 'router', to: 'handoff', edgeType: 'handoff' },
|
|
182
|
+
{ from: 'router', to: 'direct', edgeType: 'direct' },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expectCompiledWorkflow(graph.createWorkflow());
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed convenience wrapper around LangGraph's `interrupt()` for the
|
|
3
|
+
* `ask_user_question` interrupt category. Lets a custom graph node
|
|
4
|
+
* (or a tool implementation) suspend execution to collect a free-form
|
|
5
|
+
* answer from the human, without the host having to assemble the
|
|
6
|
+
* interrupt payload by hand. The companion to `Run.resume(answer)` on
|
|
7
|
+
* the host side.
|
|
8
|
+
*
|
|
9
|
+
* AsyncLocalStorage anchoring: this helper does NOT call
|
|
10
|
+
* `runWithConfig` itself — it expects to be invoked from inside a
|
|
11
|
+
* LangGraph node where the framework has already established the
|
|
12
|
+
* runnable config. ToolNode is the one place in this codebase that
|
|
13
|
+
* needs the manual `runWithConfig` shim, because its
|
|
14
|
+
* `RunnableCallable.trace = false` skips the upstream tracing path
|
|
15
|
+
* that normally sets up the AsyncLocalStorage frame; ordinary user
|
|
16
|
+
* nodes (RunnableLambda, addNode callbacks) do not have that
|
|
17
|
+
* constraint.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { interrupt } from '@langchain/langgraph';
|
|
21
|
+
import type {
|
|
22
|
+
AskUserQuestionRequest,
|
|
23
|
+
AskUserQuestionResolution,
|
|
24
|
+
AskUserQuestionInterruptPayload,
|
|
25
|
+
} from '@/types/hitl';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Suspend the current graph node to ask the human a question. Returns
|
|
29
|
+
* the host-supplied resolution after `Run.resume(resolution)` is
|
|
30
|
+
* called against a Run rebuilt with the same `thread_id` and
|
|
31
|
+
* checkpointer.
|
|
32
|
+
*
|
|
33
|
+
* On the FIRST call (no resume value available), `interrupt()` throws
|
|
34
|
+
* a `GraphInterrupt` that LangGraph catches; this function does not
|
|
35
|
+
* return — execution unwinds, the SDK persists the checkpoint, and
|
|
36
|
+
* the run completes with `run.getInterrupt()` returning a
|
|
37
|
+
* `RunInterruptResult` whose `payload` is an
|
|
38
|
+
* `AskUserQuestionInterruptPayload`.
|
|
39
|
+
*
|
|
40
|
+
* On RESUME, LangGraph re-runs the node from the start and this call
|
|
41
|
+
* returns the host's `AskUserQuestionResolution` directly.
|
|
42
|
+
*
|
|
43
|
+
* Hosts that prefer the raw `interrupt()` (e.g., to attach extra
|
|
44
|
+
* metadata) can construct an `AskUserQuestionInterruptPayload` and
|
|
45
|
+
* call `interrupt()` themselves — this helper is purely convenience.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const builder = new StateGraph(MessagesAnnotation)
|
|
50
|
+
* .addNode('clarifier', () => {
|
|
51
|
+
* const { answer } = askUserQuestion({
|
|
52
|
+
* question: 'Which environment should I deploy to?',
|
|
53
|
+
* options: [
|
|
54
|
+
* { label: 'Staging', value: 'staging' },
|
|
55
|
+
* { label: 'Production', value: 'production' },
|
|
56
|
+
* ],
|
|
57
|
+
* });
|
|
58
|
+
* return { messages: [new HumanMessage(`Use ${answer}`)] };
|
|
59
|
+
* });
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function askUserQuestion(
|
|
63
|
+
question: AskUserQuestionRequest
|
|
64
|
+
): AskUserQuestionResolution {
|
|
65
|
+
const payload: AskUserQuestionInterruptPayload = {
|
|
66
|
+
type: 'ask_user_question',
|
|
67
|
+
question,
|
|
68
|
+
};
|
|
69
|
+
return interrupt<AskUserQuestionInterruptPayload, AskUserQuestionResolution>(
|
|
70
|
+
payload
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -13,6 +13,20 @@ import type { HookEvent, HookMatcher } from './types';
|
|
|
13
13
|
*/
|
|
14
14
|
type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Snapshot of a halt request raised by a hook returning
|
|
18
|
+
* `preventContinuation: true`. The SDK's run loop polls for this between
|
|
19
|
+
* stream events and exits cleanly when set, skipping the `Stop` hook
|
|
20
|
+
* (the run is being halted, not naturally completing). One per registry
|
|
21
|
+
* instance — the first hook to halt wins; subsequent halts are ignored
|
|
22
|
+
* so the original reason isn't clobbered.
|
|
23
|
+
*/
|
|
24
|
+
export interface HookHaltSignal {
|
|
25
|
+
reason: string;
|
|
26
|
+
/** Event of the hook that triggered the halt (for diagnostics). */
|
|
27
|
+
source: HookEvent;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/**
|
|
17
31
|
* Run-scoped storage for hook matchers with an additional layer for
|
|
18
32
|
* session-scoped matchers that should be cleaned up between sessions.
|
|
@@ -34,6 +48,18 @@ type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
|
|
|
34
48
|
export class HookRegistry {
|
|
35
49
|
private readonly global: MatcherBucket = {};
|
|
36
50
|
private readonly sessions: Map<string, MatcherBucket> = new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Per-session halt signals. Scoped by `sessionId` (= the run id the
|
|
53
|
+
* hook fired under) so a host that shares one registry across
|
|
54
|
+
* concurrent runs cannot leak `preventContinuation` from one run
|
|
55
|
+
* into another. Without scoping, a halt raised by run A's hook
|
|
56
|
+
* would trip run B's stream-loop poll on the next iteration —
|
|
57
|
+
* silently terminating an unrelated run.
|
|
58
|
+
*
|
|
59
|
+
* Map storage mirrors the reasoning above for session matchers:
|
|
60
|
+
* O(1) insertion in hot paths, no spread-on-write.
|
|
61
|
+
*/
|
|
62
|
+
private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
|
|
37
63
|
|
|
38
64
|
/**
|
|
39
65
|
* Register a matcher for the lifetime of this registry (= one Run).
|
|
@@ -125,6 +151,51 @@ export class HookRegistry {
|
|
|
125
151
|
this.sessions.delete(sessionId);
|
|
126
152
|
}
|
|
127
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Raise a halt signal scoped to `sessionId` (= the run id the hook
|
|
156
|
+
* fired under). The SDK's run loop polls for this between stream
|
|
157
|
+
* events with the run's own id. First-write-wins per session: a
|
|
158
|
+
* halt already raised by an earlier hook in the same run is
|
|
159
|
+
* preserved so the original `reason` / `source` aren't overwritten.
|
|
160
|
+
*
|
|
161
|
+
* Per-session scoping is critical when hosts share one registry
|
|
162
|
+
* across concurrent runs (e.g. a global policy registered once and
|
|
163
|
+
* reused). Without it, a `preventContinuation` from run A would
|
|
164
|
+
* trip run B's stream-loop poll on the next iteration and silently
|
|
165
|
+
* terminate an unrelated run.
|
|
166
|
+
*
|
|
167
|
+
* Called by the SDK after `executeHooks` returns an aggregate with
|
|
168
|
+
* `preventContinuation: true`. Hosts can also call it directly from
|
|
169
|
+
* inside a hook callback if they want to halt without going through
|
|
170
|
+
* the aggregated return value, but `preventContinuation` is the
|
|
171
|
+
* canonical path.
|
|
172
|
+
*/
|
|
173
|
+
haltRun(sessionId: string, reason: string, source: HookEvent): void {
|
|
174
|
+
if (this.haltSignals.has(sessionId)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.haltSignals.set(sessionId, { reason, source });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns the halt signal raised by hooks running under `sessionId`,
|
|
182
|
+
* or `undefined` if no hook in that run has halted. Polled by
|
|
183
|
+
* `Run.processStream` between stream events using the run's own id.
|
|
184
|
+
*/
|
|
185
|
+
getHaltSignal(sessionId: string): HookHaltSignal | undefined {
|
|
186
|
+
return this.haltSignals.get(sessionId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Clears the halt signal for `sessionId`. Called by
|
|
191
|
+
* `Run.processStream` in its `finally` block so a subsequent
|
|
192
|
+
* invocation of the same Run (e.g. resume) starts with a fresh
|
|
193
|
+
* halt state. No-op when no signal exists for that session.
|
|
194
|
+
*/
|
|
195
|
+
clearHaltSignal(sessionId: string): void {
|
|
196
|
+
this.haltSignals.delete(sessionId);
|
|
197
|
+
}
|
|
198
|
+
|
|
128
199
|
/** True if at least one matcher exists for `event` (global + session). */
|
|
129
200
|
hasHookFor(event: HookEvent, sessionId?: string): boolean {
|
|
130
201
|
if (readList(this.global, event).length > 0) {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import type {
|
|
3
|
+
HookCallback,
|
|
4
|
+
PreToolUseHookInput,
|
|
5
|
+
PreToolUseHookOutput,
|
|
6
|
+
} from '../types';
|
|
7
|
+
import { createToolPolicyHook } from '../createToolPolicyHook';
|
|
8
|
+
|
|
9
|
+
const baseInput: Omit<PreToolUseHookInput, 'toolName'> = {
|
|
10
|
+
hook_event_name: 'PreToolUse',
|
|
11
|
+
runId: 'r-1',
|
|
12
|
+
toolInput: {},
|
|
13
|
+
toolUseId: 'call-1',
|
|
14
|
+
stepId: 'step-1',
|
|
15
|
+
turn: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function callHook(
|
|
19
|
+
hook: HookCallback<'PreToolUse'>,
|
|
20
|
+
toolName: string
|
|
21
|
+
): Promise<PreToolUseHookOutput> {
|
|
22
|
+
const signal = new AbortController().signal;
|
|
23
|
+
return await hook({ ...baseInput, toolName }, signal);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('createToolPolicyHook — default mode', () => {
|
|
27
|
+
it('asks for tools that match no rule', async () => {
|
|
28
|
+
const hook = createToolPolicyHook({ mode: 'default' });
|
|
29
|
+
expect((await callHook(hook, 'unknown_tool')).decision).toBe('ask');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('allows tools that match an allow pattern', async () => {
|
|
33
|
+
const hook = createToolPolicyHook({
|
|
34
|
+
mode: 'default',
|
|
35
|
+
allow: ['read_file', 'grep'],
|
|
36
|
+
});
|
|
37
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
38
|
+
expect((await callHook(hook, 'grep')).decision).toBe('allow');
|
|
39
|
+
expect((await callHook(hook, 'write_file')).decision).toBe('ask');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('denies tools that match a deny pattern', async () => {
|
|
43
|
+
const hook = createToolPolicyHook({
|
|
44
|
+
mode: 'default',
|
|
45
|
+
deny: ['delete_*'],
|
|
46
|
+
});
|
|
47
|
+
expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
|
|
48
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('ask');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('asks tools that match an ask pattern (redundant in default mode but explicit)', async () => {
|
|
52
|
+
const hook = createToolPolicyHook({
|
|
53
|
+
mode: 'default',
|
|
54
|
+
ask: ['execute_*'],
|
|
55
|
+
});
|
|
56
|
+
expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('createToolPolicyHook — dontAsk mode', () => {
|
|
61
|
+
it('denies tools that match no rule (no human prompt)', async () => {
|
|
62
|
+
const hook = createToolPolicyHook({ mode: 'dontAsk' });
|
|
63
|
+
expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('still allows tools that match an allow pattern', async () => {
|
|
67
|
+
const hook = createToolPolicyHook({
|
|
68
|
+
mode: 'dontAsk',
|
|
69
|
+
allow: ['read_*'],
|
|
70
|
+
});
|
|
71
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
72
|
+
expect((await callHook(hook, 'write_file')).decision).toBe('deny');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('still asks tools that match an explicit ask pattern (overrides dontAsk default)', async () => {
|
|
76
|
+
const hook = createToolPolicyHook({
|
|
77
|
+
mode: 'dontAsk',
|
|
78
|
+
ask: ['execute_*'],
|
|
79
|
+
});
|
|
80
|
+
expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
|
|
81
|
+
expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('createToolPolicyHook — bypass mode', () => {
|
|
86
|
+
it('allows everything by default', async () => {
|
|
87
|
+
const hook = createToolPolicyHook({ mode: 'bypass' });
|
|
88
|
+
expect((await callHook(hook, 'anything')).decision).toBe('allow');
|
|
89
|
+
expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('still denies tools that match a deny pattern (deny always wins)', async () => {
|
|
93
|
+
const hook = createToolPolicyHook({
|
|
94
|
+
mode: 'bypass',
|
|
95
|
+
deny: ['delete_*'],
|
|
96
|
+
});
|
|
97
|
+
expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
|
|
98
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('overrides explicit ask patterns (bypass means stop asking)', async () => {
|
|
102
|
+
const hook = createToolPolicyHook({
|
|
103
|
+
mode: 'bypass',
|
|
104
|
+
ask: ['execute_*'],
|
|
105
|
+
});
|
|
106
|
+
expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('createToolPolicyHook — pattern matching', () => {
|
|
111
|
+
it('matches glob `*` wildcards', async () => {
|
|
112
|
+
const hook = createToolPolicyHook({
|
|
113
|
+
mode: 'default',
|
|
114
|
+
allow: ['mcp:github:*'],
|
|
115
|
+
});
|
|
116
|
+
expect((await callHook(hook, 'mcp:github:create_issue')).decision).toBe(
|
|
117
|
+
'allow'
|
|
118
|
+
);
|
|
119
|
+
expect((await callHook(hook, 'mcp:github:list_repos')).decision).toBe(
|
|
120
|
+
'allow'
|
|
121
|
+
);
|
|
122
|
+
expect((await callHook(hook, 'mcp:slack:post')).decision).toBe('ask');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('matches exact tool names', async () => {
|
|
126
|
+
const hook = createToolPolicyHook({
|
|
127
|
+
mode: 'default',
|
|
128
|
+
allow: ['read_file'],
|
|
129
|
+
});
|
|
130
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
131
|
+
expect((await callHook(hook, 'read_file_lines')).decision).toBe('ask');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('escapes regex metacharacters in literal portions', async () => {
|
|
135
|
+
const hook = createToolPolicyHook({
|
|
136
|
+
mode: 'default',
|
|
137
|
+
allow: ['tool.with.dots'],
|
|
138
|
+
});
|
|
139
|
+
expect((await callHook(hook, 'tool.with.dots')).decision).toBe('allow');
|
|
140
|
+
/** A literal regex `.` would also match `tool_with_dots`; glob shouldn't. */
|
|
141
|
+
expect((await callHook(hook, 'tool_with_dots')).decision).toBe('ask');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('matches wildcards in the middle and end', async () => {
|
|
145
|
+
const hook = createToolPolicyHook({
|
|
146
|
+
mode: 'default',
|
|
147
|
+
ask: ['*search*'],
|
|
148
|
+
});
|
|
149
|
+
expect((await callHook(hook, 'web_search')).decision).toBe('ask');
|
|
150
|
+
expect((await callHook(hook, 'searcher')).decision).toBe('ask');
|
|
151
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('ask'); // default mode
|
|
152
|
+
/** Confirm the ask path tagged it (not the fallthrough): explicit ask hits before mode fallthrough. */
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('createToolPolicyHook — precedence', () => {
|
|
157
|
+
it('deny wins over allow', async () => {
|
|
158
|
+
const hook = createToolPolicyHook({
|
|
159
|
+
mode: 'default',
|
|
160
|
+
allow: ['read_*'],
|
|
161
|
+
deny: ['read_secret'],
|
|
162
|
+
});
|
|
163
|
+
expect((await callHook(hook, 'read_secret')).decision).toBe('deny');
|
|
164
|
+
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('deny wins over bypass mode', async () => {
|
|
168
|
+
const hook = createToolPolicyHook({
|
|
169
|
+
mode: 'bypass',
|
|
170
|
+
deny: ['delete_*'],
|
|
171
|
+
});
|
|
172
|
+
expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
|
|
173
|
+
expect((await callHook(hook, 'anything_else')).decision).toBe('allow');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('allow wins over ask in default mode', async () => {
|
|
177
|
+
const hook = createToolPolicyHook({
|
|
178
|
+
mode: 'default',
|
|
179
|
+
allow: ['execute_safe'],
|
|
180
|
+
ask: ['execute_*'],
|
|
181
|
+
});
|
|
182
|
+
expect((await callHook(hook, 'execute_safe')).decision).toBe('allow');
|
|
183
|
+
expect((await callHook(hook, 'execute_dangerous')).decision).toBe('ask');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('createToolPolicyHook — reason', () => {
|
|
188
|
+
it('attaches the configured reason to ask and deny decisions', async () => {
|
|
189
|
+
const hook = createToolPolicyHook({
|
|
190
|
+
mode: 'default',
|
|
191
|
+
deny: ['delete_*'],
|
|
192
|
+
reason: 'Tool {tool} requires manual review',
|
|
193
|
+
});
|
|
194
|
+
const denied = await callHook(hook, 'delete_file');
|
|
195
|
+
expect(denied.decision).toBe('deny');
|
|
196
|
+
expect(denied.reason).toBe('Tool delete_file requires manual review');
|
|
197
|
+
|
|
198
|
+
const asked = await callHook(hook, 'unknown_tool');
|
|
199
|
+
expect(asked.decision).toBe('ask');
|
|
200
|
+
expect(asked.reason).toBe('Tool unknown_tool requires manual review');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('omits the reason field for allow decisions', async () => {
|
|
204
|
+
const hook = createToolPolicyHook({
|
|
205
|
+
mode: 'default',
|
|
206
|
+
allow: ['read_*'],
|
|
207
|
+
reason: 'never seen',
|
|
208
|
+
});
|
|
209
|
+
const result = await callHook(hook, 'read_file');
|
|
210
|
+
expect(result.decision).toBe('allow');
|
|
211
|
+
expect(result.reason).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not add a reason field when no template is configured', async () => {
|
|
215
|
+
const hook = createToolPolicyHook({ mode: 'dontAsk' });
|
|
216
|
+
const result = await callHook(hook, 'unknown_tool');
|
|
217
|
+
expect(result.decision).toBe('deny');
|
|
218
|
+
expect(result.reason).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('createToolPolicyHook — registry integration', () => {
|
|
223
|
+
it('works when registered as a PreToolUse hook (round-trip via executeHooks)', async () => {
|
|
224
|
+
const { HookRegistry, executeHooks } = await import('../index');
|
|
225
|
+
const registry = new HookRegistry();
|
|
226
|
+
registry.register('PreToolUse', {
|
|
227
|
+
hooks: [
|
|
228
|
+
createToolPolicyHook({
|
|
229
|
+
mode: 'default',
|
|
230
|
+
allow: ['read_file'],
|
|
231
|
+
deny: ['delete_*'],
|
|
232
|
+
reason: 'review {tool}',
|
|
233
|
+
}),
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const allow = await executeHooks({
|
|
238
|
+
registry,
|
|
239
|
+
input: { ...baseInput, toolName: 'read_file' },
|
|
240
|
+
matchQuery: 'read_file',
|
|
241
|
+
});
|
|
242
|
+
expect(allow.decision).toBe('allow');
|
|
243
|
+
|
|
244
|
+
const deny = await executeHooks({
|
|
245
|
+
registry,
|
|
246
|
+
input: { ...baseInput, toolName: 'delete_file' },
|
|
247
|
+
matchQuery: 'delete_file',
|
|
248
|
+
});
|
|
249
|
+
expect(deny.decision).toBe('deny');
|
|
250
|
+
expect(deny.reason).toBe('review delete_file');
|
|
251
|
+
|
|
252
|
+
const ask = await executeHooks({
|
|
253
|
+
registry,
|
|
254
|
+
input: { ...baseInput, toolName: 'mystery_tool' },
|
|
255
|
+
matchQuery: 'mystery_tool',
|
|
256
|
+
});
|
|
257
|
+
expect(ask.decision).toBe('ask');
|
|
258
|
+
});
|
|
259
|
+
});
|