@librechat/agents 3.1.77-dev.1 → 3.1.78-dev.0
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/enum.cjs +54 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +148 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +317 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +90 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
- package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +27 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/recency.cjs +99 -0
- package/dist/cjs/messages/recency.cjs.map +1 -0
- package/dist/cjs/run.cjs +30 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +100 -6
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +635 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/local/attachments.cjs +183 -0
- package/dist/cjs/tools/local/attachments.cjs.map +1 -0
- package/dist/cjs/tools/local/bashAst.cjs +129 -0
- package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
- package/dist/cjs/tools/local/editStrategies.cjs +188 -0
- package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
- package/dist/cjs/tools/local/textEncoding.cjs +30 -0
- package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
- package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +53 -1
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +149 -5
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +318 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +17 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/anthropicToolCache.mjs +99 -0
- package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +26 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/recency.mjs +97 -0
- package/dist/esm/messages/recency.mjs.map +1 -0
- package/dist/esm/run.mjs +30 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +100 -6
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +635 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/local/attachments.mjs +180 -0
- package/dist/esm/tools/local/attachments.mjs.map +1 -0
- package/dist/esm/tools/local/bashAst.mjs +126 -0
- package/dist/esm/tools/local/bashAst.mjs.map +1 -0
- package/dist/esm/tools/local/editStrategies.mjs +185 -0
- package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
- package/dist/esm/tools/local/textEncoding.mjs +27 -0
- package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
- package/dist/esm/tools/local/workspaceFS.mjs +49 -0
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +39 -1
- package/dist/types/graphs/Graph.d.ts +34 -0
- package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
- package/dist/types/hooks/index.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/llm/openai/index.d.ts +17 -0
- package/dist/types/messages/anthropicToolCache.d.ts +51 -0
- package/dist/types/messages/index.d.ts +2 -0
- package/dist/types/messages/prune.d.ts +11 -0
- package/dist/types/messages/recency.d.ts +64 -0
- package/dist/types/run.d.ts +21 -0
- package/dist/types/tools/ToolNode.d.ts +145 -2
- package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
- package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
- package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
- package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
- package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
- package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
- package/dist/types/tools/local/attachments.d.ts +84 -0
- package/dist/types/tools/local/bashAst.d.ts +11 -0
- package/dist/types/tools/local/editStrategies.d.ts +28 -0
- package/dist/types/tools/local/index.d.ts +12 -0
- package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
- package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
- package/dist/types/tools/local/textEncoding.d.ts +21 -0
- package/dist/types/tools/local/workspaceFS.d.ts +49 -0
- package/dist/types/types/hitl.d.ts +56 -27
- package/dist/types/types/run.d.ts +8 -1
- package/dist/types/types/summarize.d.ts +30 -0
- package/dist/types/types/tools.d.ts +341 -6
- package/package.json +21 -2
- package/src/common/enum.ts +54 -0
- package/src/graphs/Graph.ts +164 -6
- package/src/hooks/__tests__/compactHooks.test.ts +38 -2
- package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
- package/src/hooks/createWorkspacePolicyHook.ts +355 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/llm/openai/deepseek.test.ts +479 -0
- package/src/llm/openai/index.ts +484 -1
- package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
- package/src/messages/__tests__/recency.test.ts +267 -0
- package/src/messages/anthropicToolCache.ts +116 -0
- package/src/messages/index.ts +2 -0
- package/src/messages/prune.ts +27 -1
- package/src/messages/recency.ts +155 -0
- package/src/run.ts +31 -0
- package/src/scripts/compare_pi_vs_ours.ts +840 -0
- package/src/scripts/local_engine.ts +166 -0
- package/src/scripts/local_engine_checkpointer.ts +205 -0
- package/src/scripts/local_engine_compile.ts +263 -0
- package/src/scripts/local_engine_hooks.ts +226 -0
- package/src/scripts/local_engine_image.ts +201 -0
- package/src/scripts/local_engine_ptc.ts +151 -0
- package/src/scripts/local_engine_workspace.ts +258 -0
- package/src/scripts/summarization-recency.ts +462 -0
- package/src/specs/prune.test.ts +39 -0
- package/src/summarization/__tests__/node.test.ts +499 -3
- package/src/summarization/node.ts +124 -7
- package/src/tools/ToolNode.ts +769 -20
- package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
- package/src/tools/__tests__/directToolHooks.test.ts +411 -0
- package/src/tools/__tests__/localToolNames.test.ts +73 -0
- package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
- package/src/tools/local/CompileCheckTool.ts +278 -0
- package/src/tools/local/FileCheckpointer.ts +93 -0
- package/src/tools/local/LocalCodingTools.ts +1342 -0
- package/src/tools/local/LocalExecutionEngine.ts +1329 -0
- package/src/tools/local/LocalExecutionTools.ts +167 -0
- package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
- package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
- package/src/tools/local/attachments.ts +251 -0
- package/src/tools/local/bashAst.ts +151 -0
- package/src/tools/local/editStrategies.ts +188 -0
- package/src/tools/local/index.ts +12 -0
- package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
- package/src/tools/local/syntaxCheck.ts +243 -0
- package/src/tools/local/textEncoding.ts +37 -0
- package/src/tools/local/workspaceFS.ts +89 -0
- package/src/types/hitl.ts +56 -27
- package/src/types/run.ts +12 -1
- package/src/types/summarize.ts +31 -0
- package/src/types/tools.ts +359 -7
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
4
|
+
import { describe, it, expect, jest, afterEach } from '@jest/globals';
|
|
5
|
+
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
6
|
+
import type {
|
|
7
|
+
PreToolUseHookOutput,
|
|
8
|
+
PostToolUseHookOutput,
|
|
9
|
+
PostToolUseFailureHookOutput,
|
|
10
|
+
PermissionDeniedHookOutput,
|
|
11
|
+
} from '@/hooks';
|
|
12
|
+
import { HookRegistry } from '@/hooks';
|
|
13
|
+
import { ToolNode } from '../ToolNode';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Direct-tool helper: returns a real `StructuredToolInterface` whose
|
|
17
|
+
* `func` runs in-process. Once registered as a graphTool, the ToolNode
|
|
18
|
+
* marks it `direct` (skipping the host event-dispatch path) — the path
|
|
19
|
+
* we are testing fires lifecycle hooks around.
|
|
20
|
+
*/
|
|
21
|
+
function createDirectTool(
|
|
22
|
+
name: string,
|
|
23
|
+
impl: (args: Record<string, unknown>) => string | Promise<string>
|
|
24
|
+
): StructuredToolInterface {
|
|
25
|
+
return tool(async (args: Record<string, unknown>) => impl(args), {
|
|
26
|
+
name,
|
|
27
|
+
description: `direct in-process tool ${name}`,
|
|
28
|
+
schema: z.object({ command: z.string().optional() }).passthrough(),
|
|
29
|
+
}) as unknown as StructuredToolInterface;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function aiCall(
|
|
33
|
+
callId: string,
|
|
34
|
+
name: string,
|
|
35
|
+
args: Record<string, unknown>
|
|
36
|
+
): AIMessage {
|
|
37
|
+
return new AIMessage({
|
|
38
|
+
content: '',
|
|
39
|
+
tool_calls: [{ id: callId, name, args }],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toolMessages(result: unknown): ToolMessage[] {
|
|
44
|
+
if (Array.isArray(result)) {
|
|
45
|
+
return result as ToolMessage[];
|
|
46
|
+
}
|
|
47
|
+
const obj = result as { messages: ToolMessage[] };
|
|
48
|
+
return obj.messages;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Direct-path lifecycle hooks (in-process tools)', () => {
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
jest.restoreAllMocks();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('PreToolUse decision: deny replaces the tool result with a Blocked ToolMessage and fires PermissionDenied', async () => {
|
|
57
|
+
const echo = createDirectTool('echo', () => 'EXECUTED');
|
|
58
|
+
|
|
59
|
+
const registry = new HookRegistry();
|
|
60
|
+
registry.register('PreToolUse', {
|
|
61
|
+
hooks: [
|
|
62
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
63
|
+
decision: 'deny',
|
|
64
|
+
reason: 'policy-deny',
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
const permissionDenied = jest.fn(
|
|
69
|
+
async (): Promise<PermissionDeniedHookOutput> => ({})
|
|
70
|
+
);
|
|
71
|
+
registry.register('PermissionDenied', { hooks: [permissionDenied] });
|
|
72
|
+
|
|
73
|
+
const node = new ToolNode({
|
|
74
|
+
tools: [echo],
|
|
75
|
+
eventDrivenMode: true,
|
|
76
|
+
hookRegistry: registry,
|
|
77
|
+
directToolNames: new Set(['echo']),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await node.invoke({
|
|
81
|
+
messages: [aiCall('call_1', 'echo', { command: 'rm -rf /' })],
|
|
82
|
+
});
|
|
83
|
+
const [message] = toolMessages(result);
|
|
84
|
+
|
|
85
|
+
expect(message.status).toBe('error');
|
|
86
|
+
expect(String(message.content)).toContain('Blocked: policy-deny');
|
|
87
|
+
expect(String(message.content)).not.toContain('EXECUTED');
|
|
88
|
+
|
|
89
|
+
// Event handlers can be async — flush the microtask queue once.
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
expect(permissionDenied).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('PreToolUse decision: ask is fail-closed when humanInTheLoop is disabled', async () => {
|
|
95
|
+
const echo = createDirectTool('echo', () => 'EXECUTED');
|
|
96
|
+
|
|
97
|
+
const registry = new HookRegistry();
|
|
98
|
+
registry.register('PreToolUse', {
|
|
99
|
+
hooks: [
|
|
100
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
101
|
+
decision: 'ask',
|
|
102
|
+
reason: 'needs-review',
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const node = new ToolNode({
|
|
108
|
+
tools: [echo],
|
|
109
|
+
eventDrivenMode: true,
|
|
110
|
+
hookRegistry: registry,
|
|
111
|
+
directToolNames: new Set(['echo']),
|
|
112
|
+
// humanInTheLoop intentionally not set — fail-closed.
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = await node.invoke({
|
|
116
|
+
messages: [aiCall('call_2', 'echo', { command: 'whoami' })],
|
|
117
|
+
});
|
|
118
|
+
const [message] = toolMessages(result);
|
|
119
|
+
|
|
120
|
+
expect(message.status).toBe('error');
|
|
121
|
+
expect(String(message.content)).toContain('Blocked: needs-review');
|
|
122
|
+
expect(String(message.content)).not.toContain('EXECUTED');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('PreToolUse decision: allow runs the tool unchanged', async () => {
|
|
126
|
+
const echo = createDirectTool('echo', (args) => `ran:${args.command}`);
|
|
127
|
+
|
|
128
|
+
const registry = new HookRegistry();
|
|
129
|
+
registry.register('PreToolUse', {
|
|
130
|
+
hooks: [
|
|
131
|
+
async (): Promise<PreToolUseHookOutput> => ({ decision: 'allow' }),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const node = new ToolNode({
|
|
136
|
+
tools: [echo],
|
|
137
|
+
eventDrivenMode: true,
|
|
138
|
+
hookRegistry: registry,
|
|
139
|
+
directToolNames: new Set(['echo']),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = await node.invoke({
|
|
143
|
+
messages: [aiCall('call_3', 'echo', { command: 'ls' })],
|
|
144
|
+
});
|
|
145
|
+
const [message] = toolMessages(result);
|
|
146
|
+
expect(message.status).toBe('success');
|
|
147
|
+
expect(String(message.content)).toBe('ran:ls');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('PreToolUse updatedInput rewrites the args before runTool sees them', async () => {
|
|
151
|
+
const seen: Record<string, unknown>[] = [];
|
|
152
|
+
const echo = createDirectTool('echo', (args) => {
|
|
153
|
+
seen.push(args);
|
|
154
|
+
return `ran:${String(args.command)}`;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const registry = new HookRegistry();
|
|
158
|
+
registry.register('PreToolUse', {
|
|
159
|
+
hooks: [
|
|
160
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
161
|
+
decision: 'allow',
|
|
162
|
+
updatedInput: { command: 'redacted' },
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const node = new ToolNode({
|
|
168
|
+
tools: [echo],
|
|
169
|
+
eventDrivenMode: true,
|
|
170
|
+
hookRegistry: registry,
|
|
171
|
+
directToolNames: new Set(['echo']),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = await node.invoke({
|
|
175
|
+
messages: [aiCall('call_4', 'echo', { command: 'secret' })],
|
|
176
|
+
});
|
|
177
|
+
const [message] = toolMessages(result);
|
|
178
|
+
expect(String(message.content)).toBe('ran:redacted');
|
|
179
|
+
expect(seen[0]).toMatchObject({ command: 'redacted' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('PostToolUse updatedOutput replaces the tool message content', async () => {
|
|
183
|
+
const echo = createDirectTool('echo', () => 'ORIGINAL');
|
|
184
|
+
|
|
185
|
+
const registry = new HookRegistry();
|
|
186
|
+
registry.register('PostToolUse', {
|
|
187
|
+
hooks: [
|
|
188
|
+
async (): Promise<PostToolUseHookOutput> => ({
|
|
189
|
+
updatedOutput: 'REPLACED',
|
|
190
|
+
}),
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const node = new ToolNode({
|
|
195
|
+
tools: [echo],
|
|
196
|
+
eventDrivenMode: true,
|
|
197
|
+
hookRegistry: registry,
|
|
198
|
+
directToolNames: new Set(['echo']),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const result = await node.invoke({
|
|
202
|
+
messages: [aiCall('call_5', 'echo', { command: 'x' })],
|
|
203
|
+
});
|
|
204
|
+
const [message] = toolMessages(result);
|
|
205
|
+
expect(String(message.content)).toBe('REPLACED');
|
|
206
|
+
expect(message.status).toBe('success');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('PostToolUseFailure observes errors thrown by the tool', async () => {
|
|
210
|
+
const failing = createDirectTool('boom', () => {
|
|
211
|
+
throw new Error('kaboom');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const failure = jest.fn(
|
|
215
|
+
async (): Promise<PostToolUseFailureHookOutput> => ({})
|
|
216
|
+
);
|
|
217
|
+
const registry = new HookRegistry();
|
|
218
|
+
registry.register('PostToolUseFailure', { hooks: [failure] });
|
|
219
|
+
|
|
220
|
+
const node = new ToolNode({
|
|
221
|
+
tools: [failing],
|
|
222
|
+
eventDrivenMode: true,
|
|
223
|
+
hookRegistry: registry,
|
|
224
|
+
directToolNames: new Set(['boom']),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await node.invoke({
|
|
228
|
+
messages: [aiCall('call_6', 'boom', { command: 'x' })],
|
|
229
|
+
});
|
|
230
|
+
const [message] = toolMessages(result);
|
|
231
|
+
expect(message.status).toBe('error');
|
|
232
|
+
expect(String(message.content)).toContain('kaboom');
|
|
233
|
+
|
|
234
|
+
await Promise.resolve();
|
|
235
|
+
expect(failure).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('no-hooks fast path: when no relevant hooks registered, runs runTool directly without overhead', async () => {
|
|
239
|
+
const echo = createDirectTool('echo', () => 'fast-path');
|
|
240
|
+
|
|
241
|
+
const registry = new HookRegistry();
|
|
242
|
+
// Only register an unrelated event so hasHookFor returns false for
|
|
243
|
+
// PreToolUse / PostToolUse / PostToolUseFailure.
|
|
244
|
+
registry.register('RunStart', {
|
|
245
|
+
hooks: [async (): Promise<Record<string, never>> => ({})],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const node = new ToolNode({
|
|
249
|
+
tools: [echo],
|
|
250
|
+
eventDrivenMode: true,
|
|
251
|
+
hookRegistry: registry,
|
|
252
|
+
directToolNames: new Set(['echo']),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const result = await node.invoke({
|
|
256
|
+
messages: [aiCall('call_7', 'echo', { command: 'x' })],
|
|
257
|
+
});
|
|
258
|
+
const [message] = toolMessages(result);
|
|
259
|
+
expect(String(message.content)).toBe('fast-path');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('mixed batch: PreToolUse deny on a direct tool runs the surviving tool only', async () => {
|
|
263
|
+
const allowed = createDirectTool('allowed', () => 'allowed-ran');
|
|
264
|
+
const denied = createDirectTool('denied', () => 'should-not-run');
|
|
265
|
+
|
|
266
|
+
const registry = new HookRegistry();
|
|
267
|
+
registry.register('PreToolUse', {
|
|
268
|
+
hooks: [
|
|
269
|
+
async ({ toolName }): Promise<PreToolUseHookOutput> =>
|
|
270
|
+
toolName === 'denied'
|
|
271
|
+
? { decision: 'deny', reason: 'no-no' }
|
|
272
|
+
: { decision: 'allow' },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const node = new ToolNode({
|
|
277
|
+
tools: [allowed, denied],
|
|
278
|
+
eventDrivenMode: true,
|
|
279
|
+
hookRegistry: registry,
|
|
280
|
+
directToolNames: new Set(['allowed', 'denied']),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await node.invoke({
|
|
284
|
+
messages: [
|
|
285
|
+
new AIMessage({
|
|
286
|
+
content: '',
|
|
287
|
+
tool_calls: [
|
|
288
|
+
{ id: 'call_a', name: 'allowed', args: { command: 'a' } },
|
|
289
|
+
{ id: 'call_b', name: 'denied', args: { command: 'b' } },
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
const messages = toolMessages(result);
|
|
295
|
+
expect(messages).toHaveLength(2);
|
|
296
|
+
const byId = new Map(messages.map((m) => [m.tool_call_id, m]));
|
|
297
|
+
expect(String(byId.get('call_a')?.content)).toBe('allowed-ran');
|
|
298
|
+
expect(String(byId.get('call_b')?.content)).toContain('Blocked: no-no');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('PreToolUse `turn` matches the per-tool index the body actually executes under (Codex P2 #27)', async () => {
|
|
302
|
+
// Three parallel direct calls of the same tool. Pre-fix: each
|
|
303
|
+
// hook read `turn = toolUsageCount.get('echo') ?? 0` BEFORE any
|
|
304
|
+
// await, so all three saw 0; runTool then incremented inside its
|
|
305
|
+
// own scope and the bodies ran as 0/1/2. Hook → tool got
|
|
306
|
+
// misaligned, breaking host policies that key on
|
|
307
|
+
// (toolName, turn). With the fix, the increment is hoisted into
|
|
308
|
+
// runDirectToolWithLifecycleHooks (sync, before any await) and
|
|
309
|
+
// threaded into runTool via batchContext so both observe the
|
|
310
|
+
// same value.
|
|
311
|
+
const hookTurns: number[] = [];
|
|
312
|
+
const bodyTurns: number[] = [];
|
|
313
|
+
let bodyCount = 0;
|
|
314
|
+
const echo = createDirectTool('echo', () => {
|
|
315
|
+
bodyCount += 1;
|
|
316
|
+
return 'EXECUTED';
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const registry = new HookRegistry();
|
|
320
|
+
registry.register('PreToolUse', {
|
|
321
|
+
hooks: [
|
|
322
|
+
async (input): Promise<PreToolUseHookOutput> => {
|
|
323
|
+
if (typeof input.turn === 'number') hookTurns.push(input.turn);
|
|
324
|
+
return { decision: 'allow' };
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const node = new ToolNode({
|
|
330
|
+
tools: [echo],
|
|
331
|
+
eventDrivenMode: true,
|
|
332
|
+
hookRegistry: registry,
|
|
333
|
+
directToolNames: new Set(['echo']),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Patch the tool's `func` to record the turn the body sees via the
|
|
337
|
+
// standard LangChain config.toolCall.turn channel.
|
|
338
|
+
const originalFunc = (echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func;
|
|
339
|
+
(echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func = async (
|
|
340
|
+
input,
|
|
341
|
+
config
|
|
342
|
+
): Promise<string> => {
|
|
343
|
+
const t = (config as { toolCall?: { turn?: number } } | undefined)
|
|
344
|
+
?.toolCall?.turn;
|
|
345
|
+
if (typeof t === 'number') bodyTurns.push(t);
|
|
346
|
+
return originalFunc(input, config);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const aiMsg = new AIMessage({
|
|
350
|
+
content: '',
|
|
351
|
+
tool_calls: [
|
|
352
|
+
{ id: 'c0', name: 'echo', args: { command: 'a' } },
|
|
353
|
+
{ id: 'c1', name: 'echo', args: { command: 'b' } },
|
|
354
|
+
{ id: 'c2', name: 'echo', args: { command: 'c' } },
|
|
355
|
+
],
|
|
356
|
+
});
|
|
357
|
+
await node.invoke({ messages: [aiMsg] });
|
|
358
|
+
|
|
359
|
+
// Sanity: tool ran 3 times, hook fired 3 times.
|
|
360
|
+
expect(bodyCount).toBe(3);
|
|
361
|
+
expect(hookTurns.length).toBe(3);
|
|
362
|
+
// Post-fix: each hook observes a unique turn (one of 0, 1, 2)
|
|
363
|
+
// — the SAME turn the body executes under. Pre-fix they all
|
|
364
|
+
// saw 0, so the dedupe-to-3 assertion would fail.
|
|
365
|
+
expect(new Set(hookTurns).size).toBe(3);
|
|
366
|
+
expect([...hookTurns].sort()).toEqual([0, 1, 2]);
|
|
367
|
+
// The body-side `config.toolCall.turn` should also align (when
|
|
368
|
+
// visible — LangChain may not propagate config in every test
|
|
369
|
+
// shape; assert weakly).
|
|
370
|
+
if (bodyTurns.length === 3) {
|
|
371
|
+
expect([...bodyTurns].sort()).toEqual([0, 1, 2]);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('PreToolUse `additionalContext` is materialized as a HumanMessage in the direct path (Codex P2 #39)', async () => {
|
|
376
|
+
// Pre-fix the direct path called executeHooks but discarded
|
|
377
|
+
// additionalContexts — silently broke the documented hook API
|
|
378
|
+
// for hosts using policy/recovery guidance with local tools.
|
|
379
|
+
const echo = createDirectTool('echo', () => 'EXECUTED');
|
|
380
|
+
|
|
381
|
+
const registry = new HookRegistry();
|
|
382
|
+
registry.register('PreToolUse', {
|
|
383
|
+
hooks: [
|
|
384
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
385
|
+
decision: 'allow',
|
|
386
|
+
additionalContext: 'POLICY-NOTE: writes here require approval next time',
|
|
387
|
+
}),
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const node = new ToolNode({
|
|
392
|
+
tools: [echo],
|
|
393
|
+
eventDrivenMode: true,
|
|
394
|
+
hookRegistry: registry,
|
|
395
|
+
directToolNames: new Set(['echo']),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = await node.invoke({
|
|
399
|
+
messages: [aiCall('call_ctx', 'echo', { command: 'hi' })],
|
|
400
|
+
});
|
|
401
|
+
const messages = toolMessages(result);
|
|
402
|
+
// ToolMessage for the echo call AND the materialized
|
|
403
|
+
// HumanMessage carrying the additionalContext.
|
|
404
|
+
const human = (messages as unknown as { content: unknown }[]).find(
|
|
405
|
+
(m) =>
|
|
406
|
+
typeof (m as { content: unknown }).content === 'string' &&
|
|
407
|
+
String((m as { content: string }).content).includes('POLICY-NOTE')
|
|
408
|
+
);
|
|
409
|
+
expect(human).toBeDefined();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
Constants,
|
|
4
|
+
LOCAL_CODING_BUNDLE_NAMES,
|
|
5
|
+
LOCAL_CODING_TOOL_NAMES,
|
|
6
|
+
} from '@/common';
|
|
7
|
+
import {
|
|
8
|
+
createLocalCodingTools,
|
|
9
|
+
createLocalCodingToolDefinitions,
|
|
10
|
+
LocalEditFileToolName,
|
|
11
|
+
LocalGlobSearchToolName,
|
|
12
|
+
LocalGrepSearchToolName,
|
|
13
|
+
LocalListDirectoryToolName,
|
|
14
|
+
LocalWriteFileToolName,
|
|
15
|
+
} from '../local/LocalCodingTools';
|
|
16
|
+
import { CompileCheckToolName } from '../local/CompileCheckTool';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pins the tool name surface so a typo upstream — in factories,
|
|
20
|
+
* registry definitions, the policy hook, or LibreChat's icon map —
|
|
21
|
+
* gets caught at build time. The wire-level strings have to match
|
|
22
|
+
* what consumer UIs already special-case (`bash_tool`, `read_file`,
|
|
23
|
+
* `execute_code`, `run_tools_with_code`) and what they may add icons
|
|
24
|
+
* for next (write_file, edit_file, grep_search, glob_search,
|
|
25
|
+
* list_directory, compile_check).
|
|
26
|
+
*/
|
|
27
|
+
describe('local coding tool names', () => {
|
|
28
|
+
it('the per-file Local*ToolName aliases point at canonical Constants', () => {
|
|
29
|
+
expect(LocalWriteFileToolName).toBe(Constants.WRITE_FILE);
|
|
30
|
+
expect(LocalEditFileToolName).toBe(Constants.EDIT_FILE);
|
|
31
|
+
expect(LocalGrepSearchToolName).toBe(Constants.GREP_SEARCH);
|
|
32
|
+
expect(LocalGlobSearchToolName).toBe(Constants.GLOB_SEARCH);
|
|
33
|
+
expect(LocalListDirectoryToolName).toBe(Constants.LIST_DIRECTORY);
|
|
34
|
+
expect(CompileCheckToolName).toBe(Constants.COMPILE_CHECK);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('canonical strings match what consumer UIs (e.g. LibreChat icon map) recognise', () => {
|
|
38
|
+
expect(Constants.READ_FILE).toBe('read_file');
|
|
39
|
+
expect(Constants.WRITE_FILE).toBe('write_file');
|
|
40
|
+
expect(Constants.EDIT_FILE).toBe('edit_file');
|
|
41
|
+
expect(Constants.GREP_SEARCH).toBe('grep_search');
|
|
42
|
+
expect(Constants.GLOB_SEARCH).toBe('glob_search');
|
|
43
|
+
expect(Constants.LIST_DIRECTORY).toBe('list_directory');
|
|
44
|
+
expect(Constants.COMPILE_CHECK).toBe('compile_check');
|
|
45
|
+
expect(Constants.BASH_TOOL).toBe('bash_tool');
|
|
46
|
+
expect(Constants.EXECUTE_CODE).toBe('execute_code');
|
|
47
|
+
expect(Constants.PROGRAMMATIC_TOOL_CALLING).toBe('run_tools_with_code');
|
|
48
|
+
expect(Constants.BASH_PROGRAMMATIC_TOOL_CALLING).toBe('run_tools_with_bash');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('LOCAL_CODING_BUNDLE_NAMES matches every name the bundle ships', () => {
|
|
52
|
+
const tools = createLocalCodingTools();
|
|
53
|
+
const bundleNames = tools.map((t) => t.name).sort();
|
|
54
|
+
const advertisedNames = [...LOCAL_CODING_BUNDLE_NAMES].sort();
|
|
55
|
+
expect(bundleNames).toEqual(advertisedNames);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('LOCAL_CODING_TOOL_NAMES matches the registry-definitions list (local-only tools)', () => {
|
|
59
|
+
const defs = createLocalCodingToolDefinitions();
|
|
60
|
+
const defNames = defs.map((d) => d.name).sort();
|
|
61
|
+
const advertisedNames = [...LOCAL_CODING_TOOL_NAMES].sort();
|
|
62
|
+
expect(defNames).toEqual(advertisedNames);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('LOCAL_CODING_BUNDLE_NAMES is a strict superset of LOCAL_CODING_TOOL_NAMES', () => {
|
|
66
|
+
for (const name of LOCAL_CODING_TOOL_NAMES) {
|
|
67
|
+
expect(LOCAL_CODING_BUNDLE_NAMES).toContain(name);
|
|
68
|
+
}
|
|
69
|
+
expect(LOCAL_CODING_BUNDLE_NAMES.length).toBeGreaterThan(
|
|
70
|
+
LOCAL_CODING_TOOL_NAMES.length
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { tmpdir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
5
|
+
import { Constants } from '@/common';
|
|
6
|
+
import { createLocalCodingToolBundle } from '../local/LocalCodingTools';
|
|
7
|
+
import {
|
|
8
|
+
resolveWorkspacePathSafe,
|
|
9
|
+
getWorkspaceFS,
|
|
10
|
+
} from '../local/LocalExecutionEngine';
|
|
11
|
+
import { nodeWorkspaceFS } from '../local/workspaceFS';
|
|
12
|
+
import type { WorkspaceFS } from '../local/workspaceFS';
|
|
13
|
+
|
|
14
|
+
describe('workspace seam', () => {
|
|
15
|
+
let workspace: string;
|
|
16
|
+
let extra: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
workspace = await mkdtemp(join(tmpdir(), 'lc-ws-'));
|
|
20
|
+
extra = await mkdtemp(join(tmpdir(), 'lc-ws-extra-'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(workspace, { recursive: true, force: true });
|
|
25
|
+
await rm(extra, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('additionalRoots', () => {
|
|
29
|
+
it('lets read tools touch paths inside an additional root', async () => {
|
|
30
|
+
await writeFile(join(extra, 'foo.txt'), 'extra contents\n', 'utf8');
|
|
31
|
+
const path = await resolveWorkspacePathSafe(
|
|
32
|
+
join(extra, 'foo.txt'),
|
|
33
|
+
{ workspace: { root: workspace, additionalRoots: [extra] } },
|
|
34
|
+
'read'
|
|
35
|
+
);
|
|
36
|
+
expect(path).toBe(join(extra, 'foo.txt'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('still rejects truly outside paths', async () => {
|
|
40
|
+
await expect(
|
|
41
|
+
resolveWorkspacePathSafe(
|
|
42
|
+
'/etc/passwd',
|
|
43
|
+
{ workspace: { root: workspace, additionalRoots: [extra] } },
|
|
44
|
+
'read'
|
|
45
|
+
)
|
|
46
|
+
).rejects.toThrow(/outside the local workspace/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('honours allowReadOutside split from allowWriteOutside', async () => {
|
|
50
|
+
const cfg = {
|
|
51
|
+
workspace: {
|
|
52
|
+
root: workspace,
|
|
53
|
+
allowReadOutside: true,
|
|
54
|
+
allowWriteOutside: false,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const readPath = await resolveWorkspacePathSafe(
|
|
58
|
+
'/tmp/whatever.txt',
|
|
59
|
+
cfg,
|
|
60
|
+
'read'
|
|
61
|
+
);
|
|
62
|
+
expect(readPath).toBe('/tmp/whatever.txt');
|
|
63
|
+
await expect(
|
|
64
|
+
resolveWorkspacePathSafe('/tmp/whatever.txt', cfg, 'write')
|
|
65
|
+
).rejects.toThrow(/outside the local workspace/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('legacy `cwd` + `allowOutsideWorkspace` still works', async () => {
|
|
69
|
+
const cfg = { cwd: workspace, allowOutsideWorkspace: true };
|
|
70
|
+
const p = await resolveWorkspacePathSafe('/tmp/x.txt', cfg, 'write');
|
|
71
|
+
expect(p).toBe('/tmp/x.txt');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('legacy `cwd` is honoured when `workspace.root` is absent', async () => {
|
|
75
|
+
const cfg = { cwd: workspace };
|
|
76
|
+
await expect(
|
|
77
|
+
resolveWorkspacePathSafe('/tmp/x.txt', cfg, 'write')
|
|
78
|
+
).rejects.toThrow(/outside the local workspace/);
|
|
79
|
+
const p = await resolveWorkspacePathSafe(
|
|
80
|
+
join(workspace, 'a.txt'),
|
|
81
|
+
cfg,
|
|
82
|
+
'write'
|
|
83
|
+
);
|
|
84
|
+
expect(p).toBe(join(workspace, 'a.txt'));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('WorkspaceFS seam', () => {
|
|
89
|
+
it('defaults to the Node host fs when nothing is supplied', () => {
|
|
90
|
+
expect(getWorkspaceFS({ workspace: { root: workspace } })).toBe(
|
|
91
|
+
nodeWorkspaceFS
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('routes file tool calls through a custom WorkspaceFS', async () => {
|
|
96
|
+
// Spy: every read/write/etc. routes through `tracked`. We delegate to
|
|
97
|
+
// the real Node impl so the tool actually completes; the spy just
|
|
98
|
+
// proves the seam.
|
|
99
|
+
const tracked: WorkspaceFS = {
|
|
100
|
+
readFile: jest.fn(nodeWorkspaceFS.readFile) as unknown as WorkspaceFS['readFile'],
|
|
101
|
+
writeFile: jest.fn(nodeWorkspaceFS.writeFile),
|
|
102
|
+
stat: jest.fn(nodeWorkspaceFS.stat),
|
|
103
|
+
readdir: jest.fn(nodeWorkspaceFS.readdir) as unknown as WorkspaceFS['readdir'],
|
|
104
|
+
mkdir: jest.fn(nodeWorkspaceFS.mkdir),
|
|
105
|
+
realpath: jest.fn(nodeWorkspaceFS.realpath),
|
|
106
|
+
unlink: jest.fn(nodeWorkspaceFS.unlink),
|
|
107
|
+
open: jest.fn(nodeWorkspaceFS.open),
|
|
108
|
+
};
|
|
109
|
+
const bundle = createLocalCodingToolBundle({
|
|
110
|
+
workspace: { root: workspace },
|
|
111
|
+
exec: { fs: tracked },
|
|
112
|
+
});
|
|
113
|
+
const writeTool = bundle.tools.find((t) => t.name === 'write_file')!;
|
|
114
|
+
await writeTool.invoke({
|
|
115
|
+
id: 'c1',
|
|
116
|
+
name: 'write_file',
|
|
117
|
+
args: { file_path: 'note.md', content: 'hi\n' },
|
|
118
|
+
type: 'tool_call',
|
|
119
|
+
});
|
|
120
|
+
expect(tracked.writeFile).toHaveBeenCalled();
|
|
121
|
+
expect(tracked.mkdir).toHaveBeenCalled();
|
|
122
|
+
|
|
123
|
+
const readTool = bundle.tools.find((t) => t.name === Constants.READ_FILE)!;
|
|
124
|
+
await readTool.invoke({
|
|
125
|
+
id: 'c2',
|
|
126
|
+
name: Constants.READ_FILE,
|
|
127
|
+
args: { file_path: 'note.md' },
|
|
128
|
+
type: 'tool_call',
|
|
129
|
+
});
|
|
130
|
+
expect(tracked.stat).toHaveBeenCalled();
|
|
131
|
+
expect(tracked.readFile).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|