@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.
- package/package.json +1 -1
- package/src/agent/agent.ts +53 -0
- package/src/agent/llm-loop.ts +18 -0
- package/src/agent/types.ts +8 -1
- package/src/index.ts +6 -1
- package/src/logger/logger.ts +19 -2
- package/src/memory/user-memory.ts +191 -0
- package/src/models/providers/anthropic.ts +34 -4
- package/src/models/providers/google.ts +29 -4
- package/src/models/providers/openai.ts +28 -6
- package/src/models/providers/vertex.ts +31 -4
- package/src/models/types.ts +12 -0
- package/src/toolkits/duckduckgo.ts +256 -0
- package/src/tools/define-tool.ts +3 -1
- package/src/tools/tool-executor.ts +63 -1
- package/src/tools/types.ts +7 -0
- package/dist/index.d.ts +0 -1283
- package/dist/index.js +0 -4640
|
@@ -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.
|
|
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
|
-
|
|
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?.
|
|
76
|
-
params.
|
|
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?.
|
|
102
|
-
params.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/models/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/define-tool.ts
CHANGED
|
@@ -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,
|