Skip to content

Commit 4834191

Browse files
authored
Merge pull request #513 from alectimison-maker/refactor/extract-image-budget
refactor(agent): extract image budget helpers
2 parents cab3a8c + 700aef9 commit 4834191

8 files changed

Lines changed: 148 additions & 119 deletions

File tree

TODOs.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,15 @@ parity regressions likely.
194194

195195
## 9. Test the real shared logic instead of copied shims
196196

197-
**Status:** Partially resolved. Loop detection now lives in the browser-free
198-
`agent/loop-detector.js` module in both builds. `Agent` inherits that production
199-
class, `test/run.js` imports it directly, and a parity assertion keeps the
200-
Chrome and Firefox copies byte-identical.
197+
**Status:** Partially resolved. Loop detection and image-budget sizing now live
198+
in browser-free modules in both builds. `Agent` consumes the production logic,
199+
`test/run.js` imports it directly, and parity assertions keep the Chrome and
200+
Firefox copies byte-identical.
201201

202202
**Concrete next steps:**
203-
1. Move image budget sizing and other remaining pure logic into browser-free
204-
modules and import those modules directly from `agent.js` and `test/run.js`.
205-
2. Add regression tests for the text tool-call parser and context trimming,
203+
1. Move other remaining pure logic into browser-free modules and import those
204+
modules directly from `agent.js` and `test/run.js`.
205+
2. Add broader regression tests for the text tool-call parser and context trimming,
206206
since both are high-impact agent reliability code.
207207

208208
---

src/chrome/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ src/chrome/
4242
│ ├── agent/
4343
│ │ ├── agent.js # Core agent loop + tool dispatch
4444
│ │ ├── loop-detector.js # Browser-free loop detection, directly unit-tested
45+
│ │ ├── image-budget.js # Browser-free screenshot sizing, directly unit-tested
4546
│ │ ├── mutation-tools.js # This build's state-change + mutating tool sets
4647
│ │ ├── tools.js # Tool schemas + system prompts
4748
│ │ ├── skills.js # Settings skills + dynamic skill tool manifests

src/chrome/src/agent/agent.js

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMo
22
import { handleDoneJson } from './cloud-output.js';
33
import { LoopDetector } from './loop-detector.js';
44
import { parseToolCallsFromText } from './tool-call-parser.js';
5+
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
56
import { BROWSER_MUTATION_TOOLS, STATE_CHANGE_TOOLS as SHARED_STATE_CHANGE_TOOLS } from './mutation-tools.js';
67
import { isCredentialField, CREDENTIAL_NOTE_STRICT, STRICT_SECRET_SYSTEM_NOTE } from './credential-fields.js';
78
import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLedgerDowngrade, isTerminalLedgerStatus, isValidLedgerStatus, ledgerDoneBlock, ledgerRowKey, normalizeLedgerStatus, progressCounts, selectLedgerRows, unresolvedLedgerRows, upsertLedgerItems } from './progress-ledger.js';
@@ -5747,23 +5748,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
57475748
// are tuned to Claude's native vision encoder and happen to be
57485749
// reasonable for most other endpoints too. Override per-capture if you
57495750
// need sharper (coord_aligned) or looser (full_page) constraints.
5750-
static IMAGE_BUDGET = {
5751-
pxPerToken: 28, // rough px² per vision token across providers
5752-
maxTargetPx: 1568, // no dimension bigger than this
5753-
maxTargetTokens: 1568, // total image tokens budget
5754-
maxBase64Chars: 1398100, // ~1.4 MB base64, matches Anthropic's cap
5755-
initialJpegQuality: 0.75,
5756-
minJpegQuality: 0.10,
5757-
jpegQualityStep: 0.05,
5758-
};
5751+
static IMAGE_BUDGET = IMAGE_BUDGET;
57595752

57605753
/**
57615754
* Anthropic's token-cost approximation: ceil((w*h) / pxPerToken²).
57625755
* Good enough to compare two capture sizes under the same budget; not
57635756
* exact for any specific provider's tokenizer, but better than eyeballing.
57645757
*/
57655758
static _estimateImageTokens(w, h, pxPerToken) {
5766-
return Math.ceil((w / pxPerToken) * (h / pxPerToken));
5759+
return estimateImageTokens(w, h, pxPerToken);
57675760
}
57685761

57695762
/**
@@ -5773,33 +5766,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
57735766
* Claude-for-Chrome's `C(w, h, params)` (same algorithm, clearer names).
57745767
*/
57755768
static _fitImageDimensions(origW, origH, budget = Agent.IMAGE_BUDGET) {
5776-
const { pxPerToken, maxTargetPx, maxTargetTokens } = budget;
5777-
// Already fits — no work.
5778-
if (origW <= maxTargetPx && origH <= maxTargetPx &&
5779-
Agent._estimateImageTokens(origW, origH, pxPerToken) <= maxTargetTokens) {
5780-
return [origW, origH];
5781-
}
5782-
// Search the long side; the other follows from aspect ratio.
5783-
if (origH > origW) {
5784-
const [h, w] = Agent._fitImageDimensions(origH, origW, budget);
5785-
return [w, h];
5786-
}
5787-
const aspect = origW / origH;
5788-
let hi = origW, lo = 1;
5789-
// eslint-disable-next-line no-constant-condition
5790-
while (true) {
5791-
if (lo + 1 >= hi) {
5792-
return [lo, Math.max(Math.round(lo / aspect), 1)];
5793-
}
5794-
const mid = Math.floor((lo + hi) / 2);
5795-
const midH = Math.max(Math.round(mid / aspect), 1);
5796-
if (mid <= maxTargetPx &&
5797-
Agent._estimateImageTokens(mid, midH, pxPerToken) <= maxTargetTokens) {
5798-
lo = mid;
5799-
} else {
5800-
hi = mid;
5801-
}
5802-
}
5769+
return fitImageDimensions(origW, origH, budget);
58035770
}
58045771

58055772
/**
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Browser-free image sizing shared by Agent and the unit suite. This file is
2+
// mirrored in the Firefox tree; keep both copies byte-identical.
3+
4+
// These defaults match Claude for Chrome. Pixel dimensions and approximate
5+
// vision-token area bound provider cost, while the byte/quality fields are
6+
// consumed by each browser's platform-specific image encoder. Frozen so an
7+
// accidental mutation of the shared default throws instead of silently
8+
// changing every caller; per-capture overrides spread into a fresh object.
9+
export const IMAGE_BUDGET = Object.freeze({
10+
pxPerToken: 28,
11+
maxTargetPx: 1568,
12+
maxTargetTokens: 1568,
13+
maxBase64Chars: 1398100,
14+
initialJpegQuality: 0.75,
15+
minJpegQuality: 0.10,
16+
jpegQualityStep: 0.05,
17+
});
18+
19+
export function estimateImageTokens(w, h, pxPerToken) {
20+
return Math.ceil((w / pxPerToken) * (h / pxPerToken));
21+
}
22+
23+
// Return the largest dimensions no greater than the original that fit both
24+
// the per-side and token-area caps while preserving the aspect ratio.
25+
export function fitImageDimensions(origW, origH, budget = IMAGE_BUDGET) {
26+
const { pxPerToken, maxTargetPx, maxTargetTokens } = budget;
27+
if (origW <= maxTargetPx && origH <= maxTargetPx
28+
&& estimateImageTokens(origW, origH, pxPerToken) <= maxTargetTokens) {
29+
return [origW, origH];
30+
}
31+
if (origH > origW) {
32+
const [h, w] = fitImageDimensions(origH, origW, budget);
33+
return [w, h];
34+
}
35+
const aspect = origW / origH;
36+
let hi = origW;
37+
let lo = 1;
38+
// eslint-disable-next-line no-constant-condition
39+
while (true) {
40+
if (lo + 1 >= hi) {
41+
return [lo, Math.max(Math.round(lo / aspect), 1)];
42+
}
43+
const mid = Math.floor((lo + hi) / 2);
44+
const midH = Math.max(Math.round(mid / aspect), 1);
45+
if (mid <= maxTargetPx
46+
&& estimateImageTokens(mid, midH, pxPerToken) <= maxTargetTokens) {
47+
lo = mid;
48+
} else {
49+
hi = mid;
50+
}
51+
}
52+
}

src/firefox/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ src/firefox/
5555
│ ├── agent/
5656
│ │ ├── agent.js # Core agent loop
5757
│ │ ├── loop-detector.js # Browser-free loop detection, directly unit-tested
58+
│ │ ├── image-budget.js # Browser-free screenshot sizing, directly unit-tested
5859
│ │ ├── mutation-tools.js # This build's state-change + mutating tool sets
5960
│ │ ├── tools.js # Tool schemas + system prompts (incl. 4 AX tools)
6061
│ │ ├── skills.js # Settings skills + dynamic skill tool manifests

src/firefox/src/agent/agent.js

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMo
22
import { handleDoneJson } from './cloud-output.js';
33
import { LoopDetector } from './loop-detector.js';
44
import { parseToolCallsFromText } from './tool-call-parser.js';
5+
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
56
import { BROWSER_MUTATION_TOOLS, STATE_CHANGE_TOOLS as SHARED_STATE_CHANGE_TOOLS } from './mutation-tools.js';
67
import { isCredentialField, CREDENTIAL_NOTE_STRICT, STRICT_SECRET_SYSTEM_NOTE } from './credential-fields.js';
78
import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLedgerDowngrade, isTerminalLedgerStatus, isValidLedgerStatus, ledgerDoneBlock, ledgerRowKey, normalizeLedgerStatus, progressCounts, selectLedgerRows, unresolvedLedgerRows, upsertLedgerItems } from './progress-ledger.js';
@@ -4062,44 +4063,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
40624063
// and can use regular <canvas> / toBlob instead of OffscreenCanvas.
40634064
// Constants match Anthropic's Claude-for-Chrome defaults and the Chrome
40644065
// Agent's IMAGE_BUDGET — keep them in sync.
4065-
static IMAGE_BUDGET = {
4066-
pxPerToken: 28,
4067-
maxTargetPx: 1568,
4068-
maxTargetTokens: 1568,
4069-
maxBase64Chars: 1398100,
4070-
initialJpegQuality: 0.75,
4071-
minJpegQuality: 0.10,
4072-
jpegQualityStep: 0.05,
4073-
};
4066+
static IMAGE_BUDGET = IMAGE_BUDGET;
40744067

40754068
static _estimateImageTokens(w, h, pxPerToken) {
4076-
return Math.ceil((w / pxPerToken) * (h / pxPerToken));
4069+
return estimateImageTokens(w, h, pxPerToken);
40774070
}
40784071

40794072
static _fitImageDimensions(origW, origH, budget = Agent.IMAGE_BUDGET) {
4080-
const { pxPerToken, maxTargetPx, maxTargetTokens } = budget;
4081-
if (origW <= maxTargetPx && origH <= maxTargetPx &&
4082-
Agent._estimateImageTokens(origW, origH, pxPerToken) <= maxTargetTokens) {
4083-
return [origW, origH];
4084-
}
4085-
if (origH > origW) {
4086-
const [h, w] = Agent._fitImageDimensions(origH, origW, budget);
4087-
return [w, h];
4088-
}
4089-
const aspect = origW / origH;
4090-
let hi = origW, lo = 1;
4091-
// eslint-disable-next-line no-constant-condition
4092-
while (true) {
4093-
if (lo + 1 >= hi) return [lo, Math.max(Math.round(lo / aspect), 1)];
4094-
const mid = Math.floor((lo + hi) / 2);
4095-
const midH = Math.max(Math.round(mid / aspect), 1);
4096-
if (mid <= maxTargetPx &&
4097-
Agent._estimateImageTokens(mid, midH, pxPerToken) <= maxTargetTokens) {
4098-
lo = mid;
4099-
} else {
4100-
hi = mid;
4101-
}
4102-
}
4073+
return fitImageDimensions(origW, origH, budget);
41034074
}
41044075

41054076
/**
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Browser-free image sizing shared by Agent and the unit suite. This file is
2+
// mirrored in the Firefox tree; keep both copies byte-identical.
3+
4+
// These defaults match Claude for Chrome. Pixel dimensions and approximate
5+
// vision-token area bound provider cost, while the byte/quality fields are
6+
// consumed by each browser's platform-specific image encoder. Frozen so an
7+
// accidental mutation of the shared default throws instead of silently
8+
// changing every caller; per-capture overrides spread into a fresh object.
9+
export const IMAGE_BUDGET = Object.freeze({
10+
pxPerToken: 28,
11+
maxTargetPx: 1568,
12+
maxTargetTokens: 1568,
13+
maxBase64Chars: 1398100,
14+
initialJpegQuality: 0.75,
15+
minJpegQuality: 0.10,
16+
jpegQualityStep: 0.05,
17+
});
18+
19+
export function estimateImageTokens(w, h, pxPerToken) {
20+
return Math.ceil((w / pxPerToken) * (h / pxPerToken));
21+
}
22+
23+
// Return the largest dimensions no greater than the original that fit both
24+
// the per-side and token-area caps while preserving the aspect ratio.
25+
export function fitImageDimensions(origW, origH, budget = IMAGE_BUDGET) {
26+
const { pxPerToken, maxTargetPx, maxTargetTokens } = budget;
27+
if (origW <= maxTargetPx && origH <= maxTargetPx
28+
&& estimateImageTokens(origW, origH, pxPerToken) <= maxTargetTokens) {
29+
return [origW, origH];
30+
}
31+
if (origH > origW) {
32+
const [h, w] = fitImageDimensions(origH, origW, budget);
33+
return [w, h];
34+
}
35+
const aspect = origW / origH;
36+
let hi = origW;
37+
let lo = 1;
38+
// eslint-disable-next-line no-constant-condition
39+
while (true) {
40+
if (lo + 1 >= hi) {
41+
return [lo, Math.max(Math.round(lo / aspect), 1)];
42+
}
43+
const mid = Math.floor((lo + hi) / 2);
44+
const midH = Math.max(Math.round(mid / aspect), 1);
45+
if (mid <= maxTargetPx
46+
&& estimateImageTokens(mid, midH, pxPerToken) <= maxTargetTokens) {
47+
lo = mid;
48+
} else {
49+
hi = mid;
50+
}
51+
}
52+
}

test/run.js

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,16 @@ const ToolCallParserCh = await import(
453453
const ToolCallParserFx = await import(
454454
'file://' + path.join(ROOT, 'src/firefox/src/agent/tool-call-parser.js').replace(/\\/g, '/')
455455
);
456+
const ImageBudgetCh = await import(
457+
'file://' + path.join(ROOT, 'src/chrome/src/agent/image-budget.js').replace(/\\/g, '/')
458+
);
459+
const ImageBudgetFx = await import(
460+
'file://' + path.join(ROOT, 'src/firefox/src/agent/image-budget.js').replace(/\\/g, '/')
461+
);
462+
const {
463+
estimateImageTokens,
464+
fitImageDimensions,
465+
} = ImageBudgetCh;
456466
// The mutating-tool surface differs per build, so it lives outside the
457467
// byte-identical loop-detector module. Import the real sets rather than
458468
// restating them here — a hand-copied list is exactly the drift this suite
@@ -5939,50 +5949,25 @@ test('failed API replay suppresses future bulk replay hints until a success clea
59395949
}
59405950
}
59415951
});
5942-
//
5943-
// These mirror the static helpers on Agent exactly — keep them in sync
5944-
// with src/chrome/src/agent/agent.js `_estimateImageTokens` and
5945-
// `_fitImageDimensions`. We shim rather than import because agent.js
5946-
// pulls in chrome.* and cdp-client.
5947-
// ────────────────────────────────────────────────────────────────────────
5948-
5949-
const IMAGE_BUDGET_DEFAULT = {
5950-
pxPerToken: 28,
5951-
maxTargetPx: 1568,
5952-
maxTargetTokens: 1568,
5953-
};
5954-
5955-
function estimateImageTokens(w, h, pxPerToken) {
5956-
return Math.ceil((w / pxPerToken) * (h / pxPerToken));
5957-
}
5958-
5959-
function fitImageDimensions(origW, origH, budget = IMAGE_BUDGET_DEFAULT) {
5960-
const { pxPerToken, maxTargetPx, maxTargetTokens } = budget;
5961-
if (origW <= maxTargetPx && origH <= maxTargetPx &&
5962-
estimateImageTokens(origW, origH, pxPerToken) <= maxTargetTokens) {
5963-
return [origW, origH];
5964-
}
5965-
if (origH > origW) {
5966-
const [h, w] = fitImageDimensions(origH, origW, budget);
5967-
return [w, h];
5968-
}
5969-
const aspect = origW / origH;
5970-
let hi = origW, lo = 1;
5971-
while (true) {
5972-
if (lo + 1 >= hi) return [lo, Math.max(Math.round(lo / aspect), 1)];
5973-
const mid = Math.floor((lo + hi) / 2);
5974-
const midH = Math.max(Math.round(mid / aspect), 1);
5975-
if (mid <= maxTargetPx &&
5976-
estimateImageTokens(mid, midH, pxPerToken) <= maxTargetTokens) {
5977-
lo = mid;
5978-
} else {
5979-
hi = mid;
5980-
}
5981-
}
5982-
}
5983-
59845952
console.log('\nimage budget');
59855953

5954+
test('image budget helpers are production code shared by both browser agents', () => {
5955+
assert.equal(
5956+
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/image-budget.js'), 'utf8'),
5957+
fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/image-budget.js'), 'utf8'),
5958+
'chrome and firefox image-budget modules must remain byte-identical',
5959+
);
5960+
assert.deepEqual(ImageBudgetFx.IMAGE_BUDGET, ImageBudgetCh.IMAGE_BUDGET);
5961+
assert.equal(
5962+
ImageBudgetFx.estimateImageTokens(1920, 1080, 28),
5963+
estimateImageTokens(1920, 1080, 28),
5964+
);
5965+
assert.deepEqual(
5966+
ImageBudgetFx.fitImageDimensions(3840, 2160),
5967+
fitImageDimensions(3840, 2160),
5968+
);
5969+
});
5970+
59865971
test('small viewport passes through unchanged (fast path)', () => {
59875972
// 1280×800 at pxPerToken=28 → 1307 tokens < 1568 — no resize.
59885973
const [w, h] = fitImageDimensions(1280, 800);

0 commit comments

Comments
 (0)