@librechat/agents 3.3.4 → 3.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  10. package/dist/cjs/hooks/index.cjs +12 -0
  11. package/dist/cjs/hooks/index.cjs.map +1 -1
  12. package/dist/cjs/hooks/types.cjs +1 -0
  13. package/dist/cjs/hooks/types.cjs.map +1 -1
  14. package/dist/cjs/langfuse.cjs +8 -0
  15. package/dist/cjs/langfuse.cjs.map +1 -1
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  17. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  18. package/dist/cjs/llm/invoke.cjs +268 -3
  19. package/dist/cjs/llm/invoke.cjs.map +1 -1
  20. package/dist/cjs/llm/openai/index.cjs +51 -6
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/preempt.cjs +132 -0
  23. package/dist/cjs/llm/preempt.cjs.map +1 -0
  24. package/dist/cjs/main.cjs +31 -3
  25. package/dist/cjs/messages/alternation.cjs +92 -0
  26. package/dist/cjs/messages/alternation.cjs.map +1 -0
  27. package/dist/cjs/messages/format.cjs +72 -0
  28. package/dist/cjs/messages/format.cjs.map +1 -1
  29. package/dist/cjs/messages/handoffCue.cjs +63 -0
  30. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  31. package/dist/cjs/messages/index.cjs +3 -0
  32. package/dist/cjs/messages/injected.cjs +60 -0
  33. package/dist/cjs/messages/injected.cjs.map +1 -0
  34. package/dist/cjs/run.cjs +80 -7
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  37. package/dist/cjs/session/handlers.cjs +18 -9
  38. package/dist/cjs/session/handlers.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +34 -8
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  42. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  43. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
  44. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  45. package/dist/cjs/tools/CodeExecutor.cjs +19 -16
  46. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  47. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
  48. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  49. package/dist/cjs/tools/ReadFile.cjs +8 -4
  50. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  51. package/dist/cjs/tools/SkillTool.cjs +2 -0
  52. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  53. package/dist/cjs/tools/SubagentTool.cjs +3 -0
  54. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  55. package/dist/cjs/tools/ToolNode.cjs +91 -50
  56. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  57. package/dist/cjs/tools/ToolSearch.cjs +5 -1
  58. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  59. package/dist/cjs/tools/intentArg.cjs +230 -0
  60. package/dist/cjs/tools/intentArg.cjs.map +1 -0
  61. package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
  62. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  63. package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
  64. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  65. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  66. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  67. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  68. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  69. package/dist/cjs/tools/search/schema.cjs +2 -0
  70. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  71. package/dist/cjs/tools/search/tool.cjs +40 -2
  72. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  73. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  74. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  75. package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
  76. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  77. package/dist/esm/common/constants.mjs +19 -1
  78. package/dist/esm/common/constants.mjs.map +1 -1
  79. package/dist/esm/events.mjs +10 -1
  80. package/dist/esm/events.mjs.map +1 -1
  81. package/dist/esm/graphs/Graph.mjs +455 -6
  82. package/dist/esm/graphs/Graph.mjs.map +1 -1
  83. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  84. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  85. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  86. package/dist/esm/hooks/index.mjs +12 -1
  87. package/dist/esm/hooks/index.mjs.map +1 -1
  88. package/dist/esm/hooks/types.mjs +1 -0
  89. package/dist/esm/hooks/types.mjs.map +1 -1
  90. package/dist/esm/langfuse.mjs +9 -1
  91. package/dist/esm/langfuse.mjs.map +1 -1
  92. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  93. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  94. package/dist/esm/llm/invoke.mjs +270 -6
  95. package/dist/esm/llm/invoke.mjs.map +1 -1
  96. package/dist/esm/llm/openai/index.mjs +51 -6
  97. package/dist/esm/llm/openai/index.mjs.map +1 -1
  98. package/dist/esm/llm/preempt.mjs +131 -0
  99. package/dist/esm/llm/preempt.mjs.map +1 -0
  100. package/dist/esm/main.mjs +11 -7
  101. package/dist/esm/messages/alternation.mjs +91 -0
  102. package/dist/esm/messages/alternation.mjs.map +1 -0
  103. package/dist/esm/messages/format.mjs +72 -0
  104. package/dist/esm/messages/format.mjs.map +1 -1
  105. package/dist/esm/messages/handoffCue.mjs +61 -0
  106. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  107. package/dist/esm/messages/index.mjs +3 -0
  108. package/dist/esm/messages/injected.mjs +60 -0
  109. package/dist/esm/messages/injected.mjs.map +1 -0
  110. package/dist/esm/run.mjs +80 -7
  111. package/dist/esm/run.mjs.map +1 -1
  112. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  113. package/dist/esm/session/handlers.mjs +19 -10
  114. package/dist/esm/session/handlers.mjs.map +1 -1
  115. package/dist/esm/stream.mjs +33 -9
  116. package/dist/esm/stream.mjs.map +1 -1
  117. package/dist/esm/tools/BashExecutor.mjs +3 -1
  118. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  119. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
  120. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  121. package/dist/esm/tools/CodeExecutor.mjs +19 -16
  122. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  123. package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
  124. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  125. package/dist/esm/tools/ReadFile.mjs +8 -4
  126. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  127. package/dist/esm/tools/SkillTool.mjs +2 -0
  128. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  129. package/dist/esm/tools/SubagentTool.mjs +3 -0
  130. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  131. package/dist/esm/tools/ToolNode.mjs +91 -51
  132. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  133. package/dist/esm/tools/ToolSearch.mjs +5 -1
  134. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  135. package/dist/esm/tools/intentArg.mjs +220 -0
  136. package/dist/esm/tools/intentArg.mjs.map +1 -0
  137. package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
  138. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  139. package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
  140. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  141. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  142. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  143. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  144. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  145. package/dist/esm/tools/search/schema.mjs +2 -0
  146. package/dist/esm/tools/search/schema.mjs.map +1 -1
  147. package/dist/esm/tools/search/tool.mjs +40 -3
  148. package/dist/esm/tools/search/tool.mjs.map +1 -1
  149. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  150. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  151. package/dist/esm/tools/toolOutputReferences.mjs +41 -7
  152. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  153. package/dist/types/common/constants.d.ts +18 -0
  154. package/dist/types/graphs/Graph.d.ts +138 -1
  155. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  156. package/dist/types/hooks/index.d.ts +12 -1
  157. package/dist/types/hooks/types.d.ts +45 -6
  158. package/dist/types/index.d.ts +2 -1
  159. package/dist/types/llm/invoke.d.ts +7 -0
  160. package/dist/types/llm/openai/index.d.ts +0 -11
  161. package/dist/types/llm/preempt.d.ts +40 -0
  162. package/dist/types/messages/alternation.d.ts +23 -0
  163. package/dist/types/messages/handoffCue.d.ts +40 -0
  164. package/dist/types/messages/index.d.ts +3 -0
  165. package/dist/types/messages/injected.d.ts +3 -0
  166. package/dist/types/run.d.ts +7 -0
  167. package/dist/types/stream.d.ts +14 -0
  168. package/dist/types/tools/BashExecutor.d.ts +18 -0
  169. package/dist/types/tools/CodeExecutor.d.ts +18 -0
  170. package/dist/types/tools/ReadFile.d.ts +18 -0
  171. package/dist/types/tools/SkillTool.d.ts +18 -0
  172. package/dist/types/tools/SubagentTool.d.ts +9 -0
  173. package/dist/types/tools/ToolNode.d.ts +37 -12
  174. package/dist/types/tools/ToolSearch.d.ts +18 -0
  175. package/dist/types/tools/intentArg.d.ts +119 -0
  176. package/dist/types/tools/ptcTimeout.d.ts +2 -0
  177. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  178. package/dist/types/tools/search/schema.d.ts +18 -0
  179. package/dist/types/tools/search/tool.d.ts +18 -0
  180. package/dist/types/tools/search/types.d.ts +31 -2
  181. package/dist/types/tools/toolOutputReferences.d.ts +12 -2
  182. package/dist/types/types/graph.d.ts +8 -1
  183. package/dist/types/types/run.d.ts +65 -0
  184. package/dist/types/types/stream.d.ts +12 -25
  185. package/dist/types/types/tools.d.ts +27 -2
  186. package/package.json +1 -1
  187. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  188. package/src/common/constants.ts +21 -0
  189. package/src/events.ts +15 -1
  190. package/src/graphs/Graph.ts +568 -3
  191. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  192. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  193. package/src/hooks/HookRegistry.ts +40 -0
  194. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  195. package/src/hooks/index.ts +16 -2
  196. package/src/hooks/types.ts +47 -3
  197. package/src/index.ts +2 -1
  198. package/src/langfuse.ts +26 -1
  199. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  200. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  201. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  202. package/src/llm/invoke.alternation.test.ts +87 -0
  203. package/src/llm/invoke.handoffCue.test.ts +112 -0
  204. package/src/llm/invoke.ts +417 -8
  205. package/src/llm/openai/index.ts +74 -10
  206. package/src/llm/openai/llm.spec.ts +105 -0
  207. package/src/llm/preempt.test.ts +323 -0
  208. package/src/llm/preempt.ts +178 -0
  209. package/src/messages/alternation.test.ts +212 -0
  210. package/src/messages/alternation.ts +112 -0
  211. package/src/messages/format.ts +91 -0
  212. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  213. package/src/messages/handoffCue.test.ts +96 -0
  214. package/src/messages/handoffCue.ts +78 -0
  215. package/src/messages/index.ts +3 -0
  216. package/src/messages/injected.test.ts +90 -0
  217. package/src/messages/injected.ts +74 -0
  218. package/src/run.ts +91 -6
  219. package/src/scripts/preempt-probe.ts +330 -0
  220. package/src/scripts/preempt-scenarios.ts +388 -0
  221. package/src/session/handlers.ts +32 -12
  222. package/src/specs/handoffCue.test.ts +165 -0
  223. package/src/specs/langfuse-callbacks.test.ts +352 -2
  224. package/src/specs/preemptSeal.test.ts +309 -0
  225. package/src/stream.dispatch.test.ts +63 -0
  226. package/src/stream.ts +48 -9
  227. package/src/tools/BashExecutor.ts +6 -0
  228. package/src/tools/BashProgrammaticToolCalling.ts +2 -0
  229. package/src/tools/CodeExecutor.ts +6 -0
  230. package/src/tools/ProgrammaticToolCalling.ts +2 -0
  231. package/src/tools/ReadFile.ts +2 -0
  232. package/src/tools/SkillTool.ts +2 -0
  233. package/src/tools/SubagentTool.ts +3 -0
  234. package/src/tools/ToolNode.ts +193 -74
  235. package/src/tools/ToolSearch.ts +3 -0
  236. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
  237. package/src/tools/__tests__/intentArg.test.ts +281 -0
  238. package/src/tools/__tests__/intentCoverage.test.ts +139 -0
  239. package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
  240. package/src/tools/intentArg.ts +323 -0
  241. package/src/tools/local/CompileCheckTool.ts +4 -3
  242. package/src/tools/local/LocalCodingTools.ts +14 -13
  243. package/src/tools/ptcTimeout.ts +2 -0
  244. package/src/tools/search/keenable-scraper.test.ts +153 -0
  245. package/src/tools/search/keenable-scraper.ts +137 -0
  246. package/src/tools/search/outcome.test.ts +90 -0
  247. package/src/tools/search/schema.ts +3 -0
  248. package/src/tools/search/tool.ts +60 -3
  249. package/src/tools/search/types.ts +50 -3
  250. package/src/tools/subagent/SubagentExecutor.ts +2 -1
  251. package/src/tools/toolOutputReferences.ts +77 -9
  252. package/src/types/graph.ts +12 -1
  253. package/src/types/run.ts +67 -0
  254. package/src/types/stream.ts +12 -41
  255. package/src/types/tools.ts +24 -2
  256. package/dist/cjs/splitStream.cjs +0 -151
  257. package/dist/cjs/splitStream.cjs.map +0 -1
  258. package/dist/esm/splitStream.mjs +0 -150
  259. package/dist/esm/splitStream.mjs.map +0 -1
  260. package/dist/types/mockStream.d.ts +0 -32
  261. package/dist/types/splitStream.d.ts +0 -37
  262. package/src/mockStream.ts +0 -99
  263. package/src/splitStream.ts +0 -234
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @fileoverview Tool intent labels.
3
+ *
4
+ * Lets a tool declare, as the FIRST property of its input schema, an `intent`
5
+ * string: one model-authored sentence stating what that specific call is about
6
+ * to do ("Searching for OAuth handling in the callback router"). Because the
7
+ * property is first, it is the first key providers stream in the tool-call
8
+ * args, so a host UI can render it as the call's live status label before the
9
+ * rest of the args exist. When the call settles, {@link applyOutcome} edits
10
+ * the sentence in place into its outcome form — a tool-supplied replacement
11
+ * (`outcome`), a tool-supplied span edit (`outcome_patch`), or a mechanical
12
+ * present-progressive→past-tense transform of the leading verb.
13
+ *
14
+ * The arg is always optional (never listed in `required`): the same schemas
15
+ * are callable from programmatic tool calling, where no UI renders a label
16
+ * and forcing generated code to fabricate one would be pure cost. Tool bodies
17
+ * must call {@link stripIntent} before using their args so no tool receives a
18
+ * parameter it did not declare.
19
+ */
20
+
21
+ import type { JsonSchemaType, OutcomePatch } from '@/types';
22
+
23
+ /** Argument carrying the model-authored label for a tool call. */
24
+ export const INTENT_ARG = 'intent';
25
+
26
+ /** Model-facing instruction for the injected `intent` property. */
27
+ export const INTENT_DESCRIPTION =
28
+ 'ALWAYS write this field FIRST, before any other argument. One short sentence, ' +
29
+ 'present progressive, stating what this specific call is about to do: ' +
30
+ '"Searching for OAuth handling in the callback router". It is shown to the user ' +
31
+ 'as the live status label for this call while it runs, so write it for a human ' +
32
+ 'reading a progress line. Do not restate the tool name. Do not exceed one sentence. ' +
33
+ 'When you make several calls to the same tool in one turn, each intent must ' +
34
+ 'distinguish that call from its siblings.';
35
+
36
+ /**
37
+ * Canonical (frozen) shape of the injected property. Always embed a COPY
38
+ * (`{ ...INTENT_PROPERTY }`): LangChain's JSON-schema validator stamps a
39
+ * `__absolute_uri__` marker onto every subschema it dereferences, which
40
+ * throws on a frozen object — and a single shared instance would be stamped
41
+ * with one schema's URI while embedded in many.
42
+ */
43
+ export const INTENT_PROPERTY: JsonSchemaType = Object.freeze<JsonSchemaType>({
44
+ type: 'string',
45
+ description: INTENT_DESCRIPTION,
46
+ });
47
+
48
+ /**
49
+ * Discriminates the intent LABEL property from a tool's own business
50
+ * parameter that merely shares the name: the label contract always opens
51
+ * with the same instruction. Removal/sanitize passes must never strip a
52
+ * parameter the tool actually needs.
53
+ */
54
+ export function isIntentLabelProperty(property: unknown): boolean {
55
+ if (property == null || typeof property !== 'object') {
56
+ return false;
57
+ }
58
+ const record = property as { type?: unknown; description?: unknown };
59
+ return (
60
+ record.type === 'string' &&
61
+ typeof record.description === 'string' &&
62
+ record.description.startsWith('ALWAYS write this field FIRST')
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Returns a copy of the parameters schema with `intent` prepended as the
68
+ * FIRST property (object key order is insertion order and every provider
69
+ * serializer preserves it — first key in the schema means first key in the
70
+ * streamed input). Never mutates the input; no-op when the schema already
71
+ * declares `intent`. The property is not added to `required`.
72
+ */
73
+ export function withIntent(parameters?: JsonSchemaType): JsonSchemaType {
74
+ const existingProps = parameters?.properties ?? {};
75
+ if (INTENT_ARG in existingProps) {
76
+ return parameters as JsonSchemaType;
77
+ }
78
+ return {
79
+ ...parameters,
80
+ type: 'object',
81
+ properties: { [INTENT_ARG]: { ...INTENT_PROPERTY }, ...existingProps },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Coerces tool-call args to an object, parsing a stringified JSON object
87
+ * (some providers deliver args as a string). Returns undefined otherwise.
88
+ */
89
+ function coerceArgsObject(args: unknown): Record<string, unknown> | undefined {
90
+ if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
91
+ return args as Record<string, unknown>;
92
+ }
93
+ if (typeof args === 'string' && args.trim().startsWith('{')) {
94
+ try {
95
+ const parsed = JSON.parse(args) as unknown;
96
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
97
+ return parsed as Record<string, unknown>;
98
+ }
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /**
107
+ * Reads the model-authored intent from tool-call args (handles stringified
108
+ * args). Returns undefined when absent, empty, or not a string.
109
+ */
110
+ export function readIntent(args: unknown): string | undefined {
111
+ const value = coerceArgsObject(args)?.[INTENT_ARG];
112
+ if (typeof value !== 'string') {
113
+ return undefined;
114
+ }
115
+ const trimmed = value.trim();
116
+ return trimmed === '' ? undefined : trimmed;
117
+ }
118
+
119
+ /**
120
+ * Returns the args without the `intent` key so downstream consumers that did
121
+ * not declare it never receive it. Parses stringified JSON object args;
122
+ * returns the value unchanged when the key is absent.
123
+ */
124
+ export function stripIntent(args: unknown): unknown {
125
+ const obj = coerceArgsObject(args);
126
+ if (!obj || !(INTENT_ARG in obj)) {
127
+ return args;
128
+ }
129
+ const { [INTENT_ARG]: _omit, ...rest } = obj;
130
+ return rest;
131
+ }
132
+
133
+ /**
134
+ * Leading-verb map for the mechanical outcome transform, keyed by the
135
+ * lowercased first word of the intent. Deliberately small: an unknown leading
136
+ * word leaves the intent unchanged rather than mangling it.
137
+ */
138
+ const OUTCOME_VERB_MAP: ReadonlyMap<string, string> = new Map([
139
+ ['searching', 'Searched'],
140
+ ['reading', 'Read'],
141
+ ['writing', 'Wrote'],
142
+ ['editing', 'Edited'],
143
+ ['running', 'Ran'],
144
+ ['creating', 'Created'],
145
+ ['checking', 'Checked'],
146
+ ['fetching', 'Fetched'],
147
+ ['listing', 'Listed'],
148
+ ['looking', 'Looked'],
149
+ ['building', 'Built'],
150
+ ['deleting', 'Deleted'],
151
+ ['updating', 'Updated'],
152
+ ['adding', 'Added'],
153
+ ['removing', 'Removed'],
154
+ ['verifying', 'Verified'],
155
+ ['analyzing', 'Analyzed'],
156
+ ['generating', 'Generated'],
157
+ ['delegating', 'Delegated'],
158
+ ['spawning', 'Spawned'],
159
+ ['compiling', 'Compiled'],
160
+ ['grepping', 'Grepped'],
161
+ ]);
162
+
163
+ function matchLeadingCase(replacement: string, original: string): string {
164
+ if (original.charAt(0) === original.charAt(0).toLowerCase()) {
165
+ return replacement.charAt(0).toLowerCase() + replacement.slice(1);
166
+ }
167
+ return replacement;
168
+ }
169
+
170
+ function transformLeadingVerb(intent: string): string {
171
+ const spaceIdx = intent.search(/\s/);
172
+ const leading = spaceIdx === -1 ? intent : intent.slice(0, spaceIdx);
173
+ const mapped = OUTCOME_VERB_MAP.get(leading.toLowerCase());
174
+ if (mapped == null) {
175
+ return intent;
176
+ }
177
+ return matchLeadingCase(mapped, leading) + intent.slice(leading.length);
178
+ }
179
+
180
+ /**
181
+ * Resolves the settled label for a call from its model-authored `intent` and
182
+ * the tool's result fields, in precedence order:
183
+ *
184
+ * 1. `outcome` — full replacement authored by the tool.
185
+ * 2. `outcome_patch` — first occurrence of `from` in the intent replaced
186
+ * with `to` (case-sensitive); no-op when `from` is absent or empty.
187
+ * 3. Mechanical transform — the leading word mapped present-progressive →
188
+ * past tense; an unknown leading word leaves the intent unchanged.
189
+ *
190
+ * Returns undefined when there is neither an intent nor an outcome, so
191
+ * callers fall back to their default label. Pure and dependency-free — host
192
+ * UIs needing identical logic can import or mirror it.
193
+ */
194
+ export function applyOutcome(
195
+ intent: string | undefined,
196
+ result?: { outcome?: string; outcome_patch?: OutcomePatch },
197
+ ): string | undefined {
198
+ const outcome = result?.outcome;
199
+ if (typeof outcome === 'string' && outcome.trim() !== '') {
200
+ return outcome;
201
+ }
202
+ if (intent == null || intent === '') {
203
+ return undefined;
204
+ }
205
+ const patch = result?.outcome_patch;
206
+ if (patch != null && patch.from !== '' && intent.includes(patch.from)) {
207
+ /** Replacement callback keeps `to` verbatim — a direct string second
208
+ * argument would interpret `$&`/`$'`-style tokens in tool-authored
209
+ * text (e.g. labels derived from shell syntax). */
210
+ return intent.replace(patch.from, () => patch.to);
211
+ }
212
+ return transformLeadingVerb(intent);
213
+ }
214
+
215
+ /**
216
+ * Hard cap on an emitted outcome label. The label is a single progress line
217
+ * in UI chrome; a tool that derives it from data (or a malformed patch)
218
+ * must not be able to inflate completion events or persisted parts.
219
+ */
220
+ const MAX_OUTCOME_CHARS = 256;
221
+
222
+ function boundOutcomeLabel(label: string | undefined): string | undefined {
223
+ if (label == null) {
224
+ return undefined;
225
+ }
226
+ const singleLine = label.replace(/\s+/g, ' ').trim();
227
+ if (singleLine === '') {
228
+ return undefined;
229
+ }
230
+ if (singleLine.length <= MAX_OUTCOME_CHARS) {
231
+ return singleLine;
232
+ }
233
+ return `${singleLine.slice(0, MAX_OUTCOME_CHARS - 1)}…`;
234
+ }
235
+
236
+ /**
237
+ * Resolves the settled label to emit on a completion event: only when the
238
+ * tool actually authored `outcome`/`outcome_patch` fields. Returns undefined
239
+ * otherwise — the mechanical transform of a bare intent is left to the host
240
+ * so the wire never carries a label the host can derive itself. The result
241
+ * is collapsed to a bounded single line before emission.
242
+ *
243
+ * For failed calls (`isError`), only tool-AUTHORED text may label the call:
244
+ * an explicit `outcome`, or a patch whose `from` actually matches the
245
+ * intent. An unmatched patch must not fall through to the mechanical
246
+ * past-tense transform — wording drift in a failure patch would otherwise
247
+ * render a success-looking label for an error.
248
+ */
249
+ export function resolveToolOutcome(
250
+ args: unknown,
251
+ fields?: { outcome?: string; outcome_patch?: OutcomePatch } | null,
252
+ options?: { isError?: boolean },
253
+ ): string | undefined {
254
+ if (fields == null || (fields.outcome == null && fields.outcome_patch == null)) {
255
+ return undefined;
256
+ }
257
+ if (options?.isError !== true) {
258
+ return boundOutcomeLabel(applyOutcome(readIntent(args), fields));
259
+ }
260
+ const outcome = fields.outcome;
261
+ if (typeof outcome === 'string' && outcome.trim() !== '') {
262
+ return boundOutcomeLabel(outcome);
263
+ }
264
+ const intent = readIntent(args);
265
+ const patch = fields.outcome_patch;
266
+ if (
267
+ intent != null &&
268
+ patch != null &&
269
+ patch.from !== '' &&
270
+ intent.includes(patch.from)
271
+ ) {
272
+ return boundOutcomeLabel(intent.replace(patch.from, () => patch.to));
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ /**
278
+ * Reads the outcome fields off a tool-execution result: the typed
279
+ * `outcome`/`outcome_patch` fields when present, else the artifact channel
280
+ * (see {@link readOutcomeFields}) — so a `content_and_artifact` tool authors
281
+ * its label the same way on the direct and event-driven paths.
282
+ */
283
+ export function outcomeFieldsFromResult(result: {
284
+ outcome?: string;
285
+ outcome_patch?: OutcomePatch;
286
+ artifact?: unknown;
287
+ }): { outcome?: string; outcome_patch?: OutcomePatch } | undefined {
288
+ if (result.outcome != null || result.outcome_patch != null) {
289
+ return result;
290
+ }
291
+ return readOutcomeFields(result.artifact);
292
+ }
293
+
294
+ /**
295
+ * Extracts validated `outcome`/`outcome_patch` fields from an arbitrary
296
+ * value — the artifact channel through which an in-process
297
+ * `content_and_artifact` tool authors its settled label. Returns undefined
298
+ * when neither field is usable.
299
+ */
300
+ export function readOutcomeFields(
301
+ source: unknown,
302
+ ): { outcome?: string; outcome_patch?: OutcomePatch } | undefined {
303
+ if (source == null || typeof source !== 'object' || Array.isArray(source)) {
304
+ return undefined;
305
+ }
306
+ const record = source as Record<string, unknown>;
307
+ const outcome =
308
+ typeof record.outcome === 'string' && record.outcome.trim() !== ''
309
+ ? record.outcome
310
+ : undefined;
311
+ let outcome_patch: OutcomePatch | undefined;
312
+ const rawPatch = record.outcome_patch;
313
+ if (rawPatch != null && typeof rawPatch === 'object' && !Array.isArray(rawPatch)) {
314
+ const patch = rawPatch as Record<string, unknown>;
315
+ if (typeof patch.from === 'string' && typeof patch.to === 'string') {
316
+ outcome_patch = { from: patch.from, to: patch.to };
317
+ }
318
+ }
319
+ if (outcome == null && outcome_patch == null) {
320
+ return undefined;
321
+ }
322
+ return { outcome, outcome_patch };
323
+ }
@@ -27,7 +27,6 @@ import { resolve } from 'path';
27
27
  import { tool } from '@langchain/core/tools';
28
28
  import type { DynamicStructuredTool } from '@langchain/core/tools';
29
29
  import type { WorkspaceFS } from './workspaceFS';
30
- import { isWorkspaceClientTimeoutError } from './workspaceFS';
31
30
  import type * as t from '@/types';
32
31
  import {
33
32
  getLocalCwd,
@@ -36,12 +35,14 @@ import {
36
35
  truncateLocalOutput,
37
36
  validateBashCommand,
38
37
  } from './LocalExecutionEngine';
38
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
39
+ import { withIntent } from '@/tools/intentArg';
39
40
  import { Constants } from '@/common';
40
41
 
41
42
  /** Back-compat alias; canonical name lives on `Constants.COMPILE_CHECK`. */
42
43
  export const CompileCheckToolName = Constants.COMPILE_CHECK;
43
44
 
44
- const CompileCheckSchema: t.JsonSchemaType = {
45
+ const CompileCheckSchema: t.JsonSchemaType = withIntent({
45
46
  type: 'object',
46
47
  properties: {
47
48
  command: {
@@ -55,7 +56,7 @@ const CompileCheckSchema: t.JsonSchemaType = {
55
56
  'Optional timeout in milliseconds. Defaults to 120000 (2 min).',
56
57
  },
57
58
  },
58
- };
59
+ });
59
60
 
60
61
  type DetectedKind =
61
62
  | 'typescript'
@@ -3,7 +3,6 @@ import { createTwoFilesPatch } from 'diff';
3
3
  import { tool } from '@langchain/core/tools';
4
4
  import type { DynamicStructuredTool } from '@langchain/core/tools';
5
5
  import type * as t from '@/types';
6
- import { isWorkspaceClientTimeoutError } from './workspaceFS';
7
6
  import {
8
7
  createLocalBashProgrammaticToolCallingTool,
9
8
  createLocalProgrammaticToolCallingTool,
@@ -25,9 +24,11 @@ import {
25
24
  } from './CompileCheckTool';
26
25
  import { classifyAttachment, imageAttachmentContent } from './attachments';
27
26
  import { createLocalFileCheckpointer } from './FileCheckpointer';
27
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
28
28
  import { applyEdit, locateEdit } from './editStrategies';
29
29
  import { decodeFile, encodeFile } from './textEncoding';
30
30
  import { runPostEditSyntaxCheck } from './syntaxCheck';
31
+ import { withIntent } from '@/tools/intentArg';
31
32
  import { Constants } from '@/common';
32
33
 
33
34
  const MAX_READ_CHARS = 256000;
@@ -47,7 +48,7 @@ export const LocalGrepSearchToolName = Constants.GREP_SEARCH;
47
48
  export const LocalGlobSearchToolName = Constants.GLOB_SEARCH;
48
49
  export const LocalListDirectoryToolName = Constants.LIST_DIRECTORY;
49
50
 
50
- export const LocalReadFileToolSchema: t.JsonSchemaType = {
51
+ export const LocalReadFileToolSchema: t.JsonSchemaType = withIntent({
51
52
  type: 'object',
52
53
  properties: {
53
54
  path: {
@@ -65,9 +66,9 @@ export const LocalReadFileToolSchema: t.JsonSchemaType = {
65
66
  },
66
67
  },
67
68
  required: ['path'],
68
- };
69
+ });
69
70
 
70
- export const LocalWriteFileToolSchema: t.JsonSchemaType = {
71
+ export const LocalWriteFileToolSchema: t.JsonSchemaType = withIntent({
71
72
  type: 'object',
72
73
  properties: {
73
74
  path: {
@@ -81,9 +82,9 @@ export const LocalWriteFileToolSchema: t.JsonSchemaType = {
81
82
  },
82
83
  },
83
84
  required: ['path', 'content'],
84
- };
85
+ });
85
86
 
86
- export const LocalEditFileToolSchema: t.JsonSchemaType = {
87
+ export const LocalEditFileToolSchema: t.JsonSchemaType = withIntent({
87
88
  type: 'object',
88
89
  properties: {
89
90
  path: {
@@ -114,9 +115,9 @@ export const LocalEditFileToolSchema: t.JsonSchemaType = {
114
115
  },
115
116
  },
116
117
  required: ['path'],
117
- };
118
+ });
118
119
 
119
- export const LocalGrepSearchToolSchema: t.JsonSchemaType = {
120
+ export const LocalGrepSearchToolSchema: t.JsonSchemaType = withIntent({
120
121
  type: 'object',
121
122
  properties: {
122
123
  pattern: {
@@ -137,9 +138,9 @@ export const LocalGrepSearchToolSchema: t.JsonSchemaType = {
137
138
  },
138
139
  },
139
140
  required: ['pattern'],
140
- };
141
+ });
141
142
 
142
- export const LocalGlobSearchToolSchema: t.JsonSchemaType = {
143
+ export const LocalGlobSearchToolSchema: t.JsonSchemaType = withIntent({
143
144
  type: 'object',
144
145
  properties: {
145
146
  pattern: {
@@ -156,9 +157,9 @@ export const LocalGlobSearchToolSchema: t.JsonSchemaType = {
156
157
  },
157
158
  },
158
159
  required: ['pattern'],
159
- };
160
+ });
160
161
 
161
- export const LocalListDirectoryToolSchema: t.JsonSchemaType = {
162
+ export const LocalListDirectoryToolSchema: t.JsonSchemaType = withIntent({
162
163
  type: 'object',
163
164
  properties: {
164
165
  path: {
@@ -166,7 +167,7 @@ export const LocalListDirectoryToolSchema: t.JsonSchemaType = {
166
167
  description: 'Directory to list. Defaults to cwd.',
167
168
  },
168
169
  },
169
- };
170
+ });
170
171
 
171
172
  function lineWindow(
172
173
  content: string,
@@ -1,3 +1,4 @@
1
+ import type { JsonSchemaType } from '@/types';
1
2
  import { EnvVar } from '@/common';
2
3
 
3
4
  export const DEFAULT_CODE_API_RUN_TIMEOUT_MS = 15_000;
@@ -15,6 +16,7 @@ type TimeoutSchema = {
15
16
  export type ProgrammaticToolCallingJsonSchema = {
16
17
  type: 'object';
17
18
  properties: {
19
+ intent: JsonSchemaType;
18
20
  code: {
19
21
  type: 'string';
20
22
  minLength: number;
@@ -0,0 +1,153 @@
1
+ import axios from 'axios';
2
+ import { createKeenableScraper } from './keenable-scraper';
3
+
4
+ jest.mock('axios');
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
6
+
7
+ const sampleResponse = {
8
+ data: {
9
+ url: 'https://example.com/',
10
+ title: 'Example Domain',
11
+ content:
12
+ '# Example Domain\n\nThis domain is for use in documentation examples.',
13
+ description: 'An example page.',
14
+ },
15
+ };
16
+
17
+ describe('Keenable scraper', () => {
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ delete process.env.KEENABLE_API_KEY;
21
+ delete process.env.KEENABLE_FETCH_URL;
22
+ });
23
+
24
+ it('returns an error for empty URLs without calling the API', async () => {
25
+ const scraper = createKeenableScraper();
26
+ const [url, response] = await scraper.scrapeUrl(' ');
27
+
28
+ expect(url).toBe(' ');
29
+ expect(response.success).toBe(false);
30
+ expect(mockedAxios.get).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('hits the public endpoint and omits the API key header when keyless', async () => {
34
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
35
+
36
+ const scraper = createKeenableScraper();
37
+ const [, response] = await scraper.scrapeUrl('https://example.com');
38
+
39
+ expect(mockedAxios.get).toHaveBeenCalledWith(
40
+ 'https://api.keenable.ai/v1/fetch/public',
41
+ expect.objectContaining({
42
+ params: { url: 'https://example.com' },
43
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
44
+ })
45
+ );
46
+ const headers = mockedAxios.get.mock.calls[0][1]?.headers as Record<
47
+ string,
48
+ string
49
+ >;
50
+ expect(headers['X-API-Key']).toBeUndefined();
51
+ expect(response.success).toBe(true);
52
+ expect(response.data?.content).toContain('# Example Domain');
53
+ });
54
+
55
+ it('hits the keyed endpoint and sends the API key header when a key is set', async () => {
56
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
57
+
58
+ const scraper = createKeenableScraper({ apiKey: 'secret-key' });
59
+ await scraper.scrapeUrl('https://example.com');
60
+
61
+ expect(mockedAxios.get).toHaveBeenCalledWith(
62
+ 'https://api.keenable.ai/v1/fetch',
63
+ expect.objectContaining({
64
+ headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
65
+ })
66
+ );
67
+ });
68
+
69
+ it('honors a custom attribution title and fetch URL override', async () => {
70
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
71
+
72
+ const scraper = createKeenableScraper({
73
+ apiUrl: 'https://keenable.internal/v1/fetch/public',
74
+ attributionTitle: 'MyApp',
75
+ });
76
+ await scraper.scrapeUrl('https://example.com');
77
+
78
+ expect(mockedAxios.get).toHaveBeenCalledWith(
79
+ 'https://keenable.internal/v1/fetch/public',
80
+ expect.objectContaining({
81
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'MyApp' }),
82
+ })
83
+ );
84
+ });
85
+
86
+ it('reports failure when the API returns no content', async () => {
87
+ mockedAxios.get.mockResolvedValueOnce({
88
+ data: { url: 'https://x.test', content: '' },
89
+ });
90
+
91
+ const scraper = createKeenableScraper();
92
+ const [, response] = await scraper.scrapeUrl('https://x.test');
93
+
94
+ expect(response.success).toBe(false);
95
+ expect(response.error).toMatch(/no content/i);
96
+ });
97
+
98
+ it('reports failure with a message when the request throws', async () => {
99
+ mockedAxios.get.mockRejectedValueOnce(new Error('network down'));
100
+
101
+ const scraper = createKeenableScraper();
102
+ const [, response] = await scraper.scrapeUrl('https://x.test');
103
+
104
+ expect(response.success).toBe(false);
105
+ expect(response.error).toContain('network down');
106
+ });
107
+
108
+ it('scrapes multiple URLs concurrently', async () => {
109
+ mockedAxios.get
110
+ .mockResolvedValueOnce({ data: { content: 'a', url: 'https://a.test' } })
111
+ .mockResolvedValueOnce({ data: { content: 'b', url: 'https://b.test' } });
112
+
113
+ const scraper = createKeenableScraper();
114
+ const results = await scraper.scrapeUrls([
115
+ 'https://a.test',
116
+ 'https://b.test',
117
+ ]);
118
+
119
+ expect(results).toHaveLength(2);
120
+ expect(results[0][1].data?.content).toBe('a');
121
+ expect(results[1][1].data?.content).toBe('b');
122
+ });
123
+
124
+ it('extractContent returns the markdown with no structured references', () => {
125
+ const scraper = createKeenableScraper();
126
+ const [content, references] = scraper.extractContent({
127
+ success: true,
128
+ data: { content: '# Title\n\nBody' },
129
+ });
130
+
131
+ expect(content).toBe('# Title\n\nBody');
132
+ expect(references).toBeUndefined();
133
+ });
134
+
135
+ it('extractMetadata surfaces title, description and url', () => {
136
+ const scraper = createKeenableScraper();
137
+ const metadata = scraper.extractMetadata({
138
+ success: true,
139
+ data: {
140
+ content: 'x',
141
+ title: 'Example Domain',
142
+ description: 'An example page.',
143
+ url: 'https://example.com/',
144
+ },
145
+ });
146
+
147
+ expect(metadata).toEqual({
148
+ title: 'Example Domain',
149
+ description: 'An example page.',
150
+ url: 'https://example.com/',
151
+ });
152
+ });
153
+ });