Skip to content

Commit 3675f11

Browse files
authored
Merge pull request #214 from esokullu/agent/paginate-read-page
Paginate read_page article text
2 parents 253744e + 75effb0 commit 3675f11

7 files changed

Lines changed: 560 additions & 18 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX, SYSTEM_PROMPT_WEBMCP_ASK, SYSTEM_PROMPT_WEBMCP_ACT } from './tools.js';
22
import { handleDoneJson } from './cloud-output.js';
3+
import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js';
34
import { LoopDetector } from './loop-detector.js';
45
import { parseToolCallsFromText } from './tool-call-parser.js';
56
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
@@ -12645,13 +12646,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1264512646
*/
1264612647
_limitToolResult(result) {
1264712648
const maxResultChars = 8000; // ~2k tokens
12648-
const safeResult = result == null ? {
12649+
const windowSafeResult = isReadPageWindowResult(result)
12650+
? fitReadPageWindowResult(result, maxResultChars)
12651+
: result;
12652+
const safeResult = windowSafeResult == null ? {
1264912653
success: false,
1265012654
errorCode: 'missing_tool_response',
1265112655
missingToolResponse: true,
1265212656
outcomeUnknown: false,
1265312657
error: 'Tool returned no result.',
12654-
} : result;
12658+
} : windowSafeResult;
1265512659
let json;
1265612660
try {
1265712661
json = JSON.stringify(safeResult);
@@ -12668,14 +12672,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1266812672
if (json.length <= maxResultChars) return json;
1266912673

1267012674
// Try to trim the 'text' field specifically (page content)
12671-
if (result && typeof result.text === 'string' && result.text.length > 4000) {
12672-
const trimmed = { ...result, text: result.text.slice(0, 4000) + '\n[...page text truncated]' };
12675+
if (safeResult && typeof safeResult.text === 'string' && safeResult.text.length > 4000) {
12676+
const trimmed = { ...safeResult, text: safeResult.text.slice(0, 4000) + '\n[...page text truncated]' };
1267312677
json = JSON.stringify(trimmed);
1267412678
if (json.length <= maxResultChars) return json;
1267512679
}
1267612680

12677-
if (result && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
12678-
const originalData = result.data;
12681+
if (safeResult && safeResult.data && typeof safeResult.data === 'object' && !Array.isArray(safeResult.data)) {
12682+
const originalData = safeResult.data;
1267912683
const originalText = typeof originalData.text === 'string' ? originalData.text : null;
1268012684
const originalSegments = Array.isArray(originalData.segments) ? originalData.segments : null;
1268112685
if (originalText || originalSegments) {
@@ -12710,7 +12714,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1271012714
data.segmentsTruncated = true;
1271112715
data.originalSegmentCount = data.originalSegmentCount ?? originalSegments.length;
1271212716
}
12713-
const trimmed = { ...result, data };
12717+
const trimmed = { ...safeResult, data };
1271412718
json = JSON.stringify(trimmed);
1271512719
if (json.length <= maxResultChars) return json;
1271612720
}
@@ -18067,6 +18071,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1806718071
);
1806818072
this._recordInteractionRect(tabId, name, response, interactionUrl);
1806918073
this._annotateCredentialField(name, response);
18074+
if (name === 'read_page') {
18075+
response = applyReadPageWindow(response, args);
18076+
}
1807018077
return response;
1807118078
} finally {
1807218079
clickAxSideEffectWatch?.stop();
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
export const READ_PAGE_DEFAULT_LIMIT = 4000;
2+
export const READ_PAGE_MIN_LIMIT = 500;
3+
export const READ_PAGE_MAX_LIMIT = 6000;
4+
5+
function boundedInteger(value, fallback, min, max) {
6+
if (value == null || value === '') return fallback;
7+
const numeric = Number(value);
8+
if (!Number.isFinite(numeric)) return fallback;
9+
return Math.min(max, Math.max(min, Math.floor(numeric)));
10+
}
11+
12+
function withDeliveredText(result, text) {
13+
const originalLength = Number(result.originalLength) || 0;
14+
const textOffset = Number(result.textOffset) || 0;
15+
const returnedLength = text.length;
16+
const deliveredEnd = Math.min(originalLength, textOffset + returnedLength);
17+
const hasMore = deliveredEnd < originalLength;
18+
const textTruncated = textOffset > 0 || hasMore;
19+
const continuationArgs = hasMore
20+
? {
21+
offset: deliveredEnd,
22+
limit: Number(result.textLimit) || READ_PAGE_DEFAULT_LIMIT,
23+
includeChrome: result.includeChrome === true,
24+
}
25+
: null;
26+
return {
27+
...result,
28+
text,
29+
returnedLength,
30+
textTruncated,
31+
hasMore,
32+
nextOffset: hasMore ? deliveredEnd : null,
33+
continuationArgs,
34+
truncationReason: textTruncated ? 'tool_output_window' : null,
35+
};
36+
}
37+
38+
function compactForm(form, inputLimit) {
39+
if (!form || typeof form !== 'object' || !Array.isArray(form.inputs)) return form;
40+
if (form.inputs.length <= inputLimit) return form;
41+
return {
42+
...form,
43+
inputs: form.inputs.slice(0, inputLimit),
44+
inputsTruncated: true,
45+
originalInputCount: form.inputs.length,
46+
};
47+
}
48+
49+
function compactAuxiliaryContent(result, {
50+
linkLimit,
51+
formLimit,
52+
inputLimit,
53+
shadowLimit,
54+
mediaLimit,
55+
}) {
56+
const links = Array.isArray(result.links) ? result.links : null;
57+
const forms = Array.isArray(result.forms) ? result.forms : null;
58+
const shadowDOM = Array.isArray(result.shadowDOM) ? result.shadowDOM : null;
59+
const media = result.media && typeof result.media === 'object' ? result.media : null;
60+
const videos = Array.isArray(media?.videos) ? media.videos : null;
61+
const images = Array.isArray(media?.images) ? media.images : null;
62+
return {
63+
...result,
64+
...(links ? {
65+
links: links.slice(0, linkLimit),
66+
originalLinkCount: links.length,
67+
} : {}),
68+
...(forms ? {
69+
forms: forms.slice(0, formLimit).map(form => compactForm(form, inputLimit)),
70+
originalFormCount: forms.length,
71+
} : {}),
72+
...(shadowDOM ? {
73+
shadowDOM: shadowDOM.slice(0, shadowLimit),
74+
originalShadowRootCount: shadowDOM.length,
75+
} : {}),
76+
...(media ? {
77+
media: {
78+
...media,
79+
...(videos ? { videos: videos.slice(0, mediaLimit) } : {}),
80+
...(images ? { images: images.slice(0, mediaLimit) } : {}),
81+
},
82+
} : {}),
83+
auxiliaryContentTruncated: true,
84+
};
85+
}
86+
87+
function compactCoreResult(result) {
88+
const shortString = (value, limit) => (
89+
typeof value === 'string' ? value.slice(0, limit) : value
90+
);
91+
const pageGate = result.pageGate && typeof result.pageGate === 'object'
92+
? {
93+
type: shortString(result.pageGate.type, 100),
94+
blocking: result.pageGate.blocking === true,
95+
surface: shortString(result.pageGate.surface, 100),
96+
label: shortString(result.pageGate.label, 500),
97+
}
98+
: undefined;
99+
return withDeliveredText({
100+
url: shortString(result.url, 1000),
101+
title: shortString(result.title, 500),
102+
text: result.text,
103+
textSource: shortString(result.textSource, 200),
104+
isArticlePage: result.isArticlePage === true,
105+
includeChrome: result.includeChrome === true,
106+
originalLength: result.originalLength,
107+
textOffset: result.textOffset,
108+
textLimit: result.textLimit,
109+
accessState: result.accessState,
110+
accessGateEvidence: result.accessGateEvidence,
111+
...(pageGate ? { pageGate } : {}),
112+
auxiliaryContentTruncated: true,
113+
}, result.text);
114+
}
115+
116+
export function isReadPageWindowResult(result) {
117+
return !!result
118+
&& typeof result === 'object'
119+
&& typeof result.text === 'string'
120+
&& Number.isFinite(Number(result.originalLength))
121+
&& Number.isFinite(Number(result.textOffset))
122+
&& typeof result.accessState === 'string';
123+
}
124+
125+
export function applyReadPageWindow(result, args = {}) {
126+
if (!result || typeof result !== 'object' || typeof result.text !== 'string') return result;
127+
128+
const originalText = result.text;
129+
const originalLength = originalText.length;
130+
const requestedOffset = boundedInteger(args.offset, 0, 0, Number.MAX_SAFE_INTEGER);
131+
const textOffset = Math.min(requestedOffset, originalLength);
132+
const textLimit = boundedInteger(
133+
args.limit,
134+
READ_PAGE_DEFAULT_LIMIT,
135+
READ_PAGE_MIN_LIMIT,
136+
READ_PAGE_MAX_LIMIT,
137+
);
138+
const text = originalText.slice(textOffset, textOffset + textLimit);
139+
const blockingPageGate = result.pageGate?.blocking === true;
140+
const {
141+
media,
142+
activeElement,
143+
links,
144+
forms,
145+
shadowDOM,
146+
...core
147+
} = result;
148+
149+
return withDeliveredText({
150+
...core,
151+
text,
152+
originalLength,
153+
textOffset,
154+
textLimit,
155+
accessState: blockingPageGate ? 'blocked_by_page_gate' : 'no_blocking_page_gate',
156+
accessGateEvidence: blockingPageGate ? 'pageGate' : 'none',
157+
...(media !== undefined ? { media } : {}),
158+
...(activeElement !== undefined ? { activeElement } : {}),
159+
...(links !== undefined ? { links } : {}),
160+
...(forms !== undefined ? { forms } : {}),
161+
...(shadowDOM !== undefined ? { shadowDOM } : {}),
162+
}, text);
163+
}
164+
165+
export function fitReadPageWindowResult(result, maxChars = 8000) {
166+
if (!isReadPageWindowResult(result)) return result;
167+
const fits = candidate => JSON.stringify(candidate).length <= maxChars;
168+
if (fits(result)) return result;
169+
170+
const profiles = [
171+
{ linkLimit: 20, formLimit: 5, inputLimit: 10, shadowLimit: 5, mediaLimit: 5 },
172+
{ linkLimit: 10, formLimit: 3, inputLimit: 5, shadowLimit: 2, mediaLimit: 2 },
173+
{ linkLimit: 0, formLimit: 0, inputLimit: 0, shadowLimit: 0, mediaLimit: 0 },
174+
];
175+
let compact = result;
176+
for (const profile of profiles) {
177+
compact = compactAuxiliaryContent(result, profile);
178+
if (fits(compact)) return compact;
179+
}
180+
if (!fits(withDeliveredText(compact, ''))) {
181+
compact = compactCoreResult(result);
182+
}
183+
184+
let low = 0;
185+
let high = compact.text.length;
186+
let best = withDeliveredText(compact, '');
187+
while (low <= high) {
188+
const mid = Math.floor((low + high) / 2);
189+
const candidate = withDeliveredText(compact, compact.text.slice(0, mid));
190+
if (fits(candidate)) {
191+
best = candidate;
192+
low = mid + 1;
193+
} else {
194+
high = mid - 1;
195+
}
196+
}
197+
return best;
198+
}

src/chrome/src/agent/tools.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,25 @@ export const AGENT_TOOLS = [
129129
type: 'function',
130130
function: {
131131
name: 'read_page',
132-
description: 'Read the current page as PROSE — title, URL, visible text, links, forms. LEGACY read path; prefer get_accessibility_tree for UI tasks. Use read_page only when the user is asking about long-form text content (articles, READMEs, documentation). RESULT SHAPE: `pageGate`, when present, describes a rendered blocking login/registration/subscription surface and means text behind that gate was deliberately excluded; `text` is the readable article body or bounded pre-gate/gate text; `textSource` is the CSS selector that produced the body, "page-gate", or a "(pre-gate)" selector; `isArticlePage` reports article markup. Only treat the article body as complete when no blocking `pageGate` is present and `isArticlePage:true` with a real article selector. NOTE: PDF tabs auto-redirect to read_pdf because Chrome\'s PDF viewer is a chrome-extension:// page that content scripts cannot scrape.',
132+
description: 'Read the current page as a bounded PROSE window — title, URL, visible text, links, and forms. LEGACY read path; prefer get_accessibility_tree for UI tasks. Use read_page only for long-form text content (articles, READMEs, documentation). While `hasMore:true`, continue with the exact returned `continuationArgs`; it carries `offset:nextOffset`, `limit`, and extraction options such as `includeChrome`. Do not scroll and reread the same document prefix. RESULT SHAPE: `text`, `originalLength`, `textOffset`, `textLimit`, `returnedLength`, `textTruncated`, `hasMore`, `nextOffset`, and `continuationArgs` describe the tool-output window. `truncationReason:"tool_output_window"` is a context-window boundary, never evidence of a paywall. `accessState:"blocked_by_page_gate"` plus `accessGateEvidence:"pageGate"` is the structured access-block signal; `accessState:"no_blocking_page_gate"` means tool truncation must not be described as an access restriction. `pageGate`, when present, describes the rendered blocking surface; `textSource` identifies the article selector or bounded pre-gate/gate text; `isArticlePage` reports article markup. NOTE: PDF tabs auto-redirect to read_pdf because Chrome\'s PDF viewer is a chrome-extension:// page that content scripts cannot scrape.',
133133
parameters: {
134134
type: 'object',
135135
properties: {
136136
includeChrome: {
137137
type: 'boolean',
138138
description: 'Include nav / header / footer / aside / ad-slot text in the body. Default false — when the user asks about article/README content you almost never want this. Set true only when the user is asking ABOUT the navigation menu, footer links, cookie banner, advertisement, etc.',
139139
},
140+
offset: {
141+
type: 'integer',
142+
minimum: 0,
143+
description: 'Character offset into the extracted prose. Default 0. When hasMore is true, pass the exact returned continuationArgs so extraction options stay stable.',
144+
},
145+
limit: {
146+
type: 'integer',
147+
minimum: 500,
148+
maximum: 6000,
149+
description: 'Maximum prose characters to return. Default 4000; bounded to 500..6000.',
150+
},
140151
},
141152
required: [],
142153
},
@@ -1403,7 +1414,7 @@ READING THE CURRENT TAB vs. FETCHING URLS — read this:
14031414
- DO NOT call \`fetch_url\` or \`research_url\` against the URL of the active tab, the API equivalent of the active tab, or a "renderable" / "raw" / "amp" / "mobile" variant of the active tab's URL. Re-fetching content the user is already looking at is the most common wasted step. Symptom of this antipattern: you fetch a Wikipedia/MediaWiki API URL for the same page the user is on, get a truncated result, then fetch a slightly different variant hoping for more content. Stop and call \`read_page\` instead.
14041415
- \`fetch_url\` and \`research_url\` are for content on OTHER URLs — a referenced article, an API the page links to, a sibling page, a different site entirely.
14051416
- If \`get_accessibility_tree({filter:"visible"})\` returns \`truncated:true\` / \`hasMore:true\`, call \`get_accessibility_tree({filter:"visible", page: nextPage})\` before scrolling to find a control that may already be visible but omitted from the first chunk.
1406-
- If \`read_page\` truncates or doesn't surface what you need, scroll the tab and re-read; or use \`get_accessibility_tree({ref_id: ...})\` to read a specific subtree. Don't escape to fetch_url to retrieve what's already in the DOM.
1417+
- If \`read_page\` returns \`hasMore:true\`, continue deterministically with the exact returned \`continuationArgs\` (equivalent to \`{offset: nextOffset, limit: textLimit, includeChrome}\`) until enough article text is covered. Preserve every extraction option across windows; do not scroll and reread the same prefix. \`truncationReason:"tool_output_window"\` with \`accessState:"no_blocking_page_gate"\` is NOT a paywall or access restriction; only a structured blocking \`pageGate\` supports that claim.
14071418
14081419
Guidelines:
14091420
1. Read the page first (a11y tree by default) to understand the context, then answer the user's question.

src/firefox/src/agent/agent.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX } from './tools.js';
22
import { handleDoneJson } from './cloud-output.js';
3+
import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js';
34
import { LoopDetector } from './loop-detector.js';
45
import { parseToolCallsFromText } from './tool-call-parser.js';
56
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
@@ -11217,13 +11218,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1121711218
*/
1121811219
_limitToolResult(result) {
1121911220
const maxResultChars = 8000; // ~2k tokens
11220-
const safeResult = result == null ? {
11221+
const windowSafeResult = isReadPageWindowResult(result)
11222+
? fitReadPageWindowResult(result, maxResultChars)
11223+
: result;
11224+
const safeResult = windowSafeResult == null ? {
1122111225
success: false,
1122211226
errorCode: 'missing_tool_response',
1122311227
missingToolResponse: true,
1122411228
outcomeUnknown: false,
1122511229
error: 'Tool returned no result.',
11226-
} : result;
11230+
} : windowSafeResult;
1122711231
let json;
1122811232
try {
1122911233
json = JSON.stringify(safeResult);
@@ -11240,14 +11244,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1124011244
if (json.length <= maxResultChars) return json;
1124111245

1124211246
// Try to trim the 'text' field specifically (page content)
11243-
if (result && typeof result.text === 'string' && result.text.length > 4000) {
11244-
const trimmed = { ...result, text: result.text.slice(0, 4000) + '\n[...page text truncated]' };
11247+
if (safeResult && typeof safeResult.text === 'string' && safeResult.text.length > 4000) {
11248+
const trimmed = { ...safeResult, text: safeResult.text.slice(0, 4000) + '\n[...page text truncated]' };
1124511249
json = JSON.stringify(trimmed);
1124611250
if (json.length <= maxResultChars) return json;
1124711251
}
1124811252

11249-
if (result && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
11250-
const originalData = result.data;
11253+
if (safeResult && safeResult.data && typeof safeResult.data === 'object' && !Array.isArray(safeResult.data)) {
11254+
const originalData = safeResult.data;
1125111255
const originalText = typeof originalData.text === 'string' ? originalData.text : null;
1125211256
const originalSegments = Array.isArray(originalData.segments) ? originalData.segments : null;
1125311257
if (originalText || originalSegments) {
@@ -11282,7 +11286,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1128211286
data.segmentsTruncated = true;
1128311287
data.originalSegmentCount = data.originalSegmentCount ?? originalSegments.length;
1128411288
}
11285-
const trimmed = { ...result, data };
11289+
const trimmed = { ...safeResult, data };
1128611290
json = JSON.stringify(trimmed);
1128711291
if (json.length <= maxResultChars) return json;
1128811292
}
@@ -14224,6 +14228,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1422414228
}
1422514229
}
1422614230
this._annotateCredentialField(name, response);
14231+
if (name === 'read_page') {
14232+
response = applyReadPageWindow(response, args);
14233+
}
1422714234
return response;
1422814235
} catch (e) {
1422914236
// Content script might not be injected — try injecting it
@@ -14250,6 +14257,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1425014257
}
1425114258
}
1425214259
this._annotateCredentialField(name, response);
14260+
if (name === 'read_page') {
14261+
response = applyReadPageWindow(response, args);
14262+
}
1425314263
return response;
1425414264
} catch (e2) {
1425514265
let pageUrl = '';

0 commit comments

Comments
 (0)