@librechat/agents 3.1.77 → 3.1.78

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.
Files changed (185) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +155 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/main.cjs +90 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  10. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  11. package/dist/cjs/messages/prune.cjs +27 -0
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/messages/recency.cjs +99 -0
  14. package/dist/cjs/messages/recency.cjs.map +1 -0
  15. package/dist/cjs/run.cjs +30 -0
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +100 -6
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +635 -23
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  22. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  23. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  24. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  25. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  26. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  28. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  30. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  32. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  33. package/dist/cjs/tools/local/attachments.cjs +183 -0
  34. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  35. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  36. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  37. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  38. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  39. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  40. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  41. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  42. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  43. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  44. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  45. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  46. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +31 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +53 -1
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +156 -5
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  54. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  55. package/dist/esm/main.mjs +17 -2
  56. package/dist/esm/main.mjs.map +1 -1
  57. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  58. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  59. package/dist/esm/messages/prune.mjs +26 -1
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/messages/recency.mjs +97 -0
  62. package/dist/esm/messages/recency.mjs.map +1 -0
  63. package/dist/esm/run.mjs +30 -0
  64. package/dist/esm/run.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +100 -6
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +635 -23
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  71. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  72. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  73. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  74. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  75. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  76. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  78. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  80. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  81. package/dist/esm/tools/local/attachments.mjs +180 -0
  82. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  83. package/dist/esm/tools/local/bashAst.mjs +126 -0
  84. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  85. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  86. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  87. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  88. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  89. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  90. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  91. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  92. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  93. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  94. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +31 -0
  96. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  97. package/dist/types/common/enum.d.ts +39 -1
  98. package/dist/types/graphs/Graph.d.ts +34 -0
  99. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  100. package/dist/types/hooks/index.d.ts +2 -0
  101. package/dist/types/index.d.ts +1 -0
  102. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  103. package/dist/types/messages/index.d.ts +2 -0
  104. package/dist/types/messages/prune.d.ts +11 -0
  105. package/dist/types/messages/recency.d.ts +64 -0
  106. package/dist/types/run.d.ts +21 -0
  107. package/dist/types/tools/ToolNode.d.ts +145 -2
  108. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  109. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  110. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  111. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  112. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  113. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  114. package/dist/types/tools/local/attachments.d.ts +84 -0
  115. package/dist/types/tools/local/bashAst.d.ts +11 -0
  116. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  117. package/dist/types/tools/local/index.d.ts +12 -0
  118. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  119. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  120. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  121. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  122. package/dist/types/tools/subagent/SubagentExecutor.d.ts +29 -0
  123. package/dist/types/types/hitl.d.ts +56 -27
  124. package/dist/types/types/run.d.ts +8 -1
  125. package/dist/types/types/summarize.d.ts +30 -0
  126. package/dist/types/types/tools.d.ts +341 -6
  127. package/package.json +21 -2
  128. package/src/common/enum.ts +54 -0
  129. package/src/graphs/Graph.ts +173 -6
  130. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  131. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  132. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  133. package/src/hooks/index.ts +6 -0
  134. package/src/index.ts +1 -0
  135. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  136. package/src/messages/__tests__/recency.test.ts +267 -0
  137. package/src/messages/anthropicToolCache.ts +116 -0
  138. package/src/messages/index.ts +2 -0
  139. package/src/messages/prune.ts +27 -1
  140. package/src/messages/recency.ts +155 -0
  141. package/src/run.ts +31 -0
  142. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  143. package/src/scripts/local_engine.ts +166 -0
  144. package/src/scripts/local_engine_checkpointer.ts +205 -0
  145. package/src/scripts/local_engine_compile.ts +263 -0
  146. package/src/scripts/local_engine_hooks.ts +226 -0
  147. package/src/scripts/local_engine_image.ts +201 -0
  148. package/src/scripts/local_engine_ptc.ts +151 -0
  149. package/src/scripts/local_engine_workspace.ts +258 -0
  150. package/src/scripts/subagent-configurable-inheritance.ts +252 -0
  151. package/src/scripts/summarization-recency.ts +462 -0
  152. package/src/specs/prune.test.ts +39 -0
  153. package/src/summarization/__tests__/node.test.ts +499 -3
  154. package/src/summarization/node.ts +124 -7
  155. package/src/tools/ToolNode.ts +769 -20
  156. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  157. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  158. package/src/tools/__tests__/SubagentExecutor.test.ts +148 -0
  159. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  160. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  161. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  162. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  163. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  164. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  165. package/src/tools/local/CompileCheckTool.ts +278 -0
  166. package/src/tools/local/FileCheckpointer.ts +93 -0
  167. package/src/tools/local/LocalCodingTools.ts +1342 -0
  168. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  169. package/src/tools/local/LocalExecutionTools.ts +167 -0
  170. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  171. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  172. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  173. package/src/tools/local/attachments.ts +251 -0
  174. package/src/tools/local/bashAst.ts +151 -0
  175. package/src/tools/local/editStrategies.ts +188 -0
  176. package/src/tools/local/index.ts +12 -0
  177. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  178. package/src/tools/local/syntaxCheck.ts +243 -0
  179. package/src/tools/local/textEncoding.ts +37 -0
  180. package/src/tools/local/workspaceFS.ts +89 -0
  181. package/src/tools/subagent/SubagentExecutor.ts +60 -0
  182. package/src/types/hitl.ts +56 -27
  183. package/src/types/run.ts +12 -1
  184. package/src/types/summarize.ts +31 -0
  185. package/src/types/tools.ts +359 -7
