Hermes image-utils plugin
AI-generated code — The code in this repository was written with AI assistance, then reviewed and tested before publication.
Local image-file tools for Hermes Agent: inspect, resize, crop, rotate, convert and re-encode photos
without a shell. Pillow (12.3.0, already in the Hermes venv) runs in-process — no subprocess, no
ImageMagick, no network, no new dependencies.
Toolset image_utils, six tools, no config and no secrets. The authoritative tool list is
provides_tools in plugin.yaml; the test suite pins it against the schemas, the handlers, and the
tables below.
Prerequisite — Pillow in the Hermes venv. 12.3.0 is the tested version. Pillow is a hermes-agent
dependency rather than something this plugin installs, so it is deliberately not declared in
plugin.yaml (no python_dependencies key). Without it, image_info still answers and reports
pillow_available: false, while the other five tools refuse with a how_to_fix.
What it gives the agent
| Tool | Type | Notes |
|---|---|---|
image_info |
read | dimensions, format, mode, frames, DPI, ICC present?, EXIF summary (camera, datetime, orientation, GPS presence only — never coordinates), file bytes, animation, and whether the pixels actually decode. Always visible, even when Pillow is missing — it is the diagnostic tool. |
image_resize |
write | width / height / percent (exactly one; both width+height needs allow_distort=true), aspect preserved, Lanczos, never upscales unless allow_upscale=true. A JPEG downscale calls draft() before decoding, so the JPEG is decoded at the reduced scale; the target size is capped like an input (50 MP). Animated input keeps every frame. |
image_crop |
write | box=[left, top, right, bottom] (validated against the image bounds — out-of-range is refused, Pillow would silently pad black), or aspect="16:9" / "1:1" (largest centred rectangle), or centred width/height. Animated input keeps every frame. |
image_rotate |
write | 90/180/270 by exact transpose(); other angles expand the canvas (bicubic, background-coloured corners, transparent for alpha; the fill is shaped per mode and a palette-transparent P image is rotated as RGBA so its transparency survives). auto_orient=true (default) applies the EXIF orientation first, and the written file gets the orientation tag cleared (written as 1) where the target can carry EXIF — GIF/BMP say so in notes instead. Omit angle to only fix orientation. Animated input keeps every frame. |
image_convert |
write | PNG/JPEG/WebP/TIFF/GIF/AVIF/BMP. Alpha → JPEG/BMP/GIF requires flatten=true (composited onto background, default white) instead of silently dropping transparency. Animated inputs write all frames where the target supports it (GIF/WebP/AVIF/TIFF/PNG), else the first frame alone: frames_dropped reports the rest and frame_durations describes only the frames that were written, because the others are never decoded. |
image_optimize |
write | re-encode in the input's own format: quality, effort (0–9, mapped per format and reported), optional max_dimension (Lanczos, never upscales), strip_metadata (default false and stated in the response). Animated input keeps every frame, including an in-place re-encode. A max_dimension JPEG downscale calls draft() before decoding, like image_resize. Every response states the size outcome: bytes saved, nothing saved, or larger than the input. |
Every write returns {input, output, before, after, replaced, frames_written, frames_dropped, notes}; before comes from the header stage and after is read back from the file on disk, so
the envelope describes the bytes that actually landed. Every refusal returns
{"error": ..., "how_to_fix": ...}.
Safety behaviour that matters
- No shell, ever. Pillow in-process; the test suite fails if
subprocess,os.system,Popen,shutil.whichoros.exec*ever appear in the plugin's source. - Writes never overwrite by default. The output goes to
output_path, or beside the input as<stem>_<operation>.<ext>. If that resolved path already exists — or is the input — the call is refused unlessoverwrite=trueandconfirm=true, and the response then names what was replaced.confirm=trueis supplied by the model, not by a human: it is deliberate friction, not an approval gate (apre_tool_callallowlist is deferred to v2, see below). - Writes are atomic and verified. The encoder writes a hidden temp file in the target's
directory,
fsyncs, checks the output cap, then re-reads the encoded file (dimensions, frame count, decodability, and — forstrip_metadata— that the metadata really is gone) before publishing. Publishing is exclusive (os.link, with anO_CREAT|O_EXCLfallback): if the target appeared while the encoder worked, the call is refused instead of clobbering it. Replacing a file on purpose needsoverwrite=trueandconfirm=true. Any failure deletes the temp file; a test asserts no*.tmpsurvives. The replaced file's mode bits are preserved (a 0644 photo stays 0644); new files are 0o644. - Fail closed. Missing / empty / non-regular / non-image / truncated inputs, an unsupported
target format, a bad
width/height(≤ 0), an out-of-range crop box, or an output directory that does not exist are all refused with ahow_to_fixand nothing written. The plugin never creates directories. - Caps, and where they are enforced. ≤ 50 MB file, ≤ 50 MP per frame, ≤ 100 MP summed over
frames. Checks run in a fixed order:
lstat(regular file, size) →Image.open()inside a warnings-as-errors guard forImage.DecompressionBombWarningand an explicit catch ofDecompressionBombError(which is not anOSError) → header-dims check against the plugin caps →im.load(). Pillow's ownMAX_IMAGE_PIXELS(89,478,485) is never assigned, disabled, or ignored, andImageFile.LOAD_TRUNCATED_IMAGESis never set. The guard serialises overlapping calls with a process-wide lock, becausewarnings.catch_warningsmutates process-global state and Hermes runs tool calls in worker threads. A 200 MP header-only PNG and a 96–178 MP one (the band where Pillow only warns) are both refused with ahow_to_fix— never a traceback, never a warning handed back to the model. - Only the frames that are written are decoded. A still target reads the first frame and stops; the rest are neither decoded nor validated, which is what turns a 60-frame animation into a ~26 ms conversion instead of ~200 ms. A damaged frame after the first is therefore not detected, and the response says so ("the dropped frames were neither decoded nor validated"). An animated target still walks every frame, so it still refuses a truncated file.
- Header-stage checks run before the pixel decode. The output path is resolved, gated and
max_dimensionvalidated before the pixels are read, so a corrupt file whose output path is already taken is reported as the path collision first; reading the pixels is what reports the corruption. - Memory. A decoded image costs ≈ width × height × channels bytes (a 50 MP RGB image ≈ 150 MB), and a resize holds the source plus the result — budget ~3× the decoded size transiently. The caps are sized against that budget, not against the file size on disk: 100 MP summed over frames is ~300 MB decoded and ~900 MB transient, which is what this process can afford. That is why the per-frame cap is 50 MP.
- Metadata honesty.
image_rotateclears the EXIF orientation tag (it changes pixel orientation); resize/crop/convert/optimize preserve EXIF/ICC/DPI, except where the target format cannot carry a block — then the response'snoteslist what was dropped (GIF and BMP drop EXIF+ICC; WebP and AVIF cannot carry DPI). A TIFF source is special: Pillow's reader applies the orientation to the pixels and consumes the tag at load, so the output carries no stale orientation and the note says so. A TIFF's structural IFD (strip/tile offsets, bit depths, colour maps, …) is never round-tripped onto a re-encoded image — only user-level tags (camera, datetime, orientation, GPS presence, exposure) are carried. Metadata is attached by presence, never asNone(that crashes the JPEG/TIFF/WebP/BMP savers).strip_metadata=truereally removes ICC/EXIF/DPI, and the encoded file is re-read to prove it before it is published. - HEIC/HEIF is out of scope.
pillow_heif 1.5.0exists in the venv (a hermes-agent dependency) but Pillow does not register it and this plugin deliberately does not either — HEIC is refused with the detected state named, and nopip installis ever suggested.image_convertresolves the target from its own allow-list, so a.heicoutput path cannot reach Pillow's extension guessing.
Install / configure
./install.sh # every profile under the Hermes root, staged (non-default first)
./install.sh <profile> # one profile
The script symlinks this checkout into <profile-home>/plugins/image-utils, runs
hermes -p <profile> plugins enable image-utils (which writes plugins.enabled plus the
allow_tool_override grant), verifies exposure through the same call the agent uses
(get_tool_definitions(..., skip_tool_search_assembly=True)), and restarts that profile's gateway.
No platform_toolsets entry is needed in Hermes 0.21.3: plugin toolsets are enabled by default on
every platform (_enabled_plugin_toolsets), proven live under a temp HERMES_HOME — add an entry
only to pin one platform's list.
install.sh is a POSIX shell script, which covers Linux and macOS. Windows has no sh, and ln -s
there needs developer mode, so install by hand: copy this directory to
<profile-home>/plugins/image-utils, then run hermes -p <profile> plugins enable image-utils. The
test suite runs on Linux, macOS and Windows (.github/workflows/tests.yml).
No configuration keys and no secrets. Plugins are per-profile ($HERMES_HOME/plugins/) and
opt-in (plugins.enabled), so a directory alone loads nothing; the symlink keeps this checkout the
single source of truth.
Where to look when something is off
image_infofrom a chat — it reportspillow_availableand answers even for damaged or over-limit files, so it is the first diagnostic.- Tools missing entirely →
image-utilsis not in that profile'splugins.enabled, or the checkout is not symlinked into that profile'splugins/.hermes -p <profile> plugins enable image-utilsfixes both. - A tool the docs mention is missing but nothing errors → plugin tools are listed per process when that process loads the plugin; a long-running process that started before the change keeps the old list. Restart the process that shows the stale list (the desktop app needs its own restart).
hermes plugins doctor .(from this checkout) proves import + registration;hermes plugins validate .is the strict gate (manifest fields, declared-vs-registered tools, security scan). Neither one can see the no-shell rule, the cross-toolset naming rule, or README/SKILL drift — the test suite covers those.- Rotation looks doubled → the file carries an orientation tag its pixels no longer match, so
whatever wrote it did not clear the tag.
image_rotatewrites orientation as 1 andimage_inforeports the tag it finds. - A write refused with "output already exists" → the default name is a function of (input,
operation), so re-runs collide. Pass
output_path, oroverwrite=truewithconfirm=true.
Tests
cd path/to/this/checkout
# offline
uv run --python 3.11 --with pytest --with 'pillow==12.3.0' --with pyyaml python -m pytest tests/ -q
Never pip install anything into the Hermes venv (its pins are load-bearing); uv run builds a
throwaway env from the uv cache. Offline tests generate all fixtures with Pillow itself (including
EXIF orientation via Image.Exif() — no piexif) and assert behaviour: the JSON envelopes, the bytes
on disk, mode-bit preservation, and that no temp file survives any failure path.
The fixture set covers what the format matrix can carry: a JPEG with EXIF/GPS/DPI/ICC, a PNG with alpha, a header-only bomb-shaped PNG, an animated GIF and a multi-page TIFF. Checks against real camera files are manual and are not part of the suite.
Rollback
rm <profile-home>/plugins/image-utils
hermes -p <profile> plugins disable image-utils
hermes -p <profile> gateway restart
No state outside the files it writes; the plugin keeps no cache, no database and no config.
Decisions and known limits
-
The caps are per frame and summed: 50 MP per frame, 100 MP over all frames of one input. Every refusal message names the number it tripped, so the limit is never guessed from a generic error.
-
Publishing is exclusive, not a check-then-replace:
os.link(with anO_CREAT|O_EXCLfallback) fails if a target appeared while the encoder worked, so two concurrent writers cannot both report success with one file. The window is closed, not narrowed — onlyoverwrite=truewithconfirm=truereplaces an existing file. -
confirm=trueis a model-supplied parameter, not a human approval: it is friction the model has to spend deliberately, and the response names the file it replaced. Apre_tool_callhook that blocks in-place overwrites outside an allow-listed directory tree is the v2 candidate; it is not implemented here. -
HEIC/HEIF read/write is out of scope;
image_convertlists the seven supported targets. -
GIF frame surgery, drawing/text/watermarks, batch runs and colour correction are out of scope.
-
image_resizere-encodes lossy targets at quality 95 unless the format is lossless; the note in the response says which quality was applied. -
Encoder defaults are picked for time, not inherited from Pillow. With no
effort, WebP is written atmethod=2(Pillow's saver default is 4) and AVIF atspeed=8(Pillow's default is 6), and thenotesname the setting used.effort=9maps to WebPmethod=5, not 6: the last step is the slowest of the range for the smallest return. To get the old behaviour back:effort=4selects AVIFspeed=6,effort=6selects WebPmethod=4. These defaults apply to every write tool, not onlyimage_convertandimage_optimize:image_resize,image_cropandimage_rotatetake noeffortparameter, so they always use them. -
What the defaults cost and buy, measured (median of 3, interleaved before/after, a fresh process per run, one machine) on non-noise 12 MP fixtures — a smooth photo-like image and a flat block pattern:
case before after speed output bytes WebP, photo-like 556 ms 282 ms 1.97x +9.9% WebP, flat pattern 537 ms 317 ms 1.69x +17.6% AVIF, photo-like 412 ms 237 ms 1.74x +2.2% AVIF, flat pattern 715 ms 293 ms 2.44x +271% max_dimension=1600, 12 MP JPEG144 ms 64 ms 2.24x +0.02% 60-frame GIF -> JPEG, photo-like 200 ms 26 ms 7.7x identical 60-frame GIF -> JPEG, screen-capture-like 99 ms 25 ms 4.0x identical 2-frame GIF -> JPEG 28 ms 25 ms 1.1x identical Read the byte column as a real cost, not a rounding error. A fast AVIF speed is worst exactly where the input compresses well: a flat 44 KB PNG became a 46 KB AVIF above, and the response warns whenever the output is larger than the input. The frame-walk saving scales with frame count and per-frame entropy — a 2-frame GIF gains almost nothing while a 60-frame one gains 4-8x — and its output is byte-identical in every case measured. Pass
effortwhen bytes matter more than time.