mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(tools): a screenshot arrives downscaled, with the full capture on disk
This commit is contained in:
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user