@@ -0,0 +1,594 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'crypto';
2
+ import { createServer } from 'http';
3
+ import { tool } from '@langchain/core/tools';
4
+ import type { AddressInfo } from 'net';
5
+ import type { IncomingMessage, ServerResponse } from 'http';
6
+ import type { DynamicStructuredTool } from '@langchain/core/tools';
7
+ import type * as t from '@/types';
8
+ import { executeHooks } from '@/hooks';
9
+ import {
10
+ executeTools,
11
+ filterToolsByUsage,
12
+ formatCompletedResponse,
13
+ normalizeToPythonIdentifier,
14
+ ProgrammaticToolCallingName,
15
+ ProgrammaticToolCallingSchema,
16
+ ProgrammaticToolCallingDescription,
17
+ } from '@/tools/ProgrammaticToolCalling';
18
+ import {
19
+ BashProgrammaticToolCallingSchema,
20
+ BashProgrammaticToolCallingDescription,
21
+ filterBashToolsByUsage,
22
+ normalizeToBashIdentifier,
23
+ } from '@/tools/BashProgrammaticToolCalling';
24
+ import {
25
+ executeLocalBash,
26
+ executeLocalCode,
27
+ getLocalSessionId,
28
+ shellQuote,
29
+ } from './LocalExecutionEngine';
30
+ import { Constants } from '@/common';
31
+
32
+ const DEFAULT_TIMEOUT = 60000;
33
+ const LocalProgrammaticToolCallingSchema = {
34
+ ...ProgrammaticToolCallingSchema,
35
+ properties: {
36
+ ...ProgrammaticToolCallingSchema.properties,
37
+ lang: {
38
+ type: 'string',
39
+ enum: ['py', 'python', 'bash', 'sh'],
40
+ default: 'bash',
41
+ description:
42
+ 'Local engine runtime for orchestration code. Defaults to bash; use py/python for Python orchestration.',
43
+ },
44
+ },
45
+ } as const;
46
+
47
+ type ToolBridge = {
48
+ url: string;
49
+ token: string;
50
+ close: () => Promise<void>;
51
+ };
52
+
53
+ type ToolRequest = {
54
+ id?: string;
55
+ name?: string;
56
+ input?: Record<string, unknown>;
57
+ };
58
+
59
+ const BRIDGE_AUTH_HEADER = 'x-librechat-bridge-token';
60
+
61
+ function constantTimeEquals(a: string, b: string): boolean {
62
+ const aBuf = Buffer.from(a, 'utf8');
63
+ const bBuf = Buffer.from(b, 'utf8');
64
+ if (aBuf.length !== bBuf.length) {
65
+ return false;
66
+ }
67
+ return timingSafeEqual(aBuf, bBuf);
68
+ }
69
+
70
+ type LocalProgrammaticRuntime = 'python' | 'bash';
71
+
72
+ type LocalProgrammaticParams = {
73
+ code: string;
74
+ timeout?: number;
75
+ lang?: string;
76
+ runtime?: string;
77
+ language?: string;
78
+ };
79
+
80
+ type ToolFilter = (toolDefs: t.LCTool[], code: string) => t.LCTool[];
81
+
82
+ function resolveRuntime(params: LocalProgrammaticParams): LocalProgrammaticRuntime {
83
+ const rawRuntime = params.lang ?? params.runtime ?? params.language ?? 'bash';
84
+ return rawRuntime === 'py' || rawRuntime === 'python' ? 'python' : 'bash';
85
+ }
86
+
87
+ function toSerializable(value: unknown): unknown {
88
+ if (value === undefined) {
89
+ return null;
90
+ }
91
+ return value;
92
+ }
93
+
94
+ async function readRequestBody(req: IncomingMessage): Promise<ToolRequest> {
95
+ const chunks: Buffer[] = [];
96
+ for await (const chunk of req) {
97
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
98
+ }
99
+ const raw = Buffer.concat(chunks).toString('utf8');
100
+ if (raw === '') {
101
+ return {};
102
+ }
103
+ return JSON.parse(raw) as ToolRequest;
104
+ }
105
+
106
+ function writeJson(res: ServerResponse, status: number, value: unknown): void {
107
+ res.writeHead(status, { 'Content-Type': 'application/json' });
108
+ res.end(JSON.stringify(value));
109
+ }
110
+
111
+ /**
112
+ * Run the host's `PreToolUse` hook chain for a single bridge call.
113
+ * Returns the (possibly rewritten) input and a `denyReason` if any
114
+ * matcher returned `decision: 'deny'` or `'ask'`. `'ask'` collapses
115
+ * to deny because the bridge can't raise a LangGraph interrupt from
116
+ * inside an HTTP handler — fail-closed matches the rest of the SDK
117
+ * when HITL is unavailable.
118
+ *
119
+ * @internal Exported for tests so the deny / allow / updatedInput /
120
+ * ask branches can be exercised without standing up the full HTTP
121
+ * bridge.
122
+ */
123
+ export async function applyPreToolUseHooksForBridge(
124
+ hookContext: t.ProgrammaticHookContext,
125
+ toolName: string,
126
+ toolUseId: string,
127
+ toolInput: Record<string, unknown>
128
+ ): Promise<{ input: Record<string, unknown>; denyReason?: string }> {
129
+ if (hookContext.registry == null) {
130
+ return { input: toolInput };
131
+ }
132
+ const result = await executeHooks({
133
+ registry: hookContext.registry,
134
+ input: {
135
+ hook_event_name: 'PreToolUse',
136
+ runId: hookContext.runId,
137
+ threadId: hookContext.threadId,
138
+ agentId: hookContext.agentId,
139
+ toolName,
140
+ toolInput,
141
+ toolUseId,
142
+ stepId: '',
143
+ turn: 0,
144
+ },
145
+ sessionId: hookContext.runId,
146
+ matchQuery: toolName,
147
+ }).catch(() => undefined);
148
+ if (result == null) {
149
+ return { input: toolInput };
150
+ }
151
+ const nextInput =
152
+ result.updatedInput != null
153
+ ? (result.updatedInput as Record<string, unknown>)
154
+ : toolInput;
155
+ if (result.decision === 'deny' || result.decision === 'ask') {
156
+ return {
157
+ input: nextInput,
158
+ denyReason:
159
+ result.reason ??
160
+ (result.decision === 'ask'
161
+ ? `Tool "${toolName}" requires human approval; bridge cannot raise an interrupt — denying.`
162
+ : `Tool "${toolName}" denied by PreToolUse hook.`),
163
+ };
164
+ }
165
+ return { input: nextInput };
166
+ }
167
+
168
+ async function createToolBridge(
169
+ toolMap: t.ToolMap,
170
+ hookContext?: t.ProgrammaticHookContext
171
+ ): Promise<ToolBridge> {
172
+ const token = randomBytes(32).toString('hex');
173
+ const server = createServer((req, res) => {
174
+ // `?mode=text` returns the already-serialized result as the body
175
+ // (or the error message at non-2xx). Python/Node callers stay on
176
+ // JSON; bash callers using curl can avoid pulling in a JSON
177
+ // parser dependency (Codex P2 #19 — `python3` was a hard
178
+ // requirement for the bash bridge, breaking minimal containers).
179
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
180
+ const isTextMode = url.searchParams.get('mode') === 'text';
181
+ if (req.method !== 'POST' || url.pathname !== '/tool') {
182
+ if (isTextMode) {
183
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
184
+ res.end('Not found');
185
+ } else {
186
+ writeJson(res, 404, { error: 'Not found' });
187
+ }
188
+ return;
189
+ }
190
+
191
+ const presented = req.headers[BRIDGE_AUTH_HEADER];
192
+ const presentedToken = Array.isArray(presented) ? presented[0] : presented;
193
+ if (
194
+ typeof presentedToken !== 'string' ||
195
+ !constantTimeEquals(presentedToken, token)
196
+ ) {
197
+ if (isTextMode) {
198
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
199
+ res.end('Unauthorized');
200
+ } else {
201
+ writeJson(res, 401, { error: 'Unauthorized' });
202
+ }
203
+ return;
204
+ }
205
+
206
+ readRequestBody(req)
207
+ .then(async (body) => {
208
+ if (typeof body.name !== 'string' || body.name === '') {
209
+ const message = 'Tool request is missing a tool name.';
210
+ if (isTextMode) {
211
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
212
+ res.end(message);
213
+ } else {
214
+ writeJson(res, 400, {
215
+ call_id: body.id ?? 'invalid',
216
+ result: null,
217
+ is_error: true,
218
+ error_message: message,
219
+ });
220
+ }
221
+ return;
222
+ }
223
+
224
+ const callId = body.id ?? `local_call_${randomUUID()}`;
225
+ let effectiveInput: Record<string, unknown> = body.input ?? {};
226
+ if (hookContext != null) {
227
+ const gate = await applyPreToolUseHooksForBridge(
228
+ hookContext,
229
+ body.name,
230
+ callId,
231
+ effectiveInput
232
+ );
233
+ if (gate.denyReason != null) {
234
+ const denyMsg = gate.denyReason;
235
+ if (isTextMode) {
236
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
237
+ res.end(denyMsg);
238
+ } else {
239
+ writeJson(res, 500, {
240
+ call_id: callId,
241
+ result: null,
242
+ is_error: true,
243
+ error_message: denyMsg,
244
+ });
245
+ }
246
+ return;
247
+ }
248
+ effectiveInput = gate.input;
249
+ }
250
+
251
+ const [result] = await executeTools(
252
+ [
253
+ {
254
+ id: callId,
255
+ name: body.name,
256
+ input: effectiveInput,
257
+ },
258
+ ],
259
+ toolMap
260
+ );
261
+
262
+ if (isTextMode) {
263
+ if (result.is_error === true) {
264
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
265
+ res.end(result.error_message ?? `Tool ${body.name} failed`);
266
+ } else {
267
+ const value = toSerializable(result.result);
268
+ const text =
269
+ typeof value === 'string' ? value : JSON.stringify(value);
270
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
271
+ res.end(text);
272
+ }
273
+ return;
274
+ }
275
+
276
+ writeJson(res, 200, {
277
+ ...result,
278
+ result: toSerializable(result.result),
279
+ });
280
+ })
281
+ .catch((error: Error) => {
282
+ if (isTextMode) {
283
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
284
+ res.end(error.message);
285
+ } else {
286
+ writeJson(res, 500, {
287
+ call_id: 'error',
288
+ result: null,
289
+ is_error: true,
290
+ error_message: error.message,
291
+ });
292
+ }
293
+ });
294
+ });
295
+
296
+ await new Promise<void>((resolve, reject) => {
297
+ server.once('error', reject);
298
+ server.listen(0, '127.0.0.1', resolve);
299
+ });
300
+
301
+ const address = server.address() as AddressInfo;
302
+ return {
303
+ url: `http://127.0.0.1:${address.port}/tool`,
304
+ token,
305
+ close: () =>
306
+ new Promise((resolve, reject) => {
307
+ server.close((error) => (error ? reject(error) : resolve()));
308
+ }),
309
+ };
310
+ }
311
+
312
+ function indent(code: string): string {
313
+ return code
314
+ .split('\n')
315
+ .map((line) => ` ${line}`)
316
+ .join('\n');
317
+ }
318
+
319
+ function createPythonProgram(
320
+ code: string,
321
+ toolDefs: t.LCTool[],
322
+ bridgeUrl: string,
323
+ bridgeToken: string
324
+ ): string {
325
+ const functionDefs = toolDefs
326
+ .map((def) => {
327
+ const pythonName = normalizeToPythonIdentifier(def.name);
328
+ return [
329
+ `async def ${pythonName}(**kwargs):`,
330
+ ` return await __librechat_call_tool(${JSON.stringify(def.name)}, kwargs)`,
331
+ ].join('\n');
332
+ })
333
+ .join('\n\n');
334
+
335
+ return `
336
+ import asyncio
337
+ import json
338
+ import urllib.request
339
+
340
+ __LIBRECHAT_TOOL_BRIDGE = ${JSON.stringify(bridgeUrl)}
341
+ __LIBRECHAT_TOOL_TOKEN = ${JSON.stringify(bridgeToken)}
342
+
343
+ async def __librechat_call_tool(name, payload):
344
+ body = json.dumps({"name": name, "input": payload}).encode("utf-8")
345
+ headers = {
346
+ "Content-Type": "application/json",
347
+ ${JSON.stringify(BRIDGE_AUTH_HEADER)}: __LIBRECHAT_TOOL_TOKEN,
348
+ }
349
+
350
+ def request():
351
+ req = urllib.request.Request(__LIBRECHAT_TOOL_BRIDGE, data=body, headers=headers, method="POST")
352
+ with urllib.request.urlopen(req, timeout=300) as response:
353
+ return response.read().decode("utf-8")
354
+
355
+ raw = await asyncio.to_thread(request)
356
+ result = json.loads(raw)
357
+ if result.get("is_error"):
358
+ raise RuntimeError(result.get("error_message") or f"Tool {name} failed")
359
+ return result.get("result")
360
+
361
+ ${functionDefs}
362
+
363
+ async def __librechat_main():
364
+ ${indent(code)}
365
+
366
+ asyncio.run(__librechat_main())
367
+ `.trimStart();
368
+ }
369
+
370
+ export function _createBashProgramForTests(
371
+ code: string,
372
+ toolDefs: t.LCTool[],
373
+ bridgeUrl: string,
374
+ bridgeToken: string
375
+ ): string {
376
+ return createBashProgram(code, toolDefs, bridgeUrl, bridgeToken);
377
+ }
378
+
379
+ function createBashProgram(
380
+ code: string,
381
+ toolDefs: t.LCTool[],
382
+ bridgeUrl: string,
383
+ bridgeToken: string
384
+ ): string {
385
+ const functions = toolDefs
386
+ .map((def) => {
387
+ const bashName = normalizeToBashIdentifier(def.name);
388
+ return [
389
+ `${bashName}() {`,
390
+ ' local payload="${1:-}"',
391
+ ' if [ -z "$payload" ]; then payload=\'{}\'; fi',
392
+ ` __librechat_call_tool ${shellQuote(def.name)} "$payload"`,
393
+ '}',
394
+ ].join('\n');
395
+ })
396
+ .join('\n\n');
397
+
398
+ return `
399
+ __LIBRECHAT_TOOL_BRIDGE=${shellQuote(bridgeUrl)}
400
+ __LIBRECHAT_TOOL_HEADER=${shellQuote(BRIDGE_AUTH_HEADER)}
401
+ __LIBRECHAT_TOOL_TOKEN=${shellQuote(bridgeToken)}
402
+
403
+ # Bridge call helper. Tries curl first (universally available, no
404
+ # JSON parser needed thanks to the bridge's ?mode=text endpoint),
405
+ # falls back to python3 for environments without curl. Codex P2 #19
406
+ # flagged that the prior python3-only path broke minimal containers
407
+ # (and Windows hosts without a python3 binary on PATH). Tool names
408
+ # come from Constants.* and are always safe identifiers, so we can
409
+ # splice them into JSON without an escape pass.
410
+ __librechat_call_tool() {
411
+ local tool_name="$1"
412
+ local payload="$2"
413
+ if command -v curl >/dev/null 2>&1; then
414
+ local body="{\\"name\\":\\"$tool_name\\",\\"input\\":$payload}"
415
+ local response
416
+ local http_code
417
+ response=$(curl -sS -X POST \
418
+ -H "Content-Type: application/json" \
419
+ -H "$__LIBRECHAT_TOOL_HEADER: $__LIBRECHAT_TOOL_TOKEN" \
420
+ --data-binary "$body" \
421
+ -w '\\n__LIBRECHAT_HTTP_CODE_%{http_code}__' \
422
+ "$__LIBRECHAT_TOOL_BRIDGE?mode=text")
423
+ http_code=$(printf '%s' "$response" | sed -n 's/.*__LIBRECHAT_HTTP_CODE_\\([0-9][0-9]*\\)__$/\\1/p')
424
+ local body_only
425
+ body_only=$(printf '%s' "$response" | sed 's/__LIBRECHAT_HTTP_CODE_[0-9][0-9]*__$//')
426
+ if [ "$http_code" = "200" ]; then
427
+ printf '%s' "$body_only"
428
+ return 0
429
+ fi
430
+ printf '%s\\n' "$body_only" >&2
431
+ return 1
432
+ elif command -v python3 >/dev/null 2>&1; then
433
+ python3 - "$__LIBRECHAT_TOOL_BRIDGE" "$tool_name" "$payload" "$__LIBRECHAT_TOOL_HEADER" "$__LIBRECHAT_TOOL_TOKEN" <<'PY'
434
+ import json
435
+ import sys
436
+ import urllib.request
437
+
438
+ url, name, payload, header, token = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
439
+ body = json.dumps({"name": name, "input": json.loads(payload)}).encode("utf-8")
440
+ req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", header: token}, method="POST")
441
+ with urllib.request.urlopen(req, timeout=300) as response:
442
+ result = json.loads(response.read().decode("utf-8"))
443
+ if result.get("is_error"):
444
+ print(result.get("error_message") or f"Tool {name} failed", file=sys.stderr)
445
+ sys.exit(1)
446
+ value = result.get("result")
447
+ if isinstance(value, str):
448
+ print(value)
449
+ else:
450
+ print(json.dumps(value))
451
+ PY
452
+ else
453
+ printf 'librechat: tool bridge needs either curl or python3 on PATH\\n' >&2
454
+ return 1
455
+ fi
456
+ }
457
+
458
+ ${functions}
459
+
460
+ ${code}
461
+ `.trimStart();
462
+ }
463
+
464
+ function getProgrammaticContext(config?: {
465
+ toolCall?: unknown;
466
+ }): Partial<t.ProgrammaticCache> {
467
+ return (config?.toolCall ?? {}) as Partial<t.ProgrammaticCache>;
468
+ }
469
+
470
+ function createEffectiveToolMap(
471
+ toolMap: t.ToolMap,
472
+ toolDefs: t.LCTool[],
473
+ code: string,
474
+ filterTools: ToolFilter
475
+ ): { effectiveTools: t.LCTool[]; effectiveMap: t.ToolMap } {
476
+ const effectiveTools = filterTools(toolDefs, code);
477
+ const effectiveMap = new Map<string, t.GenericTool>(
478
+ effectiveTools
479
+ .map((def) => {
480
+ const executable = toolMap.get(def.name);
481
+ return executable == null
482
+ ? undefined
483
+ : ([def.name, executable] as [string, t.GenericTool]);
484
+ })
485
+ .filter((entry): entry is [string, t.GenericTool] => entry != null)
486
+ );
487
+
488
+ return { effectiveTools, effectiveMap };
489
+ }
490
+
491
+ async function runLocalProgrammaticTool(args: {
492
+ params: LocalProgrammaticParams;
493
+ config?: { toolCall?: unknown };
494
+ localConfig: t.LocalExecutionConfig;
495
+ runtime: LocalProgrammaticRuntime;
496
+ }): Promise<[string, t.ProgrammaticExecutionArtifact]> {
497
+ const { toolMap, toolDefs, hookContext } = getProgrammaticContext(args.config);
498
+
499
+ if (toolMap == null || toolMap.size === 0) {
500
+ throw new Error('No toolMap provided for local programmatic execution.');
501
+ }
502
+ if (toolDefs == null || toolDefs.length === 0) {
503
+ throw new Error('No tool definitions provided for local programmatic execution.');
504
+ }
505
+
506
+ const { effectiveTools, effectiveMap } = createEffectiveToolMap(
507
+ toolMap,
508
+ toolDefs,
509
+ args.params.code,
510
+ args.runtime === 'bash' ? filterBashToolsByUsage : filterToolsByUsage
511
+ );
512
+ const bridge = await createToolBridge(effectiveMap, hookContext);
513
+
514
+ try {
515
+ const timeoutMs = args.params.timeout ?? args.localConfig.timeoutMs ?? DEFAULT_TIMEOUT;
516
+ const result =
517
+ args.runtime === 'bash'
518
+ ? await executeLocalBash(
519
+ createBashProgram(args.params.code, effectiveTools, bridge.url, bridge.token),
520
+ { ...args.localConfig, timeoutMs }
521
+ )
522
+ : await executeLocalCode(
523
+ {
524
+ lang: 'py',
525
+ code: createPythonProgram(args.params.code, effectiveTools, bridge.url, bridge.token),
526
+ },
527
+ { ...args.localConfig, timeoutMs }
528
+ );
529
+
530
+ if (result.exitCode !== 0 || result.timedOut) {
531
+ throw new Error(
532
+ result.stderr !== ''
533
+ ? result.stderr
534
+ : `Local ${args.runtime} programmatic execution exited with code ${
535
+ result.exitCode ?? 'unknown'
536
+ }`
537
+ );
538
+ }
539
+
540
+ return formatCompletedResponse({
541
+ status: 'completed',
542
+ session_id: getLocalSessionId(args.localConfig),
543
+ stdout: result.stdout,
544
+ stderr: result.stderr,
545
+ files: [],
546
+ });
547
+ } finally {
548
+ await bridge.close();
549
+ }
550
+ }
551
+
552
+ export function createLocalProgrammaticToolCallingTool(
553
+ localConfig: t.LocalExecutionConfig = {}
554
+ ): DynamicStructuredTool {
555
+ return tool(
556
+ async (rawParams, config) => {
557
+ const params = rawParams as LocalProgrammaticParams;
558
+ return runLocalProgrammaticTool({
559
+ params,
560
+ config,
561
+ localConfig,
562
+ runtime: resolveRuntime(params),
563
+ });
564
+ },
565
+ {
566
+ name: ProgrammaticToolCallingName,
567
+ description: `${ProgrammaticToolCallingDescription}\n\nLocal engine: runs bash by default, or Python when \`lang\` is \`py\` or \`python\`, on the host machine and calls tools through an in-process localhost bridge.`,
568
+ schema: LocalProgrammaticToolCallingSchema,
569
+ responseFormat: Constants.CONTENT_AND_ARTIFACT,
570
+ }
571
+ );
572
+ }
573
+
574
+ export function createLocalBashProgrammaticToolCallingTool(
575
+ localConfig: t.LocalExecutionConfig = {}
576
+ ): DynamicStructuredTool {
577
+ return tool(
578
+ async (rawParams, config) => {
579
+ const params = rawParams as LocalProgrammaticParams;
580
+ return runLocalProgrammaticTool({
581
+ params,
582
+ config,
583
+ localConfig,
584
+ runtime: 'bash',
585
+ });
586
+ },
587
+ {
588
+ name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING,
589
+ description: `${BashProgrammaticToolCallingDescription}\n\nLocal engine: runs this bash orchestration code on the host machine and calls tools through an in-process localhost bridge.`,
590
+ schema: BashProgrammaticToolCallingSchema,
591
+ responseFormat: Constants.CONTENT_AND_ARTIFACT,
592
+ }
593
+ );
594
+ }
@@ -0,0 +1,120 @@
1
+ import { tmpdir } from 'os';
2
+ import { join } from 'path';
3
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
4
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
5
+ import { LocalFileCheckpointerImpl } from '../FileCheckpointer';
6
+
7
+ /**
8
+ * Pins the LocalFileCheckpointer's per-Run snapshot/rewind contract.
9
+ * Critical because checkpoints are what `--rollback` style features
10
+ * (and the local engine's mid-batch undo) actually rely on — a
11
+ * regression here silently makes rewind a no-op or, worse, restores
12
+ * to the wrong byte content.
13
+ */
14
+
15
+ describe('LocalFileCheckpointerImpl', () => {
16
+ let dir: string;
17
+ beforeEach(async () => {
18
+ dir = await mkdtemp(join(tmpdir(), 'lc-fcp-'));
19
+ });
20
+ afterEach(async () => {
21
+ await rm(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ it('captures pre-write content and rewind restores it byte-exact', async () => {
25
+ const file = join(dir, 'a.txt');
26
+ await writeFile(file, 'original\n');
27
+ const cp = new LocalFileCheckpointerImpl();
28
+
29
+ await cp.captureBeforeWrite(file);
30
+ await writeFile(file, 'overwritten\n');
31
+
32
+ const restored = await cp.rewind();
33
+ expect(restored).toBe(1);
34
+ expect(await readFile(file, 'utf8')).toBe('original\n');
35
+ });
36
+
37
+ it('captureBeforeWrite is idempotent — second capture preserves the FIRST snapshot', async () => {
38
+ const file = join(dir, 'b.txt');
39
+ await writeFile(file, 'first');
40
+ const cp = new LocalFileCheckpointerImpl();
41
+
42
+ await cp.captureBeforeWrite(file);
43
+ // Simulate first write happening between captures, then a second
44
+ // tool wanting to write the same file. The second capture must be
45
+ // a no-op so rewind restores back to the very first content.
46
+ await writeFile(file, 'between');
47
+ await cp.captureBeforeWrite(file);
48
+ await writeFile(file, 'last');
49
+
50
+ await cp.rewind();
51
+ expect(await readFile(file, 'utf8')).toBe('first');
52
+ });
53
+
54
+ it('captures absent files and rewind deletes any newly-created path', async () => {
55
+ const file = join(dir, 'newly-created.txt');
56
+ const cp = new LocalFileCheckpointerImpl();
57
+
58
+ await cp.captureBeforeWrite(file); // file does not exist yet
59
+ await writeFile(file, 'created by some tool');
60
+
61
+ const restored = await cp.rewind();
62
+ expect(restored).toBe(1);
63
+ await expect(stat(file)).rejects.toThrow();
64
+ });
65
+
66
+ it('rewinds across multiple files in a single pass', async () => {
67
+ const a = join(dir, 'multi-a.txt');
68
+ const b = join(dir, 'multi-b.txt');
69
+ await writeFile(a, 'A0');
70
+ await writeFile(b, 'B0');
71
+ const cp = new LocalFileCheckpointerImpl();
72
+
73
+ await cp.captureBeforeWrite(a);
74
+ await cp.captureBeforeWrite(b);
75
+ await writeFile(a, 'A1');
76
+ await writeFile(b, 'B1');
77
+
78
+ const restored = await cp.rewind();
79
+ expect(restored).toBe(2);
80
+ expect(await readFile(a, 'utf8')).toBe('A0');
81
+ expect(await readFile(b, 'utf8')).toBe('B0');
82
+ });
83
+
84
+ it('skips snapshotting files larger than maxBytesPerFile but still tracks them', async () => {
85
+ const file = join(dir, 'big.bin');
86
+ // Write 1024 bytes, set the cap to 100 — well under file size.
87
+ await writeFile(file, Buffer.alloc(1024, 0x41));
88
+ const cp = new LocalFileCheckpointerImpl(100);
89
+
90
+ await cp.captureBeforeWrite(file);
91
+ expect(cp.capturedPaths()).toContain(file);
92
+
93
+ // Mutate the file. Rewind reports 0 restored (nothing snapshotted)
94
+ // but does not throw — best-effort behavior documented in the
95
+ // class JSDoc.
96
+ await writeFile(file, 'mutated');
97
+ const restored = await cp.rewind();
98
+ expect(restored).toBe(0);
99
+ // The file is unchanged from the post-mutation state — there was
100
+ // nothing snapshotted to restore.
101
+ expect(await readFile(file, 'utf8')).toBe('mutated');
102
+ });
103
+
104
+ it('rewind of a captured file whose parent directory was removed recreates the directory', async () => {
105
+ const subdir = join(dir, 'nested', 'deep');
106
+ const file = join(subdir, 'x.txt');
107
+ await mkdir(subdir, { recursive: true });
108
+ await writeFile(file, 'kept');
109
+ const cp = new LocalFileCheckpointerImpl();
110
+
111
+ await cp.captureBeforeWrite(file);
112
+ // Blow away the subdir entirely — simulates a tool that deleted
113
+ // the parent directory.
114
+ await rm(join(dir, 'nested'), { recursive: true, force: true });
115
+
116
+ const restored = await cp.rewind();
117
+ expect(restored).toBe(1);
118
+ expect(await readFile(file, 'utf8')).toBe('kept');
119
+ });
120
+ });