One Failed TTS Call Deleted a User File: Inside Hermes' P0 Output-Path Fix

You ask Hermes to turn a meeting summary into audio, and you add one line to the prompt: save it as meeting-notes.mp3. That filename happens to be the recording of a client call you made this morning. A few seconds later the tool comes back with an error — the TTS provider was unavailable. You open the file. It isn’t corrupted. It isn’t empty. It’s gone.
That was a real P0 in Hermes: a failed text-to-speech call deleted the existing file that output_path pointed at. It was fixed on October 9 (PR #135531 — five lines of code plus one test file). Here is the mechanism, the fix, and what it means for how you use the tool.
Why a failure could take your file with it
Start with how the text_to_speech tool cleans up after itself.
It supports long-form synthesis by splitting text into chunks, so every call keeps a set called generated_artifacts: every path this call touched gets recorded there. When the call is over — success or failure — a finally block sweeps the set and _remove_quietly()s anything that was not actually delivered. That design is entirely reasonable. If chunk 3 of 5 fails, the half-finished .chunk001.mp3 files should not be left on disk.
The problem was in the recording step. Before the fix, the code looked like this:
chunk_path = base_path # the path output_path pointed at
if len(chunks) > 1:
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
generated_artifacts.add(str(chunk_path)) # ← the user's own file was registered as "my temp output"
output_path is a tool argument the model can choose. When it pointed at a file that already existed, the old code registered that file as something this call had produced. From there:
- synthesis succeeds → the delivery step
os.replace()s the new audio over the target (the expected overwrite); - synthesis fails → the
finallysweep claims the path and removes it.
The failure branch is where the P0 lived: the user’s file never took part in the synthesis, but the cleanup logic counted it as leftover output. The bug surfaced during a catalog review of the wyoming-voice-bridge plugin.
The fix: write next door, move in only on success
The new _synthesize_chunks checks for an existing file before it writes:
if os.path.lexists(chunk_path):
# A file we didn't create: synthesize beside it so a failed attempt can neither truncate
# nor sweep it; on success the delivery step os.replace()s the audio over it (overwrite).
chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")
When the target already exists, synthesis goes to a hidden sibling — .meeting-notes.9f3c….mp3. Both outcomes are now clean:
- Failure: the sweep only removes the sibling. Your file is never opened, truncated, or unlinked.
- Success: the delivery step still
os.replace()s the audio onto the target, so the overwrite contract is unchanged.
New paths — including the default timestamped cache path — take exactly the code path they took before, and a failed attempt’s partial output is still cleaned up.
Worth knowing: the tool already had a set of path gates, but they guard where you may write, not what you may overwrite:
- a
..traversal component is rejected (absolute paths are fine; relative paths may not climb); - control characters or line separators in the path are rejected;
- a
MEDIA:directive inside the path is rejected, so a forged delivery tag can’t travel downstream; - protected credential/system locations (
file_safety’sis_write_denied/is_write_approval_required) are rejected.
Omit output_path and the tool writes to <audio cache>/tts_<timestamp>.<ext> — a timestamped name that is very unlikely to collide with something you own. That is also why most people never met this bug: hitting it requires pointing the tool at a file that already exists.
If it already happened to you
Bluntly: an unlinked file does not go to a trash can, and without a backup it is gone. But a few habits shrink the blast radius:
- Checkpoints. Hermes’ checkpoint system can roll back file changes inside a session; the
hermes checkpointscommands let you inspect and restore them. It is built for agent-made edits, so it only helps if the touched file lives in a tracked workspace. - Session exports. The text that was synthesized usually still exists in the transcript, so the audio can be regenerated. A recording cannot — that distinction is the whole risk.
- Keep irreplaceable files out of tool-facing directories. We wrote up a similar class of accidental file loss in the dirty-worktree data-loss incident.
- For the full checkpoint surface, see the hermes checkpoints reference.
How to use output_path safely
One rule covers it: never point output_path at a file you can’t afford to lose.
- Want a spoken version of some text? Don’t pass
output_pathat all — take the default cache path. - Archiving audio? Use a fresh filename (
summaries/2026-10-10-daily.mp3), not an existing one. - Deliberately overwriting a fixed output file is now safe by contract (success overwrites, failure preserves), but don’t keep the only copy of anything next to it.
When you can get it
The fix merged on 2026-10-09, after v0.21.6 shipped on October 8, so it is on origin/main only and not in any tagged release yet. hermes update will pick it up from main, or you can wait for the next release. Release status and the broader stability work are summarized on our v0.21.6 release page.
The regression test landed with it (tests/tools/test_tts_failure_preserves_existing.py), pinning the contract in three assertions: an existing file survives a provider failure with no partial left behind, a failure on a new path leaves nothing behind, and a success still overwrites. The lesson generalizes to anyone writing tools: cleanup code needs to be certain about what it owns. Registering someone else’s file as your own temp output costs the user their recording.