wave-agent-sdk 0.13.5 → 0.14.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.
- package/dist/agent.d.ts +6 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +16 -2
- package/dist/managers/aiManager.d.ts +3 -0
- package/dist/managers/aiManager.d.ts.map +1 -1
- package/dist/managers/aiManager.js +93 -8
- package/dist/managers/messageManager.d.ts +15 -0
- package/dist/managers/messageManager.d.ts.map +1 -1
- package/dist/managers/messageManager.js +52 -2
- package/dist/managers/messageQueue.d.ts +1 -0
- package/dist/managers/messageQueue.d.ts.map +1 -1
- package/dist/managers/messageQueue.js +8 -0
- package/dist/managers/permissionManager.d.ts +4 -0
- package/dist/managers/permissionManager.d.ts.map +1 -1
- package/dist/managers/permissionManager.js +6 -0
- package/dist/managers/subagentManager.d.ts.map +1 -1
- package/dist/managers/subagentManager.js +23 -17
- package/dist/prompts/index.d.ts +2 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +50 -25
- package/dist/services/aiService.d.ts.map +1 -1
- package/dist/services/aiService.js +11 -1
- package/dist/tools/agentTool.d.ts.map +1 -1
- package/dist/tools/agentTool.js +14 -2
- package/dist/tools/bashTool.d.ts.map +1 -1
- package/dist/tools/bashTool.js +27 -5
- package/dist/tools/types.d.ts +1 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/webFetchTool.d.ts.map +1 -1
- package/dist/tools/webFetchTool.js +202 -78
- package/dist/types/messaging.d.ts +1 -0
- package/dist/types/messaging.d.ts.map +1 -1
- package/dist/utils/convertMessagesForAPI.js +1 -1
- package/dist/utils/groupMessagesByApiRound.d.ts +24 -0
- package/dist/utils/groupMessagesByApiRound.d.ts.map +1 -0
- package/dist/utils/groupMessagesByApiRound.js +97 -0
- package/dist/utils/messageOperations.d.ts +1 -0
- package/dist/utils/messageOperations.d.ts.map +1 -1
- package/dist/utils/microcompact.d.ts +7 -0
- package/dist/utils/microcompact.d.ts.map +1 -0
- package/dist/utils/microcompact.js +78 -0
- package/package.json +2 -1
- package/src/agent.ts +17 -2
- package/src/managers/aiManager.ts +117 -15
- package/src/managers/messageManager.ts +64 -2
- package/src/managers/messageQueue.ts +9 -0
- package/src/managers/permissionManager.ts +7 -0
- package/src/managers/subagentManager.ts +28 -24
- package/src/prompts/index.ts +51 -25
- package/src/services/aiService.ts +14 -1
- package/src/tools/agentTool.ts +14 -2
- package/src/tools/bashTool.ts +27 -5
- package/src/tools/types.ts +1 -0
- package/src/tools/webFetchTool.ts +276 -86
- package/src/types/messaging.ts +1 -0
- package/src/utils/convertMessagesForAPI.ts +1 -1
- package/src/utils/groupMessagesByApiRound.ts +120 -0
- package/src/utils/messageOperations.ts +1 -0
- package/src/utils/microcompact.ts +101 -0
package/src/tools/bashTool.ts
CHANGED
|
@@ -139,7 +139,10 @@ Use the gh command via the Bash tool for GitHub-related tasks including working
|
|
|
139
139
|
- Do not retry failing commands in a sleep loop — diagnose the root cause.
|
|
140
140
|
- If waiting for a background task you started with \`run_in_background\`, you will be notified when it completes — do not poll.
|
|
141
141
|
- If you must poll an external process, use a check command (e.g. \`gh run view\`) rather than sleeping first.
|
|
142
|
-
- If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user
|
|
142
|
+
- If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.
|
|
143
|
+
|
|
144
|
+
# CWD management
|
|
145
|
+
Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it. When you use \`cd\`, the shell working directory will be reset to the original working directory after the command completes.`,
|
|
143
146
|
execute: async (
|
|
144
147
|
args: Record<string, unknown>,
|
|
145
148
|
context: ToolContext,
|
|
@@ -221,9 +224,16 @@ Use the gh command via the Bash tool for GitHub-related tasks including working
|
|
|
221
224
|
const { id: taskId } = backgroundTaskManager.startShell(command, timeout);
|
|
222
225
|
const task = backgroundTaskManager.getTask(taskId);
|
|
223
226
|
const outputPath = task?.outputPath;
|
|
227
|
+
const backgroundMsg = [
|
|
228
|
+
`Command started in background with ID: ${taskId}.`,
|
|
229
|
+
`You will be notified automatically when it completes.`,
|
|
230
|
+
outputPath
|
|
231
|
+
? `output_file: ${outputPath}`
|
|
232
|
+
: `Use ${READ_TOOL_NAME} tool with task_id="${taskId}" to read the output.`,
|
|
233
|
+
].join("\n");
|
|
224
234
|
return {
|
|
225
235
|
success: true,
|
|
226
|
-
content:
|
|
236
|
+
content: backgroundMsg,
|
|
227
237
|
shortResult: `Background process ${taskId} started`,
|
|
228
238
|
};
|
|
229
239
|
}
|
|
@@ -446,9 +456,20 @@ Use the gh command via the Bash tool for GitHub-related tasks including working
|
|
|
446
456
|
}
|
|
447
457
|
}
|
|
448
458
|
|
|
449
|
-
// If CWD changed, call the onCwdChange callback
|
|
459
|
+
// If CWD changed, call the onCwdChange callback and add notification
|
|
460
|
+
let cwdChangedNotification = "";
|
|
450
461
|
if (newCwd && newCwd !== context.workdir && context.onCwdChange) {
|
|
451
|
-
|
|
462
|
+
const isInSafeZone =
|
|
463
|
+
context.permissionManager?.isPathInSafeZone?.(newCwd) ?? true;
|
|
464
|
+
|
|
465
|
+
if (isInSafeZone) {
|
|
466
|
+
context.onCwdChange(newCwd);
|
|
467
|
+
} else if (context.originalWorkdir) {
|
|
468
|
+
context.onCwdChange(context.originalWorkdir);
|
|
469
|
+
cwdChangedNotification = `Shell cwd was reset to ${context.originalWorkdir}\n`;
|
|
470
|
+
} else {
|
|
471
|
+
context.onCwdChange(newCwd);
|
|
472
|
+
}
|
|
452
473
|
}
|
|
453
474
|
|
|
454
475
|
const exitCode = code ?? 0;
|
|
@@ -457,7 +478,8 @@ Use the gh command via the Bash tool for GitHub-related tasks including working
|
|
|
457
478
|
|
|
458
479
|
// Handle large output by truncation and persistence if needed
|
|
459
480
|
const finalOutput =
|
|
460
|
-
|
|
481
|
+
cwdChangedNotification +
|
|
482
|
+
(combinedOutput || `Command executed with exit code: ${exitCode}`);
|
|
461
483
|
const content = processOutput(finalOutput);
|
|
462
484
|
|
|
463
485
|
const lines = combinedOutput.trim().split("\n");
|
package/src/tools/types.ts
CHANGED
|
@@ -58,6 +58,7 @@ export interface ToolContext {
|
|
|
58
58
|
abortSignal?: AbortSignal;
|
|
59
59
|
backgroundTaskManager?: import("../managers/backgroundTaskManager.js").BackgroundTaskManager;
|
|
60
60
|
workdir: string;
|
|
61
|
+
originalWorkdir?: string;
|
|
61
62
|
/** Permission mode for this tool execution */
|
|
62
63
|
permissionMode?: PermissionMode;
|
|
63
64
|
/** Custom permission callback */
|
|
@@ -1,35 +1,105 @@
|
|
|
1
1
|
import TurndownService from "turndown";
|
|
2
|
+
import { LRUCache } from "lru-cache";
|
|
2
3
|
import { WEB_FETCH_TOOL_NAME } from "../constants/tools.js";
|
|
3
4
|
import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
|
|
4
5
|
import { logger } from "../utils/globalLogger.js";
|
|
5
6
|
|
|
7
|
+
// --- Security Limits ---
|
|
8
|
+
const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024; // 10MB
|
|
9
|
+
const FETCH_TIMEOUT_MS = 60_000; // 60s
|
|
10
|
+
const MAX_REDIRECTS = 10;
|
|
11
|
+
const MAX_MARKDOWN_LENGTH = 100_000;
|
|
12
|
+
const USER_AGENT = "Wave-User (+https://github.com/netease-lcap/wave-agent)";
|
|
13
|
+
|
|
14
|
+
// --- Cache (LRU with 15min TTL, 50MB max) ---
|
|
6
15
|
const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
|
|
7
|
-
const
|
|
16
|
+
const CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
|
17
|
+
|
|
18
|
+
interface CacheEntry {
|
|
19
|
+
bytes: number;
|
|
20
|
+
code: number;
|
|
21
|
+
codeText: string;
|
|
22
|
+
content: string;
|
|
23
|
+
contentType: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cache = new LRUCache<string, CacheEntry>({
|
|
27
|
+
ttl: CACHE_TTL,
|
|
28
|
+
maxSize: CACHE_MAX_BYTES,
|
|
29
|
+
sizeCalculation: (entry) => entry.bytes,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// --- Helpers ---
|
|
8
33
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
34
|
+
function formatSize(bytes: number): string {
|
|
35
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
36
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
37
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type URLValidationResult = { valid: true } | { valid: false; error: string };
|
|
41
|
+
|
|
42
|
+
function validateURL(url: string): URLValidationResult {
|
|
43
|
+
if (url.length > 2000) {
|
|
44
|
+
return {
|
|
45
|
+
valid: false,
|
|
46
|
+
error: "URL exceeds maximum length of 2000 characters",
|
|
47
|
+
};
|
|
13
48
|
}
|
|
14
|
-
|
|
15
|
-
|
|
49
|
+
|
|
50
|
+
let parsed: URL;
|
|
51
|
+
try {
|
|
52
|
+
parsed = new URL(url);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
error: `Invalid URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
57
|
+
};
|
|
16
58
|
}
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
59
|
|
|
20
|
-
|
|
21
|
-
|
|
60
|
+
if (parsed.username || parsed.password) {
|
|
61
|
+
return { valid: false, error: "URL must not contain username or password" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hostParts = parsed.hostname.split(".");
|
|
65
|
+
if (hostParts.length < 2) {
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
error: "URL hostname must have at least two parts (e.g., example.com)",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { valid: true };
|
|
22
73
|
}
|
|
23
74
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
75
|
+
function isPermittedRedirect(
|
|
76
|
+
originalUrl: string,
|
|
77
|
+
redirectUrl: string,
|
|
78
|
+
): boolean {
|
|
79
|
+
try {
|
|
80
|
+
const original = new URL(originalUrl);
|
|
81
|
+
const redirect = new URL(redirectUrl);
|
|
82
|
+
const origHost = original.host;
|
|
83
|
+
const redirHost = redirect.host;
|
|
84
|
+
|
|
85
|
+
// Same host
|
|
86
|
+
if (origHost === redirHost) return true;
|
|
87
|
+
|
|
88
|
+
// www. variation (e.g., example.com <-> www.example.com)
|
|
89
|
+
const bareOrig = origHost.replace(/^www\./, "");
|
|
90
|
+
const bareRedir = redirHost.replace(/^www\./, "");
|
|
91
|
+
if (bareOrig === bareRedir) return true;
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
31
96
|
}
|
|
32
|
-
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const GITHUB_URL_ERROR =
|
|
100
|
+
"For GitHub URLs, please use the 'gh' CLI via the Bash tool instead (e.g., 'gh pr view', 'gh issue view', 'gh api').";
|
|
101
|
+
|
|
102
|
+
// --- Tool ---
|
|
33
103
|
|
|
34
104
|
export const webFetchTool: ToolPlugin = {
|
|
35
105
|
name: WEB_FETCH_TOOL_NAME,
|
|
@@ -50,10 +120,11 @@ Usage notes:
|
|
|
50
120
|
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
|
|
51
121
|
- The URL must be a fully-formed valid URL
|
|
52
122
|
- HTTP URLs will be automatically upgraded to HTTPS
|
|
123
|
+
- Content exceeding ${formatSize(MAX_MARKDOWN_LENGTH)} will be truncated
|
|
53
124
|
- The prompt should describe what information you want to extract from the page
|
|
54
125
|
- This tool is read-only and does not modify any files
|
|
55
126
|
- Results may be summarized if the content is very large
|
|
56
|
-
- Includes a
|
|
127
|
+
- Includes an LRU cache with a 15-minute TTL for faster responses when repeatedly accessing the same URL
|
|
57
128
|
- When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.
|
|
58
129
|
- For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).`,
|
|
59
130
|
parameters: {
|
|
@@ -94,92 +165,110 @@ Usage notes:
|
|
|
94
165
|
url = "https://" + url.substring(7);
|
|
95
166
|
}
|
|
96
167
|
|
|
168
|
+
// Validate URL
|
|
169
|
+
const validation = validateURL(url);
|
|
170
|
+
if (!validation.valid) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
content: "",
|
|
174
|
+
error: validation.error,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
97
178
|
// Check for GitHub URLs
|
|
98
179
|
if (url.includes("github.com")) {
|
|
99
180
|
return {
|
|
100
181
|
success: false,
|
|
101
182
|
content: "",
|
|
102
|
-
error:
|
|
103
|
-
"For GitHub URLs, please use the 'gh' CLI via the Bash tool instead (e.g., 'gh pr view', 'gh issue view', 'gh api').",
|
|
183
|
+
error: GITHUB_URL_ERROR,
|
|
104
184
|
};
|
|
105
185
|
}
|
|
106
186
|
|
|
107
187
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const html = await response.text();
|
|
151
|
-
const turndownService = new TurndownService();
|
|
152
|
-
markdown = turndownService.turndown(html);
|
|
153
|
-
setToCache(url, markdown);
|
|
188
|
+
const cached = cache.get(url);
|
|
189
|
+
if (cached) {
|
|
190
|
+
const markdown = cached.content;
|
|
191
|
+
return processWithAI(
|
|
192
|
+
url,
|
|
193
|
+
prompt,
|
|
194
|
+
markdown,
|
|
195
|
+
cached.code,
|
|
196
|
+
cached.codeText,
|
|
197
|
+
context,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Fetch with redirect following
|
|
202
|
+
const result = await fetchWithRedirects(url, context.abortSignal);
|
|
203
|
+
|
|
204
|
+
if (result.kind === "redirect") {
|
|
205
|
+
return {
|
|
206
|
+
success: true,
|
|
207
|
+
content: `REDIRECT_TO: ${result.redirectUrl}\nThe URL redirected to a different host. Please make a new WebFetch request with this redirect URL if you wish to continue.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (result.kind === "error") {
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
content: "",
|
|
215
|
+
error: result.error,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { response, finalUrl } = result;
|
|
220
|
+
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
content: "",
|
|
225
|
+
error: `Failed to fetch URL: ${response.status} ${response.statusText}`,
|
|
226
|
+
};
|
|
154
227
|
}
|
|
155
228
|
|
|
156
|
-
|
|
157
|
-
|
|
229
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
230
|
+
const contentLength = contentLengthHeader
|
|
231
|
+
? parseInt(contentLengthHeader, 10)
|
|
232
|
+
: null;
|
|
233
|
+
if (contentLength !== null && contentLength > MAX_HTTP_CONTENT_LENGTH) {
|
|
158
234
|
return {
|
|
159
235
|
success: false,
|
|
160
|
-
content:
|
|
161
|
-
error:
|
|
162
|
-
"AI Manager or AI Service not available for processing content",
|
|
236
|
+
content: "",
|
|
237
|
+
error: `Content too large: ${formatSize(contentLength)} exceeds limit of ${formatSize(MAX_HTTP_CONTENT_LENGTH)}`,
|
|
163
238
|
};
|
|
164
239
|
}
|
|
165
240
|
|
|
166
|
-
const
|
|
167
|
-
const
|
|
241
|
+
const html = await response.text();
|
|
242
|
+
const turndownService = new TurndownService();
|
|
243
|
+
let markdown = turndownService.turndown(html);
|
|
244
|
+
|
|
245
|
+
const markdownBytes = new TextEncoder().encode(markdown).length;
|
|
246
|
+
|
|
247
|
+
// Truncate if too large
|
|
248
|
+
if (markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
249
|
+
markdown =
|
|
250
|
+
markdown.substring(0, MAX_MARKDOWN_LENGTH) +
|
|
251
|
+
`[Content truncated at ${MAX_MARKDOWN_LENGTH} characters due to length limit.]`;
|
|
252
|
+
}
|
|
168
253
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
254
|
+
// Store in LRU cache
|
|
255
|
+
cache.set(finalUrl, {
|
|
256
|
+
bytes: markdownBytes,
|
|
257
|
+
code: response.status,
|
|
258
|
+
codeText: response.statusText,
|
|
172
259
|
content: markdown,
|
|
173
|
-
|
|
174
|
-
model: fastModel,
|
|
175
|
-
abortSignal: context.abortSignal,
|
|
260
|
+
contentType: response.headers.get("content-type") || "",
|
|
176
261
|
});
|
|
177
262
|
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
263
|
+
return processWithAI(
|
|
264
|
+
finalUrl,
|
|
265
|
+
prompt,
|
|
266
|
+
markdown,
|
|
267
|
+
response.status,
|
|
268
|
+
response.statusText,
|
|
269
|
+
context,
|
|
270
|
+
markdownBytes,
|
|
271
|
+
);
|
|
183
272
|
} catch (error) {
|
|
184
273
|
logger.error(`WebFetch failed for ${url}:`, error);
|
|
185
274
|
return {
|
|
@@ -193,3 +282,104 @@ Usage notes:
|
|
|
193
282
|
return `Fetch ${params.url}`;
|
|
194
283
|
},
|
|
195
284
|
};
|
|
285
|
+
|
|
286
|
+
// --- Fetch with redirect following ---
|
|
287
|
+
|
|
288
|
+
async function fetchWithRedirects(
|
|
289
|
+
initialUrl: string,
|
|
290
|
+
abortSignal?: AbortSignal,
|
|
291
|
+
redirectCount = 0,
|
|
292
|
+
): Promise<
|
|
293
|
+
| { kind: "response"; response: Response; finalUrl: string }
|
|
294
|
+
| { kind: "redirect"; redirectUrl: string }
|
|
295
|
+
| { kind: "error"; error: string }
|
|
296
|
+
> {
|
|
297
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
298
|
+
return {
|
|
299
|
+
kind: "error",
|
|
300
|
+
error: `Too many redirects (max ${MAX_REDIRECTS})`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
306
|
+
|
|
307
|
+
// Forward the context's abort signal if provided
|
|
308
|
+
if (abortSignal) {
|
|
309
|
+
abortSignal.addEventListener("abort", () => controller.abort(), {
|
|
310
|
+
once: true,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let response: Response;
|
|
315
|
+
try {
|
|
316
|
+
response = await fetch(initialUrl, {
|
|
317
|
+
redirect: "manual",
|
|
318
|
+
signal: controller.signal,
|
|
319
|
+
headers: {
|
|
320
|
+
"User-Agent": USER_AGENT,
|
|
321
|
+
Accept: "text/markdown, text/html, */*",
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
} finally {
|
|
325
|
+
clearTimeout(timeoutId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (response.status >= 300 && response.status < 400) {
|
|
329
|
+
const location = response.headers.get("location");
|
|
330
|
+
if (location) {
|
|
331
|
+
const redirectUrl = new URL(location, initialUrl).toString();
|
|
332
|
+
|
|
333
|
+
if (!isPermittedRedirect(initialUrl, redirectUrl)) {
|
|
334
|
+
return { kind: "redirect", redirectUrl };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Follow permitted redirect recursively
|
|
338
|
+
return fetchWithRedirects(redirectUrl, abortSignal, redirectCount + 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { kind: "response", response, finalUrl: initialUrl };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- AI Processing ---
|
|
346
|
+
|
|
347
|
+
async function processWithAI(
|
|
348
|
+
url: string,
|
|
349
|
+
prompt: string,
|
|
350
|
+
markdown: string,
|
|
351
|
+
statusCode: number,
|
|
352
|
+
statusText: string,
|
|
353
|
+
context: ToolContext,
|
|
354
|
+
contentSize?: number,
|
|
355
|
+
): Promise<ToolResult> {
|
|
356
|
+
if (!context.aiManager || !context.aiService) {
|
|
357
|
+
return {
|
|
358
|
+
success: false,
|
|
359
|
+
content: markdown,
|
|
360
|
+
error: "AI Manager or AI Service not available for processing content",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const modelConfig = context.aiManager.getModelConfig();
|
|
365
|
+
const fastModel = modelConfig.fastModel;
|
|
366
|
+
|
|
367
|
+
const aiResponse = await context.aiService.processWebContent({
|
|
368
|
+
gatewayConfig: context.aiManager.getGatewayConfig(),
|
|
369
|
+
modelConfig: modelConfig,
|
|
370
|
+
content: markdown,
|
|
371
|
+
prompt: prompt,
|
|
372
|
+
model: fastModel,
|
|
373
|
+
abortSignal: context.abortSignal,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const sizeStr =
|
|
377
|
+
contentSize !== undefined ? formatSize(contentSize) : "unknown size";
|
|
378
|
+
const statusStr = `${statusCode} ${statusText}`.trim();
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
success: true,
|
|
382
|
+
content: aiResponse.content || "",
|
|
383
|
+
shortResult: `Received ${sizeStr} (${statusStr}) from ${url}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
package/src/types/messaging.ts
CHANGED
|
@@ -69,6 +69,7 @@ export interface ToolBlock {
|
|
|
69
69
|
compactParams?: string; // Compact parameter display
|
|
70
70
|
parametersChunk?: string; // Incremental parameter updates for streaming
|
|
71
71
|
isManuallyBackgrounded?: boolean; // Whether the tool was manually backgrounded by the user
|
|
72
|
+
timestamp?: number; // Unix ms, set when tool result is finalized (stage="end")
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
export interface ImageBlock {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Message } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
export interface ApiRound {
|
|
4
|
+
messages: Message[];
|
|
5
|
+
estimatedTokens: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Groups messages into "API rounds" — each round corresponds to one API
|
|
10
|
+
* call-response cycle. This is critical because in agentic sessions with a
|
|
11
|
+
* single user prompt, Wave creates a new Message per API round (each recursive
|
|
12
|
+
* sendAIMessage call creates a new assistant message).
|
|
13
|
+
*
|
|
14
|
+
* Boundaries:
|
|
15
|
+
* - A new `role: "user"` message starts a new round.
|
|
16
|
+
* - A new `role: "assistant"` message with a different `id` starts a new round.
|
|
17
|
+
* - A message with a `compress` block is pushed as its own round and starts a
|
|
18
|
+
* new round after it.
|
|
19
|
+
*/
|
|
20
|
+
export function groupMessagesByApiRound(messages: Message[]): ApiRound[] {
|
|
21
|
+
const rounds: ApiRound[] = [];
|
|
22
|
+
let currentRound: Message[] = [];
|
|
23
|
+
let lastAssistantId: string | undefined;
|
|
24
|
+
|
|
25
|
+
for (const msg of messages) {
|
|
26
|
+
let startNewRound = false;
|
|
27
|
+
|
|
28
|
+
if (msg.role === "user") {
|
|
29
|
+
startNewRound = true;
|
|
30
|
+
} else if (msg.role === "assistant") {
|
|
31
|
+
// Compress block is always its own round
|
|
32
|
+
const hasCompress = msg.blocks.some((b) => b.type === "compress");
|
|
33
|
+
if (hasCompress) {
|
|
34
|
+
startNewRound = true;
|
|
35
|
+
} else if (msg.id !== lastAssistantId) {
|
|
36
|
+
// New assistant id starts a new round.
|
|
37
|
+
// Exception: if the current round is [user] (first assistant after a
|
|
38
|
+
// user prompt in a normal conversation), keep them together as one
|
|
39
|
+
// round. But if we already have assistant(s) in this round (agentic
|
|
40
|
+
// tool loop), the new id starts a new round.
|
|
41
|
+
const roundHasOtherAssistant = currentRound.some(
|
|
42
|
+
(m) => m.role === "assistant" && m.id !== msg.id,
|
|
43
|
+
);
|
|
44
|
+
if (roundHasOtherAssistant) {
|
|
45
|
+
startNewRound = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
lastAssistantId = msg.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (startNewRound && currentRound.length > 0) {
|
|
52
|
+
rounds.push({
|
|
53
|
+
messages: currentRound,
|
|
54
|
+
estimatedTokens: estimateTokens(currentRound),
|
|
55
|
+
});
|
|
56
|
+
currentRound = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
currentRound.push(msg);
|
|
60
|
+
|
|
61
|
+
// After pushing a compress message as its own round, flush immediately
|
|
62
|
+
if (
|
|
63
|
+
msg.role === "assistant" &&
|
|
64
|
+
msg.blocks.some((b) => b.type === "compress")
|
|
65
|
+
) {
|
|
66
|
+
rounds.push({
|
|
67
|
+
messages: currentRound,
|
|
68
|
+
estimatedTokens: estimateTokens(currentRound),
|
|
69
|
+
});
|
|
70
|
+
currentRound = [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (currentRound.length > 0) {
|
|
75
|
+
rounds.push({
|
|
76
|
+
messages: currentRound,
|
|
77
|
+
estimatedTokens: estimateTokens(currentRound),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return rounds;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the last `roundCount` complete API rounds as a flat message array.
|
|
86
|
+
* Never splits a tool_use/tool_result pair. If fewer rounds exist, returns all.
|
|
87
|
+
*/
|
|
88
|
+
export function getLastApiRounds(
|
|
89
|
+
messages: Message[],
|
|
90
|
+
roundCount: number,
|
|
91
|
+
): Message[] {
|
|
92
|
+
const rounds = groupMessagesByApiRound(messages);
|
|
93
|
+
const lastRounds = rounds.slice(-roundCount);
|
|
94
|
+
return lastRounds.flatMap((r) => r.messages);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Roughly estimate token count from character count (~4 chars per token).
|
|
99
|
+
*/
|
|
100
|
+
function estimateTokens(messages: Message[]): number {
|
|
101
|
+
let chars = 0;
|
|
102
|
+
for (const msg of messages) {
|
|
103
|
+
for (const block of msg.blocks) {
|
|
104
|
+
if ("content" in block && typeof block.content === "string") {
|
|
105
|
+
chars += block.content.length;
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
block.type === "tool" &&
|
|
109
|
+
block.parameters &&
|
|
110
|
+
typeof block.parameters === "string"
|
|
111
|
+
) {
|
|
112
|
+
chars += block.parameters.length;
|
|
113
|
+
}
|
|
114
|
+
if (block.type === "tool" && block.result) {
|
|
115
|
+
chars += block.result.length;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Math.ceil(chars / 4);
|
|
120
|
+
}
|
|
@@ -49,6 +49,7 @@ export interface UpdateToolBlockParams {
|
|
|
49
49
|
compactParams?: string;
|
|
50
50
|
parametersChunk?: string; // Incremental parameter updates for streaming
|
|
51
51
|
isManuallyBackgrounded?: boolean;
|
|
52
|
+
timestamp?: number;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// Agent specific interfaces (without messages parameter)
|