@myagentroam/node 0.11.60 → 0.11.62
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/connector.js +11 -2
- package/dist/hosting/hosted-conversation-node-service.js +2 -0
- package/dist/runner/mar-agent/agent-network-extension.js +7 -0
- package/dist/runner/mar-agent/computer-use.js +2 -2
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +12 -1
- package/dist/runner/mar-agent/managed-run-controller.js +4 -1
- package/dist/runner/mar-agent/web-tools-extensions.js +11 -5
- package/dist/runner/mar-agent-runner.js +13 -2
- package/dist/service/web-fetch-contracts.js +32 -7
- package/dist/service/web-fetch-service.js +266 -126
- package/dist/service/web-markdown.js +16 -0
- package/dist/service/web-snapshots.js +188 -0
- package/dist/service/web-tool-service.js +132 -15
- package/package.json +7 -4
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AgentError, countModelTokens } from '@myagentroam/agent';
|
|
3
|
+
const DEFAULT_BUDGET = 2_000;
|
|
4
|
+
const SNAPSHOT_BUDGET = 32 * 1024 * 1024;
|
|
5
|
+
/** Process-local documents; callers supply the trusted storage/session scope. */
|
|
6
|
+
export class WebSnapshots {
|
|
7
|
+
maxBytes;
|
|
8
|
+
#entries = new Map();
|
|
9
|
+
#bytes = 0;
|
|
10
|
+
constructor(maxBytes = SNAPSHOT_BUDGET) {
|
|
11
|
+
this.maxBytes = maxBytes;
|
|
12
|
+
}
|
|
13
|
+
add(scope, document) {
|
|
14
|
+
const ref = randomUUID();
|
|
15
|
+
const size = Buffer.byteLength(JSON.stringify({ ...document, scope, ref })) + 128;
|
|
16
|
+
if (size > this.maxBytes)
|
|
17
|
+
throw unavailable();
|
|
18
|
+
while (this.#bytes + size > this.maxBytes) {
|
|
19
|
+
const oldest = this.#entries.keys().next().value;
|
|
20
|
+
if (oldest === undefined)
|
|
21
|
+
break;
|
|
22
|
+
this.#delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
this.#entries.set(ref, { ...document, scope, size });
|
|
25
|
+
this.#bytes += size;
|
|
26
|
+
return ref;
|
|
27
|
+
}
|
|
28
|
+
clearScope(scope) {
|
|
29
|
+
for (const [ref, entry] of this.#entries)
|
|
30
|
+
if (entry.scope === scope)
|
|
31
|
+
this.#delete(ref);
|
|
32
|
+
}
|
|
33
|
+
clearHostedStorage(storage) {
|
|
34
|
+
for (const [ref, entry] of this.#entries) {
|
|
35
|
+
try {
|
|
36
|
+
const scope = JSON.parse(entry.scope);
|
|
37
|
+
if (Array.isArray(scope) && scope[0] === 'hosted' && scope[1] === storage)
|
|
38
|
+
this.#delete(ref);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* Ignore unrelated scopes. */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
clear() {
|
|
46
|
+
this.#entries.clear();
|
|
47
|
+
this.#bytes = 0;
|
|
48
|
+
}
|
|
49
|
+
open(scope, ref, options) {
|
|
50
|
+
const entry = this.#get(scope, ref);
|
|
51
|
+
const lines = entry.content.split('\n');
|
|
52
|
+
const budget = options.maxTokens ?? DEFAULT_BUDGET;
|
|
53
|
+
let line = options.line ?? 0;
|
|
54
|
+
let column = 0;
|
|
55
|
+
if (options.cursor !== undefined) {
|
|
56
|
+
const match = /^(\d+):(\d+)$/u.exec(options.cursor);
|
|
57
|
+
if (!match)
|
|
58
|
+
throw invalidCursor();
|
|
59
|
+
line = Number(match[1]);
|
|
60
|
+
column = Number(match[2]);
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isSafeInteger(line) ||
|
|
63
|
+
!Number.isSafeInteger(column) ||
|
|
64
|
+
line < 0 ||
|
|
65
|
+
line >= lines.length ||
|
|
66
|
+
column < 0 ||
|
|
67
|
+
column > lines[line].length ||
|
|
68
|
+
(column > 0 && column < lines[line].length && /[\uDC00-\uDFFF]/u.test(lines[line][column])))
|
|
69
|
+
throw invalidCursor();
|
|
70
|
+
const startLine = line;
|
|
71
|
+
const header = `Ref: ${ref}\nTitle: ${short(entry.title ?? '', 160)}\nURL: ${short(entry.url, 240)}\n`;
|
|
72
|
+
let body = '';
|
|
73
|
+
let lastLine = line;
|
|
74
|
+
for (let seen = 0; line < lines.length && seen < 200; seen++) {
|
|
75
|
+
const remaining = lines[line].slice(column);
|
|
76
|
+
const points = Array.from(remaining);
|
|
77
|
+
const prefix = `${line}: `;
|
|
78
|
+
if (countModelTokens(header + body + prefix + '\n' + 'x'.repeat(180)) > budget)
|
|
79
|
+
break;
|
|
80
|
+
let low = 0;
|
|
81
|
+
let high = points.length;
|
|
82
|
+
while (low < high) {
|
|
83
|
+
const mid = Math.ceil((low + high) / 2);
|
|
84
|
+
if (countModelTokens(header + body + prefix + points.slice(0, mid).join('') + '\n' + 'x'.repeat(180)) <= budget)
|
|
85
|
+
low = mid;
|
|
86
|
+
else
|
|
87
|
+
high = mid - 1;
|
|
88
|
+
}
|
|
89
|
+
if (low === 0 && points.length > 0)
|
|
90
|
+
break;
|
|
91
|
+
const part = points.slice(0, low).join('');
|
|
92
|
+
body += prefix + part + '\n';
|
|
93
|
+
lastLine = line;
|
|
94
|
+
column += part.length;
|
|
95
|
+
if (column < lines[line].length)
|
|
96
|
+
break;
|
|
97
|
+
line++;
|
|
98
|
+
column = 0;
|
|
99
|
+
}
|
|
100
|
+
const hasMore = line < lines.length;
|
|
101
|
+
const nextCursor = hasMore ? `${line}:${column}` : undefined;
|
|
102
|
+
const footer = `Lines: ${startLine}-${lastLine} / ${lines.length}\nsourceTruncated: ${entry.sourceTruncated}\nhasMore: ${hasMore}${nextCursor ? `\nnextCursor: ${nextCursor}` : ''}`;
|
|
103
|
+
return {
|
|
104
|
+
content: header + body + footer,
|
|
105
|
+
data: {
|
|
106
|
+
ref,
|
|
107
|
+
url: entry.url,
|
|
108
|
+
title: entry.title,
|
|
109
|
+
status: entry.status,
|
|
110
|
+
contentType: entry.contentType,
|
|
111
|
+
format: /^text\/html\b/iu.test(entry.contentType) ? 'markdown' : 'text',
|
|
112
|
+
totalLines: lines.length,
|
|
113
|
+
startLine,
|
|
114
|
+
endLine: lastLine,
|
|
115
|
+
hasMore,
|
|
116
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
117
|
+
sourceTruncated: entry.sourceTruncated
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
find(scope, ref, pattern, maxTokens = DEFAULT_BUDGET) {
|
|
122
|
+
const entry = this.#get(scope, ref);
|
|
123
|
+
const needle = pattern.toLowerCase();
|
|
124
|
+
if (!needle)
|
|
125
|
+
throw new AgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Find pattern must not be empty.');
|
|
126
|
+
const matchLines = [];
|
|
127
|
+
const lines = entry.content.split('\n');
|
|
128
|
+
for (let i = 0; i < lines.length; i++)
|
|
129
|
+
if (lines[i].toLowerCase().includes(needle))
|
|
130
|
+
matchLines.push(i);
|
|
131
|
+
if (!matchLines.length)
|
|
132
|
+
return {
|
|
133
|
+
content: `Ref: ${ref}\nNo matches in the retained snapshot.\nsourceTruncated: ${entry.sourceTruncated}`,
|
|
134
|
+
data: { ref, matchLines: [], sourceTruncated: entry.sourceTruncated, partial: false }
|
|
135
|
+
};
|
|
136
|
+
const first = matchLines[0];
|
|
137
|
+
const matchOffset = lines[first].toLowerCase().indexOf(needle);
|
|
138
|
+
let originalOffset = 0;
|
|
139
|
+
let foldedOffset = 0;
|
|
140
|
+
for (const point of lines[first]) {
|
|
141
|
+
const next = foldedOffset + point.toLowerCase().length;
|
|
142
|
+
if (next > matchOffset)
|
|
143
|
+
break;
|
|
144
|
+
foldedOffset = next;
|
|
145
|
+
originalOffset += point.length;
|
|
146
|
+
}
|
|
147
|
+
let column = Math.max(0, originalOffset - 80);
|
|
148
|
+
if (column > 0 && /[\uDC00-\uDFFF]/u.test(lines[first][column]))
|
|
149
|
+
column--;
|
|
150
|
+
const shown = matchLines.slice(0, 40);
|
|
151
|
+
const partial = shown.length < matchLines.length;
|
|
152
|
+
const prefix = `Matches${partial ? ' (partial)' : ''}: ${shown.join(', ')}\n`;
|
|
153
|
+
const window = this.open(scope, ref, {
|
|
154
|
+
cursor: `${first}:${column}`,
|
|
155
|
+
maxTokens: maxTokens - countModelTokens(prefix) - 1
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
content: prefix + window.content,
|
|
159
|
+
data: { ...window.data, matchLines: shown, partial }
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
#get(scope, ref) {
|
|
163
|
+
const entry = this.#entries.get(ref);
|
|
164
|
+
if (!entry || entry.scope !== scope)
|
|
165
|
+
throw unavailable();
|
|
166
|
+
this.#entries.delete(ref);
|
|
167
|
+
this.#entries.set(ref, entry);
|
|
168
|
+
return entry;
|
|
169
|
+
}
|
|
170
|
+
#delete(ref) {
|
|
171
|
+
const entry = this.#entries.get(ref);
|
|
172
|
+
if (entry)
|
|
173
|
+
this.#bytes -= entry.size;
|
|
174
|
+
this.#entries.delete(ref);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function short(value, maxBytes) {
|
|
178
|
+
if (Buffer.byteLength(value) <= maxBytes)
|
|
179
|
+
return value;
|
|
180
|
+
const bytes = Buffer.from(value);
|
|
181
|
+
return `${new TextDecoder().decode(bytes.subarray(0, maxBytes - 3), { stream: true })}…`;
|
|
182
|
+
}
|
|
183
|
+
function unavailable() {
|
|
184
|
+
return new AgentError('MAR_AGENT_WEB_SNAPSHOT_UNAVAILABLE', 'Page reference is unavailable. Open the URL again.');
|
|
185
|
+
}
|
|
186
|
+
function invalidCursor() {
|
|
187
|
+
return new AgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Page position is invalid.');
|
|
188
|
+
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { AgentError } from '@myagentroam/agent';
|
|
1
|
+
import { AgentError, countModelTokens } from '@myagentroam/agent';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { readWebSearchError } from './web-search-error.js';
|
|
3
4
|
import { WebFetchService } from './web-fetch-service.js';
|
|
4
|
-
import {
|
|
5
|
+
import { browserFetchResultSchema } from './web-fetch-contracts.js';
|
|
6
|
+
import { WebSnapshots } from './web-snapshots.js';
|
|
5
7
|
/** Owns Node web reads and the authenticated Server search adapter. */
|
|
6
8
|
export class WebToolService {
|
|
7
9
|
#config;
|
|
8
10
|
#request;
|
|
9
11
|
#fetcher;
|
|
12
|
+
#snapshots = new WebSnapshots();
|
|
13
|
+
#pending = new Map();
|
|
14
|
+
#closed = false;
|
|
10
15
|
constructor(config, request = fetch) {
|
|
11
16
|
this.#config = config;
|
|
12
17
|
this.#request = request;
|
|
@@ -15,19 +20,80 @@ export class WebToolService {
|
|
|
15
20
|
webSearch(input, signal) {
|
|
16
21
|
return this.#execute(input, signal);
|
|
17
22
|
}
|
|
18
|
-
webFetch = async (input, signal, allowUntrustedTls) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
webFetch = async (input, signal, scope, allowUntrustedTls) => {
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
if (this.#closed)
|
|
26
|
+
throw new AgentError('MAR_AGENT_WEB_FETCH_CANCELLED', 'Web service is closed.');
|
|
27
|
+
if (input.action === 'find')
|
|
28
|
+
return this.#snapshots.find(scope, input.ref, input.pattern, input.maxTokens);
|
|
29
|
+
if ('ref' in input)
|
|
30
|
+
return this.#snapshots.open(scope, input.ref, {
|
|
31
|
+
maxTokens: input.maxTokens,
|
|
32
|
+
...(input.line === undefined ? {} : { line: input.line }),
|
|
33
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor })
|
|
34
|
+
});
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const pending = this.#pending.get(scope) ?? new Set();
|
|
37
|
+
this.#pending.set(scope, pending);
|
|
38
|
+
pending.add(controller);
|
|
39
|
+
const combined = AbortSignal.any([signal, controller.signal]);
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = await this.#fetcher.fetch({ url: input.url, mode: 'browser' }, combined, {
|
|
43
|
+
...(allowUntrustedTls === undefined ? {} : { allowUntrustedTls })
|
|
44
|
+
});
|
|
45
|
+
combined.throwIfAborted();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
pending.delete(controller);
|
|
49
|
+
if (pending.size === 0)
|
|
50
|
+
this.#pending.delete(scope);
|
|
51
|
+
}
|
|
52
|
+
const parsed = browserFetchResultSchema.safeParse(result);
|
|
23
53
|
if (!parsed.success)
|
|
24
54
|
throw new AgentError('MAR_AGENT_WEB_FETCH_FAILED', 'Web fetch returned an invalid result.');
|
|
25
|
-
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
if (this.#closed)
|
|
57
|
+
throw new AgentError('MAR_AGENT_WEB_FETCH_CANCELLED', 'Web service is closed.');
|
|
58
|
+
const { content, data, truncated } = parsed.data;
|
|
59
|
+
const ref = this.#snapshots.add(scope, {
|
|
60
|
+
content,
|
|
61
|
+
url: data.url,
|
|
62
|
+
status: data.status,
|
|
63
|
+
contentType: data.contentType,
|
|
64
|
+
...(data.title === undefined ? {} : { title: data.title }),
|
|
65
|
+
sourceTruncated: truncated
|
|
66
|
+
});
|
|
67
|
+
return this.#snapshots.open(scope, ref, { maxTokens: input.maxTokens });
|
|
26
68
|
};
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
clearSession(scope) {
|
|
70
|
+
for (const controller of this.#pending.get(scope) ?? [])
|
|
71
|
+
controller.abort();
|
|
72
|
+
this.#snapshots.clearScope(scope);
|
|
73
|
+
}
|
|
74
|
+
clearHostedStorage(storage) {
|
|
75
|
+
for (const [scope, controllers] of this.#pending) {
|
|
76
|
+
try {
|
|
77
|
+
const parts = JSON.parse(scope);
|
|
78
|
+
if (Array.isArray(parts) && parts[0] === 'hosted' && parts[1] === storage)
|
|
79
|
+
for (const controller of controllers)
|
|
80
|
+
controller.abort();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* Ignore unrelated scopes. */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.#snapshots.clearHostedStorage(storage);
|
|
87
|
+
}
|
|
88
|
+
async close() {
|
|
89
|
+
this.#closed = true;
|
|
90
|
+
await this.#fetcher.close();
|
|
91
|
+
this.#snapshots.clear();
|
|
29
92
|
}
|
|
30
93
|
async #execute(input, signal) {
|
|
94
|
+
signal.throwIfAborted();
|
|
95
|
+
if (this.#closed)
|
|
96
|
+
throw new AgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web service is closed.');
|
|
31
97
|
const config = this.#config();
|
|
32
98
|
if (config?.nodeId === undefined || config.credential === undefined)
|
|
33
99
|
throw new Error('NODE_UNAUTHENTICATED');
|
|
@@ -39,7 +105,7 @@ export class WebToolService {
|
|
|
39
105
|
'x-mar-node-id': config.nodeId,
|
|
40
106
|
authorization: `Bearer ${config.credential}`
|
|
41
107
|
},
|
|
42
|
-
body: JSON.stringify(input),
|
|
108
|
+
body: JSON.stringify({ query: input.query, maxResults: input.maxResults }),
|
|
43
109
|
signal
|
|
44
110
|
});
|
|
45
111
|
if (!response.ok)
|
|
@@ -51,12 +117,55 @@ export class WebToolService {
|
|
|
51
117
|
catch {
|
|
52
118
|
throw invalidResult();
|
|
53
119
|
}
|
|
54
|
-
|
|
120
|
+
const parsed = z
|
|
121
|
+
.object({
|
|
122
|
+
query: z.string().max(500),
|
|
123
|
+
results: z
|
|
124
|
+
.array(z.object({ title: z.string(), url: z.string().max(8192), snippet: z.string() }))
|
|
125
|
+
.max(10)
|
|
126
|
+
})
|
|
127
|
+
.safeParse(result);
|
|
128
|
+
if (!parsed.success)
|
|
55
129
|
throw invalidResult();
|
|
56
|
-
const
|
|
57
|
-
|
|
130
|
+
const budget = input.maxTokens ?? 2000;
|
|
131
|
+
let snippetLimit = 2000;
|
|
132
|
+
let metadataLimit = 8192;
|
|
133
|
+
let results = parsed.data.results;
|
|
134
|
+
let content = '';
|
|
135
|
+
let truncated = false;
|
|
136
|
+
do {
|
|
137
|
+
results = parsed.data.results.map((item) => ({
|
|
138
|
+
title: clip(item.title, metadataLimit),
|
|
139
|
+
url: clip(item.url, metadataLimit),
|
|
140
|
+
snippet: clip(item.snippet, snippetLimit)
|
|
141
|
+
}));
|
|
142
|
+
truncated = results.some((item, i) => item.title !== parsed.data.results[i].title ||
|
|
143
|
+
item.url !== parsed.data.results[i].url ||
|
|
144
|
+
item.snippet !== parsed.data.results[i].snippet);
|
|
145
|
+
content =
|
|
146
|
+
`Search results${truncated ? ' (truncated)' : ''}:\n` +
|
|
147
|
+
results
|
|
148
|
+
.map((item, i) => `${i + 1}. ${item.title}\nURL: ${item.url}\nSearch snippet: ${item.snippet}`)
|
|
149
|
+
.join('\n');
|
|
150
|
+
if (snippetLimit > 0)
|
|
151
|
+
snippetLimit = snippetLimit <= 32 ? 0 : Math.floor(snippetLimit / 2);
|
|
152
|
+
else
|
|
153
|
+
metadataLimit = Math.floor(metadataLimit / 2);
|
|
154
|
+
} while (countModelTokens(content) > budget && metadataLimit >= 32);
|
|
155
|
+
if (countModelTokens(content) > budget)
|
|
58
156
|
throw invalidResult();
|
|
59
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
content,
|
|
159
|
+
data: {
|
|
160
|
+
query: parsed.data.query,
|
|
161
|
+
results: results.map((item, index) => ({
|
|
162
|
+
...item,
|
|
163
|
+
url: parsed.data.results[index].url
|
|
164
|
+
})),
|
|
165
|
+
truncated
|
|
166
|
+
},
|
|
167
|
+
truncated
|
|
168
|
+
};
|
|
60
169
|
}
|
|
61
170
|
catch (error) {
|
|
62
171
|
if (error instanceof AgentError || signal.aborted)
|
|
@@ -67,6 +176,14 @@ export class WebToolService {
|
|
|
67
176
|
}
|
|
68
177
|
}
|
|
69
178
|
}
|
|
179
|
+
function clip(value, maxBytes) {
|
|
180
|
+
if (maxBytes <= 0)
|
|
181
|
+
return value ? '…' : '';
|
|
182
|
+
const bytes = Buffer.from(value);
|
|
183
|
+
return bytes.length <= maxBytes
|
|
184
|
+
? value
|
|
185
|
+
: `${new TextDecoder().decode(bytes.subarray(0, maxBytes - 3), { stream: true })}…`;
|
|
186
|
+
}
|
|
70
187
|
function invalidResult() {
|
|
71
188
|
return new AgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web search returned an invalid result.');
|
|
72
189
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.62",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0 AND GPL-2.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -36,14 +36,17 @@
|
|
|
36
36
|
"npm": "11.12.1",
|
|
37
37
|
"playwright": "1.62.1",
|
|
38
38
|
"sharp": "0.35.5",
|
|
39
|
+
"turndown": "^7.2.4",
|
|
40
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
39
41
|
"ws": "^8.21.3",
|
|
40
42
|
"zod": "4.4.3",
|
|
41
|
-
"@myagentroam/
|
|
43
|
+
"@myagentroam/agent": "0.11.62",
|
|
42
44
|
"@myagentroam/code-navigation": "0.11.1",
|
|
43
|
-
"@myagentroam/
|
|
44
|
-
"@myagentroam/
|
|
45
|
+
"@myagentroam/marctl": "0.11.11",
|
|
46
|
+
"@myagentroam/protocol": "0.11.62"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
49
|
+
"@types/turndown": "^5.0.6",
|
|
47
50
|
"@types/ws": "^8.18.1"
|
|
48
51
|
},
|
|
49
52
|
"exports": {
|