fix(tools): a screenshot arrives downscaled, with the full capture on disk

This commit is contained in:
2026-08-27 08:45:44 -07:00
parent 9f6663482d
commit 369b525882
11 changed files with 342 additions and 104 deletions
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.15.15"
version = "0.15.16"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md"
requires-python = ">=3.12"
@@ -30,16 +30,16 @@ dependencies = [
"hanzo-async>=0.1.3",
"uvloop>=0.22.1; sys_platform != 'win32'",
# Hanzo tool infrastructure (all tools)
"hanzo-tools>=0.3.4", # 0.3.4 owns hanzo_tools.core exclusively, incl. HanzoCloud (the unified `hanzo` tool needs it)
"hanzo-tools>=0.3.6", # 0.3.6 carries capture(): downscaled inline, full-res on disk
"hanzo-tools-fs>=0.3.4", # 0.3.4 returns image files as native MCP ImageContent
"hanzo-tools-shell>=0.6.5", # 0.6.5 pairs with hanzo-tools>=0.3.0 (ToolError)
"hanzo-tools-core>=0.3.1", # 0.3.1 is the EMPTY shim — no longer shadows canonical hanzo-tools.core
"hanzo-tools-browser[playwright]>=0.5.9", # 0.5.9 emits screenshots as native MCP ImageContent
"hanzo-tools-browser[playwright]>=0.5.11", # 0.5.11 screenshots downscale before they reach the wire
"hanzo-tools-memory>=0.2.0",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
"hanzo-tools-lsp>=0.1.0",
"hanzo-tools-computer>=0.1.0",
"hanzo-tools-computer>=0.5.5", # 0.5.5 screenshots downscale before they reach the wire
# 0.2.1 fixes ConfigTool passing permission_manager positionally into BaseTool.__init__
"hanzo-tools-config>=0.2.1",
"hanzo-tools-refactor>=0.1.0",
@@ -31,7 +31,7 @@ from dataclasses import field, dataclass
from pydantic import Field
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolImage
from hanzo_tools.core import BaseTool, capture
from hanzo_tools.core.unified import _result_to_mcp
# Playwright import with graceful fallback
@@ -82,36 +82,6 @@ def _save_config(config: dict) -> None:
logger.warning(f"Failed to save config: {e}")
def _save_capture(data: bytes, fmt: str, path: Optional[str] = None) -> dict:
"""Persist a screenshot/pdf capture to disk and return a COMPACT result.
A full-page PNG inlined as base64 (100K+ chars) overflows an agent's context
window and wedges the whole run — the single biggest cause of "the browser
tool hangs". So captures ALWAYS go to a file and we return its ``path`` + byte
``size``; ``base64`` is inlined ONLY for a small PNG (<= 40 KB raw) that a
caller can display without blowing the context. Honors a caller-supplied path.
"""
import uuid
if path:
p = Path(path).expanduser()
if p.parent and not p.parent.exists():
p.parent.mkdir(parents=True, exist_ok=True)
else:
d = Path.home() / ".hanzo" / "screenshots"
d.mkdir(parents=True, exist_ok=True)
p = d / f"capture-{uuid.uuid4().hex[:12]}.{fmt}"
try:
p.write_bytes(data)
except Exception as e: # never let a save failure wedge the action
logger.warning(f"screenshot save failed ({e}); returning base64 inline")
return {"success": True, "format": fmt, "size": len(data), "base64": base64.b64encode(data).decode()}
out = {"success": True, "format": fmt, "size": len(data), "path": str(p)}
if fmt == "png" and len(data) <= 40000:
out["base64"] = base64.b64encode(data).decode()
return out
def _extract_b64(text: str) -> Optional[str]:
"""Pull the base64 payload out of a screenshot response — whether it's raw
base64, a data: URL, or JSON under data/base64/screenshot (incl. a nested
@@ -326,10 +296,22 @@ def _zap_params(
code: Optional[str] = None,
expression: Optional[str] = None,
full_page: Optional[bool] = None,
max_width: Optional[int] = None,
quality: Optional[int] = None,
full_res: Optional[bool] = None,
**rest,
) -> dict:
"""Translate the extension-tool kwargs into wire params."""
params: dict[str, Any] = {}
if action == "screenshot":
# Shrink where the pixels are. A 4480x1440 PNG is ~740 KB of base64 on the
# native-messaging hop; asking the browser for a 1280px JPEG means that
# payload is never built, let alone carried.
params["format"] = "png" if full_res else "jpeg"
if quality is not None:
params["quality"] = quality
if max_width is not None and not full_res:
params["maxWidth"] = max_width
if url is not None:
params["url"] = url
if selector is not None:
@@ -380,6 +362,7 @@ async def _bidi_dispatch(
text: Optional[str] = None,
code: Optional[str] = None,
key: Optional[str] = None,
detail: Optional[dict] = None,
) -> Optional[dict[str, Any]]:
"""Drive Firefox via WebDriver BiDi (real navigation + trusted input).
@@ -434,7 +417,8 @@ async def _bidi_dispatch(
return {"success": True, "source": "bidi"}
if action == "screenshot":
c = await _ctx()
return {"success": True, "source": "bidi", "image": ToolImage(data=await client.capture_screenshot(c), mime_type="image/png", alt="screenshot")}
data = base64.b64decode(await client.capture_screenshot(c))
return {**capture(data, fmt="png", **(detail or {})), "source": "bidi"}
if action == "evaluate":
c = await _ctx()
return {"success": True, "source": "bidi", "result": _val(await client.script_evaluate(c, code or ""))}
@@ -464,6 +448,7 @@ async def _extension_command(
browser: Optional[str] = None,
tab_id: Optional[Union[str, int]] = None,
client_id: Optional[str] = None,
detail: Optional[dict] = None,
**kwargs,
) -> Optional[dict]:
"""Route a browser command to a provider via the local zapd router.
@@ -487,8 +472,9 @@ async def _extension_command(
if not provider:
return {"error": "no browser provider connected over zapd", "transport": "native-zap"}
detail = detail or {}
method = _zap_method_for(action)
params = _zap_params(action, tab_id=tab_id, **kwargs) or {}
params = _zap_params(action, tab_id=tab_id, **detail, **kwargs) or {}
str_params = {k: (v if isinstance(v, str) else str(v)) for k, v in params.items() if v is not None}
try:
raw = await asyncio.to_thread(consumer.route, provider, method, str_params)
@@ -496,21 +482,20 @@ async def _extension_command(
return {"error": str(e), "transport": "native-zap"}
text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw
# A screenshot/pdf comes back as (JSON-wrapped) base64. Inlining a full-page
# PNG (100K+ chars) as text overflows the agent's context and wedges the whole
# run — the #1 cause of "the browser tool hangs". One way for images: return a
# ToolImage → the register() converter turns it into native MCP ImageContent the
# client SEES (no base64-in-text). A PDF isn't an image → persist to disk, path.
if action in ("screenshot", "pdf") and isinstance(text, str) and len(text) > 20000:
# A screenshot/pdf comes back as (JSON-wrapped) base64. Base64 in the JSON text
# is charged to the agent's context by the character, so a capture never travels
# that way: capture() writes the bytes to a file and returns a ToolImage that
# register() turns into a native MCP ImageContent block the client SEES.
if action in ("screenshot", "pdf") and isinstance(text, str):
b64 = _extract_b64(text)
if b64:
try:
data = base64.b64decode(b64)
meta = {"transport": "native-zap", "source": "zapd", "provider": provider}
if action == "pdf":
return {**_save_capture(data, "pdf", kwargs.get("path")), **meta}
return {"success": True, "format": "png", "size": len(data),
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"), **meta}
# Trust the bytes, not the provider's label — the extension picks
# the encoding and older builds report it inconsistently.
fmt = "pdf" if action == "pdf" else ("jpeg" if data[:3] == b"\xff\xd8\xff" else "png")
return {**capture(data, fmt=fmt, path=kwargs.get("path"), **detail), **meta}
except Exception as e: # fall back to raw on any decode failure
logger.warning(f"native-zap capture decode failed ({e}); returning raw")
return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "result": text}
@@ -1041,6 +1026,10 @@ DISPLAY INSTRUCTIONS: Show results as bullet points.
• expect_*: ✓ Assertion passed / ✗ Assertion failed
• screenshot: Captured [size] bytes
SCREENSHOTS: downscaled JPEG (~1280px, q70) by default to save context; the
full-resolution capture is saved to a file whose path is returned — pass
full_res=true (or max_width/quality) only when you need pixel detail.
PARALLEL AGENTS:
- Use `new_context` for isolated sessions
- One Chrome, many parallel agent contexts
@@ -1149,6 +1138,11 @@ CATEGORIES:
# Options
timeout: Optional[int] = None,
full_page: bool = False,
# Capture detail
path: Optional[str] = None,
max_width: int = 1280,
quality: int = 70,
full_res: bool = False,
tab_index: Optional[int] = None,
tab_id: Optional[Union[str, int]] = None,
client_id: Optional[str] = None,
@@ -1178,6 +1172,9 @@ CATEGORIES:
"""
timeout = timeout or self.timeout
sel = selector or ref
# How much pixel detail comes back inline. Travels together to every
# capture site so all three backends answer a screenshot the same way.
detail = {"max_width": max_width, "quality": quality, "full_res": full_res}
# === LOCAL ACTIONS (answered from the zapd provider list) ===
if action in ("list_mcp_instances", "list_browsers", "browsers"):
@@ -1212,7 +1209,7 @@ CATEGORIES:
bidi_target = target_browser or (self.backend if self.backend == "firefox" else None)
if bidi_target == "firefox":
bidi_res = await _bidi_dispatch(
action, url=url, selector=sel, text=text, code=code, key=key
action, url=url, selector=sel, text=text, code=code, key=key, detail=detail
)
if bidi_res is not None:
return bidi_res
@@ -1264,6 +1261,8 @@ CATEGORIES:
browser=browser_filter,
tab_id=tab_id,
client_id=client_id,
detail=detail,
path=path,
url=url,
selector=sel,
text=text,
@@ -1928,17 +1927,10 @@ CATEGORIES:
data = await loc.screenshot(**opts)
else:
data = await page.screenshot(**opts)
return {
"success": True,
"format": "png",
"size": len(data),
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"),
}
return capture(data, fmt="png", path=path, **detail)
elif action == "pdf":
data = await page.pdf()
out = _save_capture(data, "pdf", locals().get("path"))
return out
return capture(await page.pdf(), fmt="pdf", path=path)
elif action == "snapshot":
return {
@@ -2456,6 +2448,31 @@ CATEGORIES:
full_page: Annotated[
bool, Field(description="Full page screenshot")
] = False,
path: Annotated[
Optional[str],
Field(description="Where to write the screenshot/pdf capture"),
] = None,
max_width: Annotated[
int,
Field(
description=(
"Longest edge of the inline screenshot, in pixels. 0 "
"inlines the capture at native resolution."
)
),
] = 1280,
quality: Annotated[
int, Field(description="Inline JPEG quality, 1-95.")
] = 70,
full_res: Annotated[
bool,
Field(
description=(
"Inline the native-resolution PNG. Costs 15-25x the "
"context; ask for it only when the pixels are the question."
)
),
] = False,
tab_index: Annotated[Optional[int], Field(description="Tab index in current window")] = None,
tab_id: Annotated[
Optional[str],
@@ -2559,6 +2576,10 @@ CATEGORIES:
not_=not_,
timeout=timeout,
full_page=full_page,
path=path,
max_width=max_width,
quality=quality,
full_res=full_res,
tab_index=tab_index,
tab_id=tab_id,
client_id=client_id,
@@ -13,15 +13,21 @@ Use ``cdp`` when you need a CDP method the high-level surface doesn't expose.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any, Union, Optional, Annotated
from pydantic import Field
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool
from hanzo_tools.core import BaseTool, capture
from hanzo_tools.core.unified import _result_to_mcp
from hanzo_tools.browser.browser_tool import _extract_b64
from hanzo_tools.browser.zapd_consumer import get_consumer
logger = logging.getLogger(__name__)
# Sugared actions → the bare CDP method they map to.
_SUGARED: dict[str, str] = {
@@ -69,7 +75,20 @@ async def _route_cdp(
return {"error": str(e), "transport": "native-zap", "method": method}
text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw
return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "method": method, "result": text}
meta = {"transport": "native-zap", "source": "zapd", "provider": provider, "method": method}
# Raw dispatch, but a capture is still a capture: it goes to a file and comes
# back as pixels, never as base64 in the JSON text. Same door as every other
# tool. Pass CDP's own format/quality (and maxWidth) in params to steer it.
if method.endswith("captureScreenshot") and isinstance(text, str):
b64 = _extract_b64(text)
if b64:
try:
data = base64.b64decode(b64)
fmt = "jpeg" if data[:3] == b"\xff\xd8\xff" else "png"
return {**capture(data, fmt=fmt), **meta}
except Exception as e:
logger.warning(f"cdp capture decode failed ({e}); returning raw")
return {"success": True, **meta, "result": text}
async def _list_browsers() -> dict[str, Any]:
@@ -108,6 +127,10 @@ ACTIONS:
- status : Browser.getVersion — connection + version
- list_browsers : list extension providers (firefox/chrome/safari/edge) connected
Page.captureScreenshot answers with a downscaled JPEG (~1280px, q70) by default
to save context; the full-resolution capture is saved to a file whose path is
returned. Pass params={"format":"png","maxWidth":0} for pixel detail.
EXAMPLES:
- cdp(action="send", method="Page.navigate", params={"url": "https://example.com"})
- cdp(action="send", method="Runtime.evaluate", params={"expression": "document.title"})
@@ -182,7 +205,7 @@ Use `browser` for high-level verbs (navigate, click, screenshot).
Optional[float],
Field(description="Per-call timeout (seconds)"),
] = None,
) -> str:
) -> Any:
result = await tool_instance.execute(
action=action,
method=method,
@@ -192,4 +215,4 @@ Use `browser` for high-level verbs (navigate, click, screenshot).
client_id=client_id,
timeout=timeout,
)
return json.dumps(result, indent=2, default=str)
return _result_to_mcp(result)
@@ -58,4 +58,9 @@ Same action surface as `browser` but forces backend="playwright":
Use `browser` for auto-routing (extension > CDP-bridge > Playwright).
Use `cdp` for raw Chrome DevTools Protocol method dispatch.
Use `playwright` for deterministic headless automation.
SCREENSHOTS: downscaled JPEG (~1280px, q70) by default to save context; the
full-resolution capture is saved to a file whose path is returned — pass
full_res=true (or max_width/quality) only when you need pixel detail.
""" + BrowserTool.description.fget(self).split("CATEGORIES:", 1)[-1].rstrip()
+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.5.10"
version = "0.5.11"
description = "Browser automation tools — zapd consumer. The MCP hosts no server; it routes CDP commands to the browser extension through the shared local zapd router (~/.zap/run/zapd.sock). No in-process server, no HTTP bridge, no mDNS, no port pool."
readme = "README.md"
requires-python = ">=3.12"
@@ -12,8 +12,10 @@ license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "browser", "playwright", "mcp", "ai", "automation", "extension"]
dependencies = [
"hanzo-tools>=0.3.0",
"hanzo-tools>=0.3.6", # 0.3.6 carries capture(): downscaled inline, full-res on disk
"mcp>=1.25.0",
# capture() downscales with PIL; without it a screenshot is a path and no pixels.
"pillow>=10.0.0",
"pydantic>=2.12.5",
# Canonical ZAP router envelope (zap.frame + ZapClient). The zapd wire is
# imported from here, never reimplemented in this package.
@@ -12,13 +12,11 @@ Performance:
- Batch operations with minimal overhead
"""
import io
import os
import re
import sys
import json
import time
import base64
import shutil
import asyncio
import tempfile
@@ -31,7 +29,8 @@ from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, PermissionManager, auto_timeout
from hanzo_tools.core import BaseTool, PermissionManager, auto_timeout, capture
from hanzo_tools.core.unified import _result_to_mcp
# Platform detection
PLATFORM = sys.platform
@@ -1078,6 +1077,10 @@ SCREEN (< 50ms native):
- get_screens(): List displays
- screen_size() / current_screen()
Screenshots are downscaled JPEG (~1280px, q70) by default to save context; the
full-resolution capture is saved to a file whose path is returned — pass
full_res=true (or max_width/quality) only when you need pixel detail.
IMAGE LOCATION:
- locate(image_path): Find image, return center
- locate_all(image_path): Find all matches
@@ -1157,12 +1160,16 @@ Examples:
name: str | None = None,
width: int | None = None,
height: int | None = None,
# Screenshot detail
max_width: int = 1280,
quality: int = 70,
full_res: bool = False,
# Settings
value: float | bool | None = None,
# Batch
actions: list[dict[str, Any]] | None = None,
**kwargs,
) -> str:
) -> Any:
"""Execute computer control action."""
loop = asyncio.get_event_loop()
@@ -1281,37 +1288,16 @@ Examples:
# Screen actions
elif action == "screenshot" or action == "screenshot_region":
data = await run(NativeControl.screenshot_native, region)
# If name is provided, save to file instead of returning base64
if name:
# Expand ~ and make absolute path
file_path = os.path.expanduser(name)
if not os.path.isabs(file_path):
file_path = os.path.join(tempfile.gettempdir(), name)
if not file_path.endswith(".png"):
file_path += ".png"
with open(file_path, "wb") as f:
f.write(data)
return json.dumps(
{
"success": True,
"format": "png",
"size": len(data),
"path": file_path,
}
return _result_to_mcp(
capture(
data,
fmt="png",
alt="screen",
path=self._destination(name),
max_width=max_width,
quality=quality,
full_res=full_res,
)
# Otherwise return base64 (large output warning)
b64 = base64.b64encode(data).decode()
return json.dumps(
{
"success": True,
"format": "png",
"size": len(data),
"base64": b64,
}
)
# Image location (uses pyautogui)
@@ -1416,9 +1402,15 @@ Examples:
return json.dumps({"error": f"Region '{name}' not defined"})
reg = list(self._defined_regions[name])
data = await run(NativeControl.screenshot_native, reg)
b64 = base64.b64encode(data).decode()
return json.dumps(
{"success": True, "format": "png", "size": len(data), "base64": b64}
return _result_to_mcp(
capture(
data,
fmt="png",
alt=f"region {name}",
max_width=max_width,
quality=quality,
full_res=full_res,
)
)
elif action == "region_locate":
@@ -1545,6 +1537,16 @@ Examples:
except Exception as e:
return json.dumps({"error": str(e)})
@staticmethod
def _destination(name: str | None) -> str | None:
"""Resolve a caller-supplied screenshot name to an absolute .png path."""
if not name:
return None
p = os.path.expanduser(name)
if not os.path.isabs(p):
p = os.path.join(tempfile.gettempdir(), p)
return p if p.endswith(".png") else p + ".png"
# Helper methods for pyautogui-only features
def _locate(self, image_path: str, confidence: float, region: tuple | None) -> str:
@@ -1760,12 +1762,33 @@ Examples:
name: Annotated[str | None, Field(description="Region name")] = None,
width: Annotated[int | None, Field(description="Width")] = None,
height: Annotated[int | None, Field(description="Height")] = None,
max_width: Annotated[
int,
Field(
description=(
"Longest edge of the inline screenshot, in pixels. 0 "
"inlines the capture at native resolution."
)
),
] = 1280,
quality: Annotated[
int, Field(description="Inline JPEG quality, 1-95.")
] = 70,
full_res: Annotated[
bool,
Field(
description=(
"Inline the native-resolution PNG. Costs 15-25x the "
"context; ask for it only when the pixels are the question."
)
),
] = False,
value: Annotated[float | None, Field(description="Value")] = None,
actions: Annotated[
list[dict] | None, Field(description="Batch actions")
] = None,
ctx: MCPContext = None,
) -> str:
) -> Any:
return await tool_self.call(
ctx,
action=action,
@@ -1794,6 +1817,9 @@ Examples:
name=name,
width=width,
height=height,
max_width=max_width,
quality=quality,
full_res=full_res,
value=value,
actions=actions,
)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hanzo-tools-computer"
version = "0.5.4"
version = "0.5.5"
description = "Computer control tools for Hanzo AI - automation, screen recording, activity detection, session compression for Claude (100 images/32MB/2000px limits)"
readme = "README.md"
license = "MIT"
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"hanzo-tools>=0.3.0",
"hanzo-tools>=0.3.6", # 0.3.6 carries capture(): downscaled inline, full-res on disk
"pyautogui>=0.9.54",
"pillow>=10.0.0",
"numpy>=1.26.0",
@@ -25,6 +25,7 @@ from hanzo_tools.core.unified import (
ConflictError,
NotFoundError,
InvalidParamsError,
capture,
file_uri,
content_hash,
)
@@ -56,6 +57,7 @@ __all__ = [
"InvalidParamsError",
"Paging",
"Range",
"capture",
"content_hash",
"file_uri",
# Identity tool
@@ -84,6 +84,87 @@ class ToolImage:
return p
#: Formats ``capture`` can downscale. Anything else (pdf) is saved, not inlined.
_MIME = {"png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg", "webp": "image/webp"}
def capture(
raw: bytes,
*,
fmt: str = "png",
alt: str = "screenshot",
path: str | None = None,
max_width: int = 1280,
quality: int = 70,
full_res: bool = False,
) -> dict[str, Any]:
"""Persist a capture, hand back a view of it that fits in a context window.
A 4480x1440 screen is ~740K base64 chars as a full-res PNG — more than the
whole budget an agent has to think with, so inlining one wedges the run.
This is the ONE way a tool returns a capture:
* the native bytes are always written to disk and their ``path`` returned,
so pixel detail is one file read away;
* the inline ``ToolImage`` is downscaled into a ``max_width`` box and
re-encoded JPEG at ``quality`` — 15-25x smaller, and indistinguishable
from the original for reading a UI.
``full_res=True`` (or ``max_width<=0``) inlines the native capture instead;
ask for it only when the pixels themselves are the question. A format with
no raster (pdf) is saved and returned as a path with no image at all.
"""
import uuid
from pathlib import Path
if path:
p = Path(path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
else:
d = Path.home() / ".hanzo" / "screenshots"
d.mkdir(parents=True, exist_ok=True)
p = d / f"capture-{uuid.uuid4().hex[:12]}.{fmt}"
out: dict[str, Any] = {"success": True, "format": fmt, "size": len(raw)}
try:
p.write_bytes(raw)
out["path"] = str(p)
except OSError as e: # a full disk must not cost the caller its screenshot
out["note"] = f"not saved: {e}"
mime = _MIME.get(fmt.lower())
if mime is None:
return out
if full_res or max_width <= 0:
out["image"] = ToolImage.from_bytes(raw, mime, alt)
return out
try:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(raw))
out["width"], out["height"] = img.size
img.thumbnail((max_width, max_width))
buf = BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
small = buf.getvalue()
out["preview"] = {
"format": "jpeg",
"width": img.width,
"height": img.height,
"size": len(small),
"quality": quality,
}
out["image"] = ToolImage.from_bytes(small, "image/jpeg", alt)
except Exception as e:
# Inlining the native bytes here would be the very blowup this exists to
# prevent, so the path is the answer and the reason is on the record.
out["note"] = f"not downscaled ({e}); read the full-res capture at path"
return out
@dataclass
class ToolError(Exception):
"""Structured error for tool operations."""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools"
version = "0.3.5"
version = "0.3.6"
description = "Hanzo AI tools - core infrastructure and tool bundles"
readme = "README.md"
requires-python = ">=3.12"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for hanzo_tools.core.capture — the one way a tool returns a screenshot."""
import base64
from io import BytesIO
import pytest
from hanzo_tools.core import ToolImage, capture
from hanzo_tools.core.unified import _result_to_mcp
PIL = pytest.importorskip("PIL.Image")
#: The size that started this: a 4480x1440 desktop is ~740K base64 chars as PNG.
SCREEN = (4480, 1440)
def png(size=SCREEN) -> bytes:
"""A noisy image — a flat fill would compress away and prove nothing."""
img = PIL.new("RGB", size)
img.putdata([((x * 7) % 256, (x * 13) % 256, (x * 29) % 256) for x in range(size[0] * size[1])])
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
@pytest.fixture(scope="module")
def raw():
return png()
def test_inline_is_a_bounded_jpeg_and_the_native_capture_is_on_disk(raw, tmp_path):
out = capture(raw, fmt="png", path=str(tmp_path / "shot.png"))
img = out["image"]
assert isinstance(img, ToolImage) and img.mime_type == "image/jpeg"
assert len(base64.b64decode(img.data)) < 150_000
assert out["preview"]["width"] == 1280
assert (out["width"], out["height"]) == SCREEN
# The full-resolution capture is a file read away, byte for byte.
assert (tmp_path / "shot.png").read_bytes() == raw
assert out["path"] == str(tmp_path / "shot.png")
def test_full_res_inlines_the_native_capture(raw):
out = capture(raw, fmt="png", full_res=True)
assert out["image"].mime_type == "image/png"
assert base64.b64decode(out["image"].data) == raw
# max_width=0 is the same door.
assert base64.b64decode(capture(raw, fmt="png", max_width=0)["image"].data) == raw
def test_max_width_and_quality_are_the_dials(raw):
small = capture(raw, fmt="png", max_width=320, quality=40)
assert small["preview"]["width"] == 320
assert len(base64.b64decode(small["image"].data)) < len(
base64.b64decode(capture(raw, fmt="png")["image"].data)
)
def test_a_capture_smaller_than_the_box_keeps_its_size(raw):
out = capture(png((400, 300)), fmt="png")
assert (out["preview"]["width"], out["preview"]["height"]) == (400, 300)
def test_a_pdf_is_saved_never_inlined(tmp_path):
out = capture(b"%PDF-1.4\n", fmt="pdf", path=str(tmp_path / "doc.pdf"))
assert "image" not in out
assert (tmp_path / "doc.pdf").read_bytes() == b"%PDF-1.4\n"
def test_the_wire_carries_pixels_not_base64_text(raw):
blocks = _result_to_mcp(capture(raw, fmt="png"))
kinds = [b.type for b in blocks]
assert kinds == ["text", "image"]
assert blocks[1].mimeType == "image/jpeg"
# The JSON text an agent reads stays a few hundred characters.
assert len(blocks[0].text) < 1000
assert "base64" not in blocks[0].text