@iinm/plain-agent 1.1.0 → 1.2.1

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.
@@ -701,6 +701,21 @@
701
701
  }
702
702
  }
703
703
  },
704
+ {
705
+ "name": "glm-5",
706
+ "variant": "bedrock",
707
+ "platform": {
708
+ "name": "bedrock",
709
+ "variant": "default"
710
+ },
711
+ "model": {
712
+ "format": "openai-messages",
713
+ "config": {
714
+ "model": "zai.glm-5",
715
+ "reasoning_effort": "high"
716
+ }
717
+ }
718
+ },
704
719
  {
705
720
  "name": "glm-5",
706
721
  "variant": "ollama",
package/README.md CHANGED
@@ -87,19 +87,33 @@ Create the configuration.
87
87
 
88
88
  // Optional
89
89
  "tools": {
90
- "askGoogle": {
91
- "model": "gemini-3-flash-preview",
92
-
93
- // Google AI Studio
94
- "apiKey": "FIXME"
90
+ "askWeb": {
91
+ "provider": "gemini",
92
+ "apiKey": "FIXME",
93
+ "model": "gemini-3-flash-preview"
94
+ // Optional
95
+ // "baseURL": "<proxy_url>"
95
96
 
96
97
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
97
- // "platform": "vertex-ai",
98
+ // "provider": "gemini-vertex-ai",
98
99
  // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
100
+ // "model": "gemini-3-flash-preview"
101
+ // Optional:
102
+ // "account": "<service_account_email>"
99
103
  },
100
104
 
101
- "searchWeb": {
102
- "tavilyApiKey": "FIXME"
105
+ "askURL": {
106
+ "provider": "gemini",
107
+ "apiKey": "FIXME"
108
+ // Optional
109
+ // "baseURL": "<proxy_url>"
110
+
111
+ // Or use Vertex AI (Requires gcloud CLI to get authentication token)
112
+ // "provider": "gemini-vertex-ai",
113
+ // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
114
+ // "model": "gemini-3-flash-preview"
115
+ // Optional:
116
+ // "account": "<service_account_email>"
103
117
  }
104
118
  },
105
119
 
@@ -173,9 +187,8 @@ The agent can use the following tools to assist with tasks:
173
187
  - **write_file**: Write a file.
174
188
  - **patch_file**: Patch a file.
175
189
  - **tmux_command**: Run a tmux command.
176
- - **fetch_web_page**: Fetch and extract web page content from a given URL, returning it as Markdown.
177
- - **ask_google**: Ask Google a question using natural language (requires Google API key or Vertex AI configuration).
178
- - **search_web**: Search the web for information (requires Tavily API key).
190
+ - **ask_web**: Use the web search to answer questions that need up-to-date information or supporting sources. (requires Google API key or Vertex AI configuration).
191
+ - **ask_url**: Use one or more provided URLs to answer a question. Include the URLs in your question. (requires Google API key or Vertex AI configuration).
179
192
  - **delegate_to_subagent**: Delegate a subtask to a subagent. The agent switches to a subagent role within the same conversation, focusing on the specified goal.
180
193
  - **report_as_subagent**: Report completion and return to the main agent. Used by subagents to communicate results and restore the main agent role. After reporting, the subagent's conversation history is removed from the context.
181
194
 
@@ -218,12 +231,6 @@ The agent loads configuration files in the following order. Settings in later fi
218
231
  "defaultAction": "deny",
219
232
  "maxApprovals": 100,
220
233
  "patterns": [
221
- // Prohibit direct access to external URLs (even GET requests can leak data via URL parameters)
222
- {
223
- "toolName": "fetch_web_page",
224
- "action": "deny",
225
- "reason": "Use ask_google instead"
226
- },
227
234
  {
228
235
  "toolName": { "$regex": "^(write_file|patch_file)$" },
229
236
  "action": "allow"
@@ -233,11 +240,10 @@ The agent loads configuration files in the following order. Settings in later fi
233
240
  "action": "allow"
234
241
  },
235
242
  {
236
- "toolName": "ask_google",
243
+ "toolName": { "$regex": "^(ask_web|ask_url)$" },
237
244
  "action": "allow"
238
245
  }
239
-
240
- // ⚠️ Never do this. fetch_web_page and mcp run outside the sandbox, so they can send anything externally.
246
+ // ⚠️ Never do this. mcp run outside the sandbox, so they can send anything externally.
241
247
  // {
242
248
  // "toolName": { "$regex": "." },
243
249
  // "action": "allow"
@@ -269,6 +275,7 @@ The agent loads configuration files in the following order. Settings in later fi
269
275
  ```js
270
276
  {
271
277
  "autoApproval": {
278
+ "defaultAction": "ask",
272
279
  // The maximum number of automatic approvals.
273
280
  "maxApprovals": 50,
274
281
  // Patterns are evaluated in order. First match wins.
@@ -290,8 +297,9 @@ The agent loads configuration files in the following order. Settings in later fi
290
297
  "input": { "command": "npm", "args": ["run", { "$regex": "^(check|test|lint|fix)$" }] },
291
298
  "action": "allow"
292
299
  },
300
+
293
301
  {
294
- "toolName": "ask_google",
302
+ "toolName": { "$regex": "^(ask_web|ask_url)$" },
295
303
  "action": "allow"
296
304
  },
297
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,21 +37,17 @@
37
37
  "dependencies": {
38
38
  "@aws-crypto/sha256-js": "^5.2.0",
39
39
  "@aws-sdk/credential-providers": "^3.1001.0",
40
- "@modelcontextprotocol/sdk": "^1.27.1",
41
- "@mozilla/readability": "^0.6.0",
40
+ "@cfworker/json-schema": "^4.1.1",
41
+ "@modelcontextprotocol/client": "^2.0.0-alpha.2",
42
42
  "@smithy/protocol-http": "^5.3.10",
43
43
  "@smithy/signature-v4": "^5.3.10",
44
44
  "diff": "^8.0.3",
45
- "js-yaml": "^4.1.1",
46
- "jsdom": "^28.1.0",
47
- "turndown": "^7.2.2"
45
+ "js-yaml": "^4.1.1"
48
46
  },
49
47
  "devDependencies": {
50
48
  "@biomejs/biome": "^2.4.5",
51
49
  "@types/js-yaml": "^4.0.9",
52
- "@types/jsdom": "^28.0.0",
53
50
  "@types/node": "^22.19.13",
54
- "@types/turndown": "^5.0.6",
55
51
  "typescript": "^5.9.3"
56
52
  }
57
53
  }
@@ -4,7 +4,6 @@
4
4
  * @import { PatchFileInput } from "./tools/patchFile"
5
5
  * @import { WriteFileInput } from "./tools/writeFile"
6
6
  * @import { TmuxCommandInput } from "./tools/tmuxCommand"
7
- * @import { TavilySearchInput } from "./tools/tavilySearch"
8
7
  * @import { DelegateToSubagentInput } from "./tools/delegateToSubagent"
9
8
  */
10
9
 
@@ -86,14 +85,6 @@ export function formatToolUse(toolUse) {
86
85
  ].join("\n");
87
86
  }
88
87
 
89
- if (toolName === "search_web") {
90
- /** @type {Partial<TavilySearchInput>} */
91
- const tavilySearchInput = input;
92
- return [`tool: ${toolName}`, `query: ${tavilySearchInput.query}`].join(
93
- "\n",
94
- );
95
- }
96
-
97
88
  if (toolName === "delegate_to_subagent") {
98
89
  /** @type {Partial<DelegateToSubagentInput>} */
99
90
  const delegateInput = input;
package/src/config.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ModelDefinition, PlatformConfig } from "./modelDefinition";
2
2
  import { ToolUsePattern } from "./tool";
3
+ import { AskURLToolOptions } from "./tools/askURL.mjs";
4
+ import { AskWebToolOptions } from "./tools/askWeb.mjs";
3
5
  import { ExecCommandSanboxConfig } from "./tools/execCommand";
4
6
 
5
7
  export type ClaudeCodePluginConfig = {
@@ -18,20 +20,8 @@ export type AppConfig = {
18
20
  };
19
21
  sandbox?: ExecCommandSanboxConfig;
20
22
  tools?: {
21
- /**
22
- * - Vertex AI: requires baseURL and account
23
- * - AI Studio: requires apiKey
24
- */
25
- askGoogle?: {
26
- platform?: "vertex-ai";
27
- baseURL?: string;
28
- account?: string;
29
- apiKey?: string;
30
- model?: string;
31
- };
32
- searchWeb?: {
33
- tavilyApiKey?: string;
34
- };
23
+ askWeb?: AskWebToolOptions;
24
+ askURL?: AskURLToolOptions;
35
25
  };
36
26
  mcpServers?: Record<string, MCPServerConfig>;
37
27
  notifyCmd?: string;
package/src/config.mjs CHANGED
@@ -76,14 +76,18 @@ export async function loadAppConfig(options = {}) {
76
76
  },
77
77
  sandbox: config.sandbox ?? merged.sandbox,
78
78
  tools: {
79
- searchWeb: {
80
- ...(merged.tools?.searchWeb ?? {}),
81
- ...(config.tools?.searchWeb ?? {}),
82
- },
83
- askGoogle: {
84
- ...(merged.tools?.askGoogle ?? {}),
85
- ...(config.tools?.askGoogle ?? {}),
86
- },
79
+ askWeb: config.tools?.askWeb
80
+ ? {
81
+ ...(merged.tools?.askWeb ?? {}),
82
+ ...config.tools.askWeb,
83
+ }
84
+ : merged.tools?.askWeb,
85
+ askURL: config.tools?.askURL
86
+ ? {
87
+ ...(merged.tools?.askURL ?? {}),
88
+ ...config.tools.askURL,
89
+ }
90
+ : merged.tools?.askWeb,
87
91
  },
88
92
  mcpServers: {
89
93
  ...(merged.mcpServers ?? {}),
package/src/main.mjs CHANGED
@@ -18,13 +18,12 @@ import {
18
18
  import { setupMCPServer } from "./mcp.mjs";
19
19
  import { createModelCaller } from "./modelCaller.mjs";
20
20
  import { createPrompt } from "./prompt.mjs";
21
- import { createAskGoogleTool } from "./tools/askGoogle.mjs";
21
+ import { createAskURLTool } from "./tools/askURL.mjs";
22
+ import { createAskWebTool } from "./tools/askWeb.mjs";
22
23
  import { createDelegateToSubagentTool } from "./tools/delegateToSubagent.mjs";
23
24
  import { createExecCommandTool } from "./tools/execCommand.mjs";
24
- import { fetchWebPageTool } from "./tools/fetchWebPage.mjs";
25
25
  import { patchFileTool } from "./tools/patchFile.mjs";
26
26
  import { createReportAsSubagentTool } from "./tools/reportAsSubagent.mjs";
27
- import { createTavilySearchTool } from "./tools/tavilySearch.mjs";
28
27
  import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
29
28
  import { writeFileTool } from "./tools/writeFile.mjs";
30
29
  import { createToolUseApprover } from "./toolUseApprover.mjs";
@@ -142,23 +141,16 @@ if (cliArgs.listModels) {
142
141
  writeFileTool,
143
142
  patchFileTool,
144
143
  createTmuxCommandTool({ sandbox: appConfig.sandbox }),
145
- fetchWebPageTool,
146
144
  createDelegateToSubagentTool(),
147
145
  createReportAsSubagentTool(),
148
146
  ];
149
147
 
150
- if (appConfig.tools?.searchWeb?.tavilyApiKey) {
151
- builtinTools.push(createTavilySearchTool(appConfig.tools.searchWeb));
148
+ if (appConfig.tools?.askWeb) {
149
+ builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
152
150
  }
153
151
 
154
- if (appConfig.tools?.askGoogle) {
155
- builtinTools.push(
156
- createAskGoogleTool({
157
- platform: appConfig.tools.askGoogle.platform,
158
- baseURL: appConfig.tools.askGoogle.baseURL,
159
- apiKey: appConfig.tools.askGoogle.apiKey,
160
- }),
161
- );
152
+ if (appConfig.tools?.askURL) {
153
+ builtinTools.push(createAskURLTool(appConfig.tools.askURL));
162
154
  }
163
155
 
164
156
  const toolUseApprover = createToolUseApprover({
package/src/mcp.mjs CHANGED
@@ -1,12 +1,11 @@
1
1
  /**
2
- * @import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- * @import { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js";
4
2
  * @import { StructuredToolResultContent, Tool, ToolImplementation } from "./tool";
5
3
  * @import { MCPServerConfig } from "./config";
6
4
  */
7
5
 
8
6
  import { mkdir, open } from "node:fs/promises";
9
7
  import path from "node:path";
8
+ import { Client } from "@modelcontextprotocol/client";
10
9
  import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
11
10
  import { writeTmpFile } from "./tmpfile.mjs";
12
11
  import { noThrow } from "./utils/noThrow.mjs";
@@ -52,7 +51,7 @@ export async function setupMCPServer(serverName, serverConfig) {
52
51
  /**
53
52
  * @typedef {Object} MCPClientOptions
54
53
  * @property {string} serverName - The name of the MCP server.
55
- * @property {StdioServerParameters} params - The transport to use for the client.
54
+ * @property {import("@modelcontextprotocol/client").StdioServerParameters} params - The transport to use for the client.
56
55
  */
57
56
 
58
57
  /**
@@ -60,10 +59,7 @@ export async function setupMCPServer(serverName, serverConfig) {
60
59
  * @returns {Promise<{client: Client; cleanup: () => void}>} - The MCP client and cleanup function.
61
60
  */
62
61
  async function startMCPServer(options) {
63
- const mcpClient = await import("@modelcontextprotocol/sdk/client/index.js");
64
- const mcpClientStdio = await import(
65
- "@modelcontextprotocol/sdk/client/stdio.js"
66
- );
62
+ const mcpClient = await import("@modelcontextprotocol/client");
67
63
 
68
64
  const client = new mcpClient.Client({
69
65
  name: "undefined",
@@ -83,7 +79,7 @@ async function startMCPServer(options) {
83
79
  const logPath = path.join(logDir, `mcp--${options.serverName}.stderr`);
84
80
  const stderrLogFile = await open(logPath, "a");
85
81
 
86
- const transport = new mcpClientStdio.StdioClientTransport({
82
+ const transport = new mcpClient.StdioClientTransport({
87
83
  ...restParams,
88
84
  env: env ? { ...defaultEnv, ...env } : undefined,
89
85
  stderr: stderrLogFile.fd,
@@ -0,0 +1,201 @@
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 {AskURLToolGeminiOptions | AskURLToolGeminiVertexAIOptions} AskURLToolOptions */
10
+
11
+ /**
12
+ * @typedef {Object} AskURLToolGeminiOptions
13
+ * @property {"gemini"} provider
14
+ * @property {string=} baseURL
15
+ * @property {string} apiKey
16
+ * @property {string} model
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} AskURLToolGeminiVertexAIOptions
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 {AskURLToolOptions} config
34
+ * @returns {Tool}
35
+ */
36
+ export function createAskURLTool(config) {
37
+ /**
38
+ * @param {AskWebInput} input
39
+ * @param {number} retryCount
40
+ * @returns {Promise<string | Error>}
41
+ */
42
+ async function askURL(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
+ url_context: {},
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 askURL(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_url",
180
+ description:
181
+ "Use one or more provided URLs to answer a question. Include the URLs in your question.",
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {
185
+ question: {
186
+ type: "string",
187
+ description:
188
+ "The question to ask, including one or more URLs to use as context.",
189
+ },
190
+ },
191
+ required: ["question"],
192
+ },
193
+ },
194
+
195
+ /**
196
+ * @param {AskWebInput} input
197
+ * @returns {Promise<string | Error>}
198
+ */
199
+ impl: async (input) => await noThrow(async () => askURL(input, 0)),
200
+ };
201
+ }
@@ -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
- }