@iinm/plain-agent 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
7
+ import { noThrow } from "../utils/noThrow.mjs";
8
+
9
+ /** @typedef {AskWebToolGeminiOptions | AskWebToolGeminiVertexAIOptions} AskWebToolOptions */
10
+
11
+ /**
12
+ * @typedef {Object} AskWebToolGeminiOptions
13
+ * @property {"gemini"} provider
14
+ * @property {string=} baseURL
15
+ * @property {string} apiKey
16
+ * @property {string} model
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} AskWebToolGeminiVertexAIOptions
21
+ * @property {"gemini-vertex-ai"} provider
22
+ * @property {string} baseURL
23
+ * @property {string=} account
24
+ * @property {string} model
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} AskWebInput
29
+ * @property {string} question
30
+ */
31
+
32
+ /**
33
+ * @param {AskWebToolOptions} config
34
+ * @returns {Tool}
35
+ */
36
+ export function createAskWebTool(config) {
37
+ /**
38
+ * @param {AskWebInput} input
39
+ * @param {number} retryCount
40
+ * @returns {Promise<string | Error>}
41
+ */
42
+ async function askWeb(input, retryCount = 0) {
43
+ const model = config.model ?? "gemini-3-flash-preview";
44
+ const url =
45
+ config.provider === "gemini-vertex-ai"
46
+ ? `${config.baseURL}/publishers/google/models/${config.model}:generateContent`
47
+ : config.baseURL
48
+ ? `${config.baseURL}/models/${model}:generateContent`
49
+ : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
50
+
51
+ /** @type {Record<string,string>} */
52
+ const authHeader =
53
+ config.provider === "gemini-vertex-ai"
54
+ ? {
55
+ Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
56
+ }
57
+ : {
58
+ "x-goog-api-key": config.apiKey ?? "",
59
+ };
60
+
61
+ const data = {
62
+ contents: [
63
+ {
64
+ role: "user",
65
+ parts: [
66
+ {
67
+ text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
68
+
69
+ Question: ${input.question}`,
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ tools: [
75
+ {
76
+ google_search: {},
77
+ },
78
+ ],
79
+ };
80
+
81
+ const response = await fetch(url, {
82
+ method: "POST",
83
+ headers: {
84
+ ...authHeader,
85
+ "Content-Type": "application/json",
86
+ },
87
+ body: JSON.stringify(data),
88
+ signal: AbortSignal.timeout(120 * 1000),
89
+ });
90
+
91
+ if (response.status === 429 || response.status >= 500) {
92
+ const interval = Math.min(2 * 2 ** retryCount, 16);
93
+ console.error(
94
+ styleText(
95
+ "yellow",
96
+ `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
97
+ ),
98
+ );
99
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
100
+ return askWeb(input, retryCount + 1);
101
+ }
102
+
103
+ if (!response.ok) {
104
+ return new Error(
105
+ `Failed to ask Web: status=${response.status}, body=${await response.text()}`,
106
+ );
107
+ }
108
+
109
+ const body = await response.json();
110
+
111
+ const candidate = body.candidates?.[0];
112
+ const text = candidate?.content?.parts?.[0]?.text;
113
+ /** @type {{segment?:{startIndex:number,endIndex:number,text:string},groundingChunkIndices?:number[]}[] | undefined} */
114
+ const supports = candidate?.groundingMetadata?.groundingSupports;
115
+ /** @type {{web?:{uri:string,title:string}}[] | undefined} */
116
+ const chunks = candidate?.groundingMetadata?.groundingChunks;
117
+
118
+ if (typeof text !== "string") {
119
+ return new Error(
120
+ `Unexpected response format from Google: ${JSON.stringify(body)}`,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * @param {string} source
126
+ * @param {number} byteIndex
127
+ * @param {string} insertText
128
+ */
129
+ const insertTextAtUtf8ByteIndex = (source, byteIndex, insertText) => {
130
+ const sourceBuffer = Buffer.from(source, "utf8");
131
+ const normalizedByteIndex = Math.max(
132
+ 0,
133
+ Math.min(byteIndex, sourceBuffer.length),
134
+ );
135
+
136
+ return Buffer.concat([
137
+ sourceBuffer.subarray(0, normalizedByteIndex),
138
+ Buffer.from(insertText, "utf8"),
139
+ sourceBuffer.subarray(normalizedByteIndex),
140
+ ]).toString("utf8");
141
+ };
142
+
143
+ // Sort by end_index desc because Gemini grounding indexes are byte offsets
144
+ // into the original UTF-8 text.
145
+ const sortedSupports = supports?.toSorted(
146
+ (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
147
+ );
148
+
149
+ // Insert citations using UTF-8 byte offsets.
150
+ let textWithCitations = text;
151
+ for (const support of sortedSupports ?? []) {
152
+ const endIndex = support.segment?.endIndex;
153
+ if (
154
+ typeof endIndex !== "number" ||
155
+ !support.groundingChunkIndices?.length
156
+ ) {
157
+ continue;
158
+ }
159
+
160
+ textWithCitations = insertTextAtUtf8ByteIndex(
161
+ textWithCitations,
162
+ endIndex,
163
+ ` [${support.groundingChunkIndices.map((i) => i + 1).join(", ")}] `,
164
+ );
165
+ }
166
+
167
+ const chunkString = (chunks ?? [])
168
+ .map(
169
+ (chunk, index) =>
170
+ `- [${index + 1} - ${chunk.web?.title}](${chunk.web?.uri})`,
171
+ )
172
+ .join("\n");
173
+
174
+ return [textWithCitations, chunkString].join("\n\n");
175
+ }
176
+
177
+ return {
178
+ def: {
179
+ name: "ask_web",
180
+ description:
181
+ "Use the web search to answer questions that need up-to-date information or supporting sources.",
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {
185
+ question: {
186
+ type: "string",
187
+ description: "The question to ask",
188
+ },
189
+ },
190
+ required: ["question"],
191
+ },
192
+ },
193
+
194
+ /**
195
+ * @param {AskWebInput} input
196
+ * @returns {Promise<string | Error>}
197
+ */
198
+ impl: async (input) => await noThrow(async () => askWeb(input, 0)),
199
+ };
200
+ }
@@ -1,135 +0,0 @@
1
- /**
2
- * @import { Tool } from '../tool'
3
- */
4
-
5
- import { styleText } from "node:util";
6
- import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
7
- import { noThrow } from "../utils/noThrow.mjs";
8
-
9
- /**
10
- * @typedef {Object} AskGoogleToolOptions
11
- * @property {"vertex-ai"=} platform
12
- * @property {string=} baseURL
13
- * @property {string=} apiKey - API key for Google AI Studio
14
- * @property {string=} account - The Google Cloud account to use for Vertex AI
15
- * @property {string=} model
16
- */
17
-
18
- /**
19
- * @typedef {Object} AskGoogleInput
20
- * @property {string} question
21
- */
22
-
23
- /**
24
- * @param {AskGoogleToolOptions} config
25
- * @returns {Tool}
26
- */
27
- export function createAskGoogleTool(config) {
28
- /**
29
- * @param {AskGoogleInput} input
30
- * @param {number} retryCount
31
- * @returns {Promise<string | Error>}
32
- */
33
- async function askGoogle(input, retryCount = 0) {
34
- const model = config.model ?? "gemini-3-flash-preview";
35
- const url =
36
- config.platform === "vertex-ai" && config.baseURL
37
- ? `${config.baseURL}/publishers/google/models/${model}:generateContent`
38
- : config.baseURL
39
- ? `${config.baseURL}/models/${model}:generateContent`
40
- : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
41
-
42
- /** @type {Record<string,string>} */
43
- const authHeader =
44
- config.platform === "vertex-ai"
45
- ? {
46
- Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
47
- }
48
- : {
49
- "x-goog-api-key": config.apiKey ?? "",
50
- };
51
-
52
- const data = {
53
- contents: [
54
- {
55
- role: "user",
56
- parts: [
57
- {
58
- text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
59
-
60
- Question: ${input.question}`,
61
- },
62
- ],
63
- },
64
- ],
65
- tools: [
66
- {
67
- google_search: {},
68
- },
69
- ],
70
- };
71
-
72
- const response = await fetch(url, {
73
- method: "POST",
74
- headers: {
75
- ...authHeader,
76
- "Content-Type": "application/json",
77
- },
78
- body: JSON.stringify(data),
79
- signal: AbortSignal.timeout(120 * 1000),
80
- });
81
-
82
- if (response.status === 429 || response.status >= 500) {
83
- const interval = Math.min(2 * 2 ** retryCount, 16);
84
- console.error(
85
- styleText(
86
- "yellow",
87
- `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
88
- ),
89
- );
90
- await new Promise((resolve) => setTimeout(resolve, interval * 1000));
91
- return askGoogle(input, retryCount + 1);
92
- }
93
-
94
- if (!response.ok) {
95
- return new Error(
96
- `Failed to ask Google: status=${response.status}, body=${await response.text()}`,
97
- );
98
- }
99
-
100
- const body = await response.json();
101
-
102
- const answer = body.candidates?.[0]?.content?.parts?.[0]?.text;
103
-
104
- if (typeof answer !== "string") {
105
- return new Error(
106
- `Unexpected response format from Google: ${JSON.stringify(body)}`,
107
- );
108
- }
109
-
110
- return answer;
111
- }
112
-
113
- return {
114
- def: {
115
- name: "ask_google",
116
- description: "Ask Google a question using natural language",
117
- inputSchema: {
118
- type: "object",
119
- properties: {
120
- question: {
121
- type: "string",
122
- description: "The question to ask Google",
123
- },
124
- },
125
- required: ["question"],
126
- },
127
- },
128
-
129
- /**
130
- * @param {AskGoogleInput} input
131
- * @returns {Promise<string | Error>}
132
- */
133
- impl: async (input) => await noThrow(async () => askGoogle(input, 0)),
134
- };
135
- }
@@ -1,96 +0,0 @@
1
- /**
2
- * @import { Tool } from '../tool'
3
- */
4
-
5
- import { writeTmpFile } from "../tmpfile.mjs";
6
- import { noThrow } from "../utils/noThrow.mjs";
7
-
8
- const MAX_CONTENT_LENGTH = 1024 * 8;
9
-
10
- /** @type {Tool} */
11
- export const fetchWebPageTool = {
12
- def: {
13
- name: "fetch_web_page",
14
- description:
15
- "Fetch and extract web page content from a given URL, returning it as Markdown.",
16
- inputSchema: {
17
- type: "object",
18
- properties: {
19
- url: {
20
- type: "string",
21
- },
22
- },
23
- required: ["url"],
24
- },
25
- },
26
-
27
- /**
28
- * @param {Record<string, unknown>} input
29
- * @returns {Record<string, unknown>}
30
- */
31
- maskApprovalInput: (input) => {
32
- try {
33
- const url = new URL(String(input.url));
34
- return { url: url.hostname };
35
- } catch {
36
- return input;
37
- }
38
- },
39
-
40
- /**
41
- * @param {{url: string}} input
42
- * @returns {Promise<string | Error>}
43
- */
44
- impl: async (input) =>
45
- await noThrow(async () => {
46
- const { Readability } = await import("@mozilla/readability");
47
- const { JSDOM } = await import("jsdom");
48
- const TurndownService = (await import("turndown")).default;
49
-
50
- const response = await fetch(input.url, {
51
- signal: AbortSignal.timeout(30 * 1000),
52
- });
53
- const html = await response.text();
54
- const dom = new JSDOM(html, { url: input.url });
55
- const reader = new Readability(dom.window.document);
56
- const article = reader.parse();
57
-
58
- if (!article?.content) {
59
- return "";
60
- }
61
-
62
- const turndownService = new TurndownService({
63
- headingStyle: "atx",
64
- bulletListMarker: "-",
65
- codeBlockStyle: "fenced",
66
- });
67
-
68
- const markdown = turndownService.turndown(article.content);
69
- const trimmedMarkdown = markdown.trim();
70
-
71
- if (trimmedMarkdown.length <= MAX_CONTENT_LENGTH) {
72
- return trimmedMarkdown;
73
- }
74
-
75
- const filePath = await writeTmpFile(
76
- trimmedMarkdown,
77
- "read_web_page",
78
- "md",
79
- );
80
-
81
- const lineCount = trimmedMarkdown.split("\n").length;
82
-
83
- return [
84
- `Content is large (${trimmedMarkdown.length} characters, ${lineCount} lines) and saved to ${filePath}`,
85
- "- Use rg / awk to read specific parts",
86
- ].join("\n");
87
- }),
88
- };
89
-
90
- // Playground
91
- // (async () => {
92
- // const input = {
93
- // url: "https://devin.ai/agents101",
94
- // };
95
- // console.log(await fetchWebPageTool.impl(input));
96
- // })();
@@ -1,6 +0,0 @@
1
- /**
2
- * @doc https://docs.tavily.com/documentation/api-reference/endpoint/search
3
- */
4
- export type TavilySearchInput = {
5
- query: string;
6
- };
@@ -1,57 +0,0 @@
1
- /**
2
- * @import { Tool } from '../tool'
3
- * @import { TavilySearchInput } from './tavilySearch'
4
- */
5
-
6
- import { noThrow } from "../utils/noThrow.mjs";
7
-
8
- /**
9
- * @param {{tavilyApiKey?: string}} config
10
- * @returns {Tool}
11
- */
12
- export function createTavilySearchTool(config) {
13
- return {
14
- def: {
15
- name: "search_web",
16
- description: "Search the web for information",
17
- inputSchema: {
18
- type: "object",
19
- properties: {
20
- query: {
21
- type: "string",
22
- },
23
- },
24
- required: ["query"],
25
- },
26
- },
27
-
28
- /**
29
- * @param {TavilySearchInput} input
30
- * @returns {Promise<string | Error>}
31
- */
32
- impl: async (input) =>
33
- await noThrow(async () => {
34
- const response = await fetch("https://api.tavily.com/search", {
35
- method: "POST",
36
- headers: {
37
- Authorization: `Bearer ${config.tavilyApiKey}`,
38
- "Content-Type": "application/json",
39
- },
40
- body: JSON.stringify({
41
- ...input,
42
- max_results: 5,
43
- }),
44
- signal: AbortSignal.timeout(120 * 1000),
45
- });
46
-
47
- if (!response.ok) {
48
- return new Error(
49
- `Failed to search: status=${response.status}, body=${await response.text()}`,
50
- );
51
- }
52
-
53
- const body = await response.json();
54
- return JSON.stringify(body);
55
- }),
56
- };
57
- }