@radaros/core 0.3.1 → 0.3.3

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.
@@ -82,6 +82,12 @@ export class GoogleProvider implements ModelProvider {
82
82
  }
83
83
  }
84
84
 
85
+ if (options?.reasoning?.enabled) {
86
+ config.thinkingConfig = {
87
+ thinkingBudget: options.reasoning.budgetTokens ?? 10000,
88
+ };
89
+ }
90
+
85
91
  const params: Record<string, unknown> = {
86
92
  model: this.modelId,
87
93
  contents,
@@ -116,6 +122,12 @@ export class GoogleProvider implements ModelProvider {
116
122
  if (options?.topP !== undefined) config.topP = options.topP;
117
123
  if (options?.stop) config.stopSequences = options.stop;
118
124
 
125
+ if (options?.reasoning?.enabled) {
126
+ config.thinkingConfig = {
127
+ thinkingBudget: options.reasoning.budgetTokens ?? 10000,
128
+ };
129
+ }
130
+
119
131
  const params: Record<string, unknown> = {
120
132
  model: this.modelId,
121
133
  contents,
@@ -142,7 +154,9 @@ export class GoogleProvider implements ModelProvider {
142
154
  if (!candidate?.content?.parts) continue;
143
155
 
144
156
  for (const part of candidate.content.parts) {
145
- if (part.text) {
157
+ if (part.thought) {
158
+ yield { type: "thinking", text: part.text ?? "" };
159
+ } else if (part.text) {
146
160
  yield { type: "text", text: part.text };
147
161
  }
148
162
 
@@ -313,16 +327,19 @@ export class GoogleProvider implements ModelProvider {
313
327
  return cleaned;
314
328
  }
315
329
 
316
- private normalizeResponse(response: any): ModelResponse {
330
+ private normalizeResponse(response: any): ModelResponse & { thinking?: string } {
317
331
  const candidate = response.candidates?.[0];
318
332
  const parts = candidate?.content?.parts ?? [];
319
333
 
320
334
  let textContent = "";
335
+ let thinkingContent = "";
321
336
  const toolCalls: ToolCall[] = [];
322
337
  let toolCallCounter = 0;
323
338
 
324
339
  for (const part of parts) {
325
- if (part.text) {
340
+ if (part.thought && part.text) {
341
+ thinkingContent += part.text;
342
+ } else if (part.text) {
326
343
  textContent += part.text;
327
344
  }
328
345
  if (part.functionCall) {
@@ -334,10 +351,12 @@ export class GoogleProvider implements ModelProvider {
334
351
  }
335
352
  }
336
353
 
354
+ const thinkingTokens = response.usageMetadata?.thoughtsTokenCount ?? 0;
337
355
  const usage: TokenUsage = {
338
356
  promptTokens: response.usageMetadata?.promptTokenCount ?? 0,
339
357
  completionTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
340
358
  totalTokens: response.usageMetadata?.totalTokenCount ?? 0,
359
+ ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
341
360
  };
342
361
 
343
362
  let finishReason: ModelResponse["finishReason"] = "stop";
@@ -347,7 +366,7 @@ export class GoogleProvider implements ModelProvider {
347
366
  else if (candidate?.finishReason === "SAFETY")
348
367
  finishReason = "content_filter";
349
368
 
350
- return {
369
+ const result: ModelResponse & { thinking?: string } = {
351
370
  message: {
352
371
  role: "assistant",
353
372
  content: textContent || null,
@@ -357,5 +376,11 @@ export class GoogleProvider implements ModelProvider {
357
376
  finishReason,
358
377
  raw: response,
359
378
  };
379
+
380
+ if (thinkingContent) {
381
+ result.thinking = thinkingContent;
382
+ }
383
+
384
+ return result;
360
385
  }
361
386
  }
@@ -72,8 +72,12 @@ export class OpenAIProvider implements ModelProvider {
72
72
  messages: this.toOpenAIMessages(messages),
73
73
  };
74
74
 
75
- if (options?.temperature !== undefined)
76
- params.temperature = options.temperature;
75
+ if (options?.reasoning?.enabled) {
76
+ params.reasoning_effort = options.reasoning.effort ?? "medium";
77
+ } else {
78
+ if (options?.temperature !== undefined)
79
+ params.temperature = options.temperature;
80
+ }
77
81
  if (options?.maxTokens !== undefined)
78
82
  params.max_tokens = options.maxTokens;
79
83
  if (options?.topP !== undefined) params.top_p = options.topP;
@@ -98,8 +102,12 @@ export class OpenAIProvider implements ModelProvider {
98
102
  stream: true,
99
103
  };
100
104
 
101
- if (options?.temperature !== undefined)
102
- params.temperature = options.temperature;
105
+ if (options?.reasoning?.enabled) {
106
+ params.reasoning_effort = options.reasoning.effort ?? "medium";
107
+ } else {
108
+ if (options?.temperature !== undefined)
109
+ params.temperature = options.temperature;
110
+ }
103
111
  if (options?.maxTokens !== undefined)
104
112
  params.max_tokens = options.maxTokens;
105
113
  if (options?.topP !== undefined) params.top_p = options.topP;
@@ -165,11 +173,16 @@ export class OpenAIProvider implements ModelProvider {
165
173
  }
166
174
  }
167
175
 
176
+ if (delta?.reasoning_content) {
177
+ yield { type: "thinking", text: delta.reasoning_content };
178
+ }
179
+
168
180
  if (choice.finish_reason) {
169
181
  for (const [, tc] of activeToolCalls) {
170
182
  yield { type: "tool_call_end", toolCallId: tc.id };
171
183
  }
172
184
 
185
+ const reasoningTkns = chunk.usage?.completion_tokens_details?.reasoning_tokens ?? 0;
173
186
  yield {
174
187
  type: "finish",
175
188
  finishReason:
@@ -181,6 +194,7 @@ export class OpenAIProvider implements ModelProvider {
181
194
  promptTokens: chunk.usage.prompt_tokens ?? 0,
182
195
  completionTokens: chunk.usage.completion_tokens ?? 0,
183
196
  totalTokens: chunk.usage.total_tokens ?? 0,
197
+ ...(reasoningTkns > 0 ? { reasoningTokens: reasoningTkns } : {}),
184
198
  }
185
199
  : undefined,
186
200
  };
@@ -287,7 +301,7 @@ export class OpenAIProvider implements ModelProvider {
287
301
  }));
288
302
  }
289
303
 
290
- private normalizeResponse(response: any): ModelResponse {
304
+ private normalizeResponse(response: any): ModelResponse & { thinking?: string } {
291
305
  const choice = response.choices[0];
292
306
  const msg = choice.message;
293
307
 
@@ -297,10 +311,12 @@ export class OpenAIProvider implements ModelProvider {
297
311
  arguments: JSON.parse(tc.function.arguments || "{}"),
298
312
  }));
299
313
 
314
+ const reasoningTokens = response.usage?.completion_tokens_details?.reasoning_tokens ?? 0;
300
315
  const usage: TokenUsage = {
301
316
  promptTokens: response.usage?.prompt_tokens ?? 0,
302
317
  completionTokens: response.usage?.completion_tokens ?? 0,
303
318
  totalTokens: response.usage?.total_tokens ?? 0,
319
+ ...(reasoningTokens > 0 ? { reasoningTokens } : {}),
304
320
  };
305
321
 
306
322
  let finishReason: ModelResponse["finishReason"] = "stop";
@@ -309,7 +325,7 @@ export class OpenAIProvider implements ModelProvider {
309
325
  else if (choice.finish_reason === "content_filter")
310
326
  finishReason = "content_filter";
311
327
 
312
- return {
328
+ const result: ModelResponse & { thinking?: string } = {
313
329
  message: {
314
330
  role: "assistant",
315
331
  content: msg.content ?? null,
@@ -319,5 +335,11 @@ export class OpenAIProvider implements ModelProvider {
319
335
  finishReason,
320
336
  raw: response,
321
337
  };
338
+
339
+ if (msg.reasoning_content) {
340
+ result.thinking = msg.reasoning_content;
341
+ }
342
+
343
+ return result;
322
344
  }
323
345
  }
@@ -108,6 +108,12 @@ export class VertexAIProvider implements ModelProvider {
108
108
  }
109
109
  }
110
110
 
111
+ if (options?.reasoning?.enabled) {
112
+ config.thinkingConfig = {
113
+ thinkingBudget: options.reasoning.budgetTokens ?? 10000,
114
+ };
115
+ }
116
+
111
117
  const params: Record<string, unknown> = {
112
118
  model: this.modelId,
113
119
  contents,
@@ -140,6 +146,12 @@ export class VertexAIProvider implements ModelProvider {
140
146
  if (options?.topP !== undefined) config.topP = options.topP;
141
147
  if (options?.stop) config.stopSequences = options.stop;
142
148
 
149
+ if (options?.reasoning?.enabled) {
150
+ config.thinkingConfig = {
151
+ thinkingBudget: options.reasoning.budgetTokens ?? 10000,
152
+ };
153
+ }
154
+
143
155
  const params: Record<string, unknown> = {
144
156
  model: this.modelId,
145
157
  contents,
@@ -163,7 +175,9 @@ export class VertexAIProvider implements ModelProvider {
163
175
  if (!candidate?.content?.parts) continue;
164
176
 
165
177
  for (const part of candidate.content.parts) {
166
- if (part.text) {
178
+ if (part.thought) {
179
+ yield { type: "thinking", text: part.text ?? "" };
180
+ } else if (part.text) {
167
181
  yield { type: "text", text: part.text };
168
182
  }
169
183
 
@@ -354,16 +368,21 @@ export class VertexAIProvider implements ModelProvider {
354
368
  return cleaned;
355
369
  }
356
370
 
357
- private normalizeResponse(response: any): ModelResponse {
371
+ private normalizeResponse(response: any): ModelResponse & { thinking?: string } {
358
372
  const candidate = response.candidates?.[0];
359
373
  const parts = candidate?.content?.parts ?? [];
360
374
 
361
375
  let textContent = "";
376
+ let thinkingContent = "";
362
377
  const toolCalls: ToolCall[] = [];
363
378
  let toolCallCounter = 0;
364
379
 
365
380
  for (const part of parts) {
366
- if (part.text) textContent += part.text;
381
+ if (part.thought && part.text) {
382
+ thinkingContent += part.text;
383
+ } else if (part.text) {
384
+ textContent += part.text;
385
+ }
367
386
  if (part.functionCall) {
368
387
  toolCalls.push({
369
388
  id: `vertex_tc_${toolCallCounter++}`,
@@ -373,10 +392,12 @@ export class VertexAIProvider implements ModelProvider {
373
392
  }
374
393
  }
375
394
 
395
+ const thinkingTokens = response.usageMetadata?.thoughtsTokenCount ?? 0;
376
396
  const usage: TokenUsage = {
377
397
  promptTokens: response.usageMetadata?.promptTokenCount ?? 0,
378
398
  completionTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
379
399
  totalTokens: response.usageMetadata?.totalTokenCount ?? 0,
400
+ ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
380
401
  };
381
402
 
382
403
  let finishReason: ModelResponse["finishReason"] = "stop";
@@ -386,7 +407,7 @@ export class VertexAIProvider implements ModelProvider {
386
407
  else if (candidate?.finishReason === "SAFETY")
387
408
  finishReason = "content_filter";
388
409
 
389
- return {
410
+ const result: ModelResponse & { thinking?: string } = {
390
411
  message: {
391
412
  role: "assistant",
392
413
  content: textContent || null,
@@ -396,5 +417,11 @@ export class VertexAIProvider implements ModelProvider {
396
417
  finishReason,
397
418
  raw: response,
398
419
  };
420
+
421
+ if (thinkingContent) {
422
+ result.thinking = thinkingContent;
423
+ }
424
+
425
+ return result;
399
426
  }
400
427
  }
@@ -64,6 +64,7 @@ export interface TokenUsage {
64
64
  promptTokens: number;
65
65
  completionTokens: number;
66
66
  totalTokens: number;
67
+ reasoningTokens?: number;
67
68
  }
68
69
 
69
70
  // ── Model response ────────────────────────────────────────────────────────
@@ -77,6 +78,7 @@ export interface ModelResponse {
77
78
 
78
79
  export type StreamChunk =
79
80
  | { type: "text"; text: string }
81
+ | { type: "thinking"; text: string }
80
82
  | { type: "tool_call_start"; toolCall: { id: string; name: string } }
81
83
  | { type: "tool_call_delta"; toolCallId: string; argumentsDelta: string }
82
84
  | { type: "tool_call_end"; toolCallId: string }
@@ -84,6 +86,14 @@ export type StreamChunk =
84
86
 
85
87
  // ── Model config ──────────────────────────────────────────────────────────
86
88
 
89
+ export interface ReasoningConfig {
90
+ enabled: boolean;
91
+ /** Reasoning effort for OpenAI o-series models. */
92
+ effort?: "low" | "medium" | "high";
93
+ /** Token budget for thinking (Anthropic / Gemini). */
94
+ budgetTokens?: number;
95
+ }
96
+
87
97
  export interface ModelConfig {
88
98
  temperature?: number;
89
99
  maxTokens?: number;
@@ -92,6 +102,8 @@ export interface ModelConfig {
92
102
  responseFormat?: "text" | "json" | { type: "json_schema"; schema: Record<string, unknown>; name?: string };
93
103
  /** Per-request API key override. When provided, the provider uses this key instead of the one set at construction. */
94
104
  apiKey?: string;
105
+ /** Enable extended thinking / reasoning. */
106
+ reasoning?: ReasoningConfig;
95
107
  }
96
108
 
97
109
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -0,0 +1,256 @@
1
+ import { z } from "zod";
2
+ import type { ToolDef } from "../tools/types.js";
3
+ import type { RunContext } from "../agent/run-context.js";
4
+ import { Toolkit } from "./base.js";
5
+
6
+ export interface DuckDuckGoConfig {
7
+ /** Enable web search (default true). */
8
+ enableSearch?: boolean;
9
+ /** Enable news search (default true). */
10
+ enableNews?: boolean;
11
+ /** Fixed max results per query (default 5). */
12
+ maxResults?: number;
13
+ }
14
+
15
+ /**
16
+ * DuckDuckGo Toolkit — search the web and news without any API key.
17
+ *
18
+ * Uses the DuckDuckGo HTML API (no key required).
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const ddg = new DuckDuckGoToolkit();
23
+ * const agent = new Agent({ tools: [...ddg.getTools()] });
24
+ * ```
25
+ */
26
+ export class DuckDuckGoToolkit extends Toolkit {
27
+ readonly name = "duckduckgo";
28
+ private config: DuckDuckGoConfig;
29
+
30
+ constructor(config: DuckDuckGoConfig = {}) {
31
+ super();
32
+ this.config = {
33
+ enableSearch: config.enableSearch ?? true,
34
+ enableNews: config.enableNews ?? true,
35
+ maxResults: config.maxResults ?? 5,
36
+ };
37
+ }
38
+
39
+ getTools(): ToolDef[] {
40
+ const tools: ToolDef[] = [];
41
+
42
+ if (this.config.enableSearch) {
43
+ tools.push(this.buildSearchTool());
44
+ }
45
+
46
+ if (this.config.enableNews) {
47
+ tools.push(this.buildNewsTool());
48
+ }
49
+
50
+ return tools;
51
+ }
52
+
53
+ private buildSearchTool(): ToolDef {
54
+ const self = this;
55
+ return {
56
+ name: "duckduckgo_search",
57
+ description:
58
+ "Search the web using DuckDuckGo. Returns titles, URLs, and snippets. No API key required.",
59
+ parameters: z.object({
60
+ query: z.string().describe("The search query"),
61
+ maxResults: z
62
+ .number()
63
+ .optional()
64
+ .describe("Maximum number of results (default 5)"),
65
+ }),
66
+ async execute(
67
+ args: Record<string, unknown>,
68
+ _ctx: RunContext
69
+ ): Promise<string> {
70
+ const query = args.query as string;
71
+ const max = (args.maxResults as number) ?? self.config.maxResults ?? 5;
72
+ return self.search(query, max);
73
+ },
74
+ };
75
+ }
76
+
77
+ private buildNewsTool(): ToolDef {
78
+ const self = this;
79
+ return {
80
+ name: "duckduckgo_news",
81
+ description:
82
+ "Get the latest news from DuckDuckGo. Returns headlines, sources, URLs, and dates.",
83
+ parameters: z.object({
84
+ query: z.string().describe("The news search query"),
85
+ maxResults: z
86
+ .number()
87
+ .optional()
88
+ .describe("Maximum number of results (default 5)"),
89
+ }),
90
+ async execute(
91
+ args: Record<string, unknown>,
92
+ _ctx: RunContext
93
+ ): Promise<string> {
94
+ const query = args.query as string;
95
+ const max = (args.maxResults as number) ?? self.config.maxResults ?? 5;
96
+ return self.searchNews(query, max);
97
+ },
98
+ };
99
+ }
100
+
101
+ private async search(query: string, maxResults: number): Promise<string> {
102
+ const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
103
+
104
+ const res = await fetch(url, {
105
+ headers: { "User-Agent": "RadarOS/1.0" },
106
+ });
107
+
108
+ if (!res.ok) {
109
+ throw new Error(`DuckDuckGo search failed: ${res.status}`);
110
+ }
111
+
112
+ const data = (await res.json()) as any;
113
+ const results: string[] = [];
114
+
115
+ if (data.Abstract) {
116
+ results.push(
117
+ `Answer: ${data.Abstract}\nSource: ${data.AbstractSource}\nURL: ${data.AbstractURL}`
118
+ );
119
+ }
120
+
121
+ const topics = data.RelatedTopics ?? [];
122
+ for (const topic of topics.slice(0, maxResults)) {
123
+ if (topic.Text && topic.FirstURL) {
124
+ results.push(`${topic.Text}\nURL: ${topic.FirstURL}`);
125
+ }
126
+ if (topic.Topics) {
127
+ for (const sub of topic.Topics.slice(0, 2)) {
128
+ if (sub.Text && sub.FirstURL) {
129
+ results.push(`${sub.Text}\nURL: ${sub.FirstURL}`);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (results.length === 0 && data.Redirect) {
136
+ return `Redirect: ${data.Redirect}`;
137
+ }
138
+
139
+ if (results.length === 0) {
140
+ const htmlResults = await this.scrapeHtmlSearch(query, maxResults);
141
+ if (htmlResults) return htmlResults;
142
+ return "No results found.";
143
+ }
144
+
145
+ return results.join("\n\n---\n\n");
146
+ }
147
+
148
+ private async scrapeHtmlSearch(
149
+ query: string,
150
+ maxResults: number
151
+ ): Promise<string | null> {
152
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
153
+
154
+ const res = await fetch(url, {
155
+ headers: {
156
+ "User-Agent":
157
+ "Mozilla/5.0 (compatible; RadarOS/1.0; +https://radaros.dev)",
158
+ },
159
+ });
160
+
161
+ if (!res.ok) return null;
162
+
163
+ const html = await res.text();
164
+
165
+ const results: string[] = [];
166
+ const linkRegex =
167
+ /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
168
+ const snippetRegex =
169
+ /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
170
+
171
+ const links: { url: string; title: string }[] = [];
172
+ let match;
173
+ while ((match = linkRegex.exec(html)) !== null) {
174
+ const rawUrl = match[1];
175
+ const title = match[2].replace(/<[^>]*>/g, "").trim();
176
+ const decoded = this.decodeDdgUrl(rawUrl);
177
+ if (title && decoded) {
178
+ links.push({ url: decoded, title });
179
+ }
180
+ }
181
+
182
+ const snippets: string[] = [];
183
+ while ((match = snippetRegex.exec(html)) !== null) {
184
+ snippets.push(match[1].replace(/<[^>]*>/g, "").trim());
185
+ }
186
+
187
+ for (let i = 0; i < Math.min(links.length, maxResults); i++) {
188
+ results.push(
189
+ `Title: ${links[i].title}\nURL: ${links[i].url}\nSnippet: ${snippets[i] ?? ""}`
190
+ );
191
+ }
192
+
193
+ return results.length > 0 ? results.join("\n\n---\n\n") : null;
194
+ }
195
+
196
+ private decodeDdgUrl(url: string): string | null {
197
+ if (url.startsWith("//duckduckgo.com/l/?uddg=")) {
198
+ const match = url.match(/uddg=([^&]*)/);
199
+ if (match) return decodeURIComponent(match[1]);
200
+ }
201
+ if (url.startsWith("http")) return url;
202
+ return null;
203
+ }
204
+
205
+ private async searchNews(
206
+ query: string,
207
+ maxResults: number
208
+ ): Promise<string> {
209
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query + " news")}&iar=news`;
210
+
211
+ const res = await fetch(url, {
212
+ headers: {
213
+ "User-Agent":
214
+ "Mozilla/5.0 (compatible; RadarOS/1.0; +https://radaros.dev)",
215
+ },
216
+ });
217
+
218
+ if (!res.ok) {
219
+ throw new Error(`DuckDuckGo news search failed: ${res.status}`);
220
+ }
221
+
222
+ const html = await res.text();
223
+ const results: string[] = [];
224
+
225
+ const linkRegex =
226
+ /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
227
+ const snippetRegex =
228
+ /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
229
+
230
+ const links: { url: string; title: string }[] = [];
231
+ let match;
232
+ while ((match = linkRegex.exec(html)) !== null) {
233
+ const rawUrl = match[1];
234
+ const title = match[2].replace(/<[^>]*>/g, "").trim();
235
+ const decoded = this.decodeDdgUrl(rawUrl);
236
+ if (title && decoded) {
237
+ links.push({ url: decoded, title });
238
+ }
239
+ }
240
+
241
+ const snippets: string[] = [];
242
+ while ((match = snippetRegex.exec(html)) !== null) {
243
+ snippets.push(match[1].replace(/<[^>]*>/g, "").trim());
244
+ }
245
+
246
+ for (let i = 0; i < Math.min(links.length, maxResults); i++) {
247
+ results.push(
248
+ `Title: ${links[i].title}\nURL: ${links[i].url}\nSnippet: ${snippets[i] ?? ""}`
249
+ );
250
+ }
251
+
252
+ return results.length > 0
253
+ ? results.join("\n\n---\n\n")
254
+ : "No news results found.";
255
+ }
256
+ }
@@ -1,6 +1,6 @@
1
1
  import type { z } from "zod";
2
2
  import type { RunContext } from "../agent/run-context.js";
3
- import type { ToolDef, ToolResult } from "./types.js";
3
+ import type { ToolDef, ToolResult, ToolCacheConfig } from "./types.js";
4
4
 
5
5
  export function defineTool<T extends z.ZodObject<any>>(config: {
6
6
  name: string;
@@ -10,11 +10,13 @@ export function defineTool<T extends z.ZodObject<any>>(config: {
10
10
  args: z.infer<T>,
11
11
  ctx: RunContext
12
12
  ) => Promise<string | ToolResult>;
13
+ cache?: ToolCacheConfig;
13
14
  }): ToolDef {
14
15
  return {
15
16
  name: config.name,
16
17
  description: config.description,
17
18
  parameters: config.parameters,
18
19
  execute: config.execute as ToolDef["execute"],
20
+ cache: config.cache,
19
21
  };
20
22
  }
@@ -1,19 +1,61 @@
1
1
  import { createRequire } from "node:module";
2
2
  import type { ToolCall } from "../models/types.js";
3
3
  import type { RunContext } from "../agent/run-context.js";
4
- import type { ToolDef, ToolCallResult } from "./types.js";
4
+ import type { ToolDef, ToolCallResult, ToolResult } from "./types.js";
5
5
 
6
6
  const _require = createRequire(import.meta.url);
7
7
 
8
+ interface CacheEntry {
9
+ result: string | ToolResult;
10
+ expiresAt: number;
11
+ }
12
+
8
13
  export class ToolExecutor {
9
14
  private tools: Map<string, ToolDef>;
10
15
  private concurrency: number;
16
+ private cache = new Map<string, CacheEntry>();
11
17
 
12
18
  constructor(tools: ToolDef[], concurrency: number = 5) {
13
19
  this.tools = new Map(tools.map((t) => [t.name, t]));
14
20
  this.concurrency = concurrency;
15
21
  }
16
22
 
23
+ clearCache(): void {
24
+ this.cache.clear();
25
+ }
26
+
27
+ private getCacheKey(toolName: string, args: Record<string, unknown>): string {
28
+ const sortedArgs = JSON.stringify(args, Object.keys(args).sort());
29
+ return `${toolName}:${sortedArgs}`;
30
+ }
31
+
32
+ private getCached(toolName: string, args: Record<string, unknown>): (string | ToolResult) | undefined {
33
+ const tool = this.tools.get(toolName);
34
+ if (!tool?.cache) return undefined;
35
+
36
+ const key = this.getCacheKey(toolName, args);
37
+ const entry = this.cache.get(key);
38
+ if (!entry) return undefined;
39
+
40
+ if (Date.now() > entry.expiresAt) {
41
+ this.cache.delete(key);
42
+ return undefined;
43
+ }
44
+
45
+ return entry.result;
46
+ }
47
+
48
+ private setCache(toolName: string, args: Record<string, unknown>, result: string | ToolResult): void {
49
+ const tool = this.tools.get(toolName);
50
+ if (!tool?.cache) return;
51
+
52
+ const key = this.getCacheKey(toolName, args);
53
+ this.cache.set(key, {
54
+ result,
55
+ expiresAt: Date.now() + tool.cache.ttl,
56
+ });
57
+ }
58
+
17
59
  async executeAll(
18
60
  toolCalls: ToolCall[],
19
61
  ctx: RunContext
@@ -66,6 +108,24 @@ export class ToolExecutor {
66
108
  args: toolCall.arguments,
67
109
  });
68
110
 
111
+ const cachedResult = this.getCached(toolCall.name, toolCall.arguments);
112
+ if (cachedResult !== undefined) {
113
+ const resultContent =
114
+ typeof cachedResult === "string" ? cachedResult : cachedResult.content;
115
+
116
+ ctx.eventBus.emit("tool.result", {
117
+ runId: ctx.runId,
118
+ toolName: toolCall.name,
119
+ result: `[cached] ${resultContent}`,
120
+ });
121
+
122
+ return {
123
+ toolCallId: toolCall.id,
124
+ toolName: toolCall.name,
125
+ result: cachedResult,
126
+ };
127
+ }
128
+
69
129
  const parsed = tool.parameters.safeParse(toolCall.arguments);
70
130
  if (!parsed.success) {
71
131
  const errMsg = `Invalid arguments: ${parsed.error.message}`;
@@ -89,6 +149,8 @@ export class ToolExecutor {
89
149
  const resultContent =
90
150
  typeof rawResult === "string" ? rawResult : rawResult.content;
91
151
 
152
+ this.setCache(toolCall.name, toolCall.arguments, rawResult);
153
+
92
154
  ctx.eventBus.emit("tool.result", {
93
155
  runId: ctx.runId,
94
156
  toolName: toolCall.name,