All plugins

tool-call-salvage

Removes tool-call markup that a model wrote into its final reply as text instead of calling the tool (Qwen <function=...>, <invoke>, DeepSeek DSML and tool tokens, Harmony, Kimi, Mistral and Llama formats). Only complete markup that ends the reply is touched; code blocks and markup followed by more text are left as they are. An optional salvage mode, off by default and limited to the models you list, turns the markup into a real call to a tool offered in that request. Disclosure: no network, files, shell, subprocesses, telemetry or stored state; logs name the format and tools, never arguments; in salvage mode text the model wrote can become a real tool call (tools offered in the request, or Hermes's tool_call bridge when deferred_tools is on), run through Hermes's normal approvals.

JulientalbotModelsv0.1.0Updated 2 hours ago

About

Removes tool-call markup that a model wrote into its final reply as text instead of calling the tool (Qwen <function=...>, <invoke>, DeepSeek DSML and tool tokens, Harmony, Kimi, Mistral and Llama formats). Only complete markup that ends the reply is touched; code blocks and markup followed by more text are left as they are. An optional salvage mode, off by default and limited to the models you list, turns the markup into a real call to a tool offered in that request. Disclosure: no network, files, shell, subprocesses, telemetry or stored state; logs name the format and tools, never arguments; in salvage mode text the model wrote can become a real tool call (tools offered in the request, or Hermes's tool_call bridge when deferred_tools is on), run through Hermes's normal approvals.

tool-call-salvage

A Hermes Agent plugin for tool calls that a model
writes into its reply as text instead of making them.

The problem

Some models and serving stacks sometimes return a tool call as plain text: <tool_call>…,
<function=…>, <invoke name="…">, DeepSeek DSML or special tokens, Harmony to=functions.…,
[TOOL_CALLS], or a bare {"name": …, "arguments": …}. The tool_calls field is empty, so the
user sees markup and nothing runs. Reports upstream include NousResearch/hermes-agent #117090,
#135008, #74723, #87900, #63282, #129060, #119264, #137775 and #125472. Hermes already removes
closed <tool_call> blocks from the final reply and recovers leaks from the Codex Responses API;
this plugin covers the other shapes and, if you ask for it, runs the call.

Default behaviour (mode: strip)

When the final reply ends with complete tool-call markup, the plugin removes that markup and
logs a warning that names the format and the tools (never the arguments). Nothing runs. If the
reply was only markup, the user gets a short notice instead (empty_notice).

It never touches:

  • fenced (```, ~~~, an unclosed fence included) or indented code blocks, so a
    developer who asks what a tool call looks like gets the example;
  • markup followed by more text (an example or an explanation), or plain markup in the middle of
    a line (only special tokens such as <|python_tag|> or <|tool▁calls▁begin|> may follow
    prose on the same line);
  • JSON. A bare JSON object is never stripped, because it is also an ordinary answer;
  • incomplete markup: every parameter must be closed.

Optional: run the call (mode: salvage, off by default)

For the models listed in salvage_models, the plugin reads each Chat Completions response
before Hermes does. If the response has no structured call and its text ends with complete
calls, the plugin turns them into structured calls, and Hermes runs them through its normal path
(argument checks, guardrails, approvals). Text before the calls stays as the reply. All of these
must hold, or the response is left as it was:

  • every tool name is offered in that request, and each call has the tool's required arguments
    (with deferred_tools: true, a tool kept behind Hermes's Tool Search is reached through the
    offered tool_call bridge instead, which checks the name against the session's tools);
  • the generation was not cut off (finish_reason is not length);
  • at most max_calls calls;
  • for bare JSON (Ollama /v1): bare_json: true, and the reply is nothing but call objects
    with no extra keys.

If the reply is empty and the reasoning ends with complete calls (a model that stalled with its
call written there), the calls are taken from the reasoning, which is kept as it is.

Settings

In ~/.hermes/config.yaml (also shown in the Desktop app under Settings → Plugins):

plugins:
  enabled: [tool-call-salvage]
  entries:
    tool-call-salvage:
      settings:
        mode: strip            # strip | salvage
        salvage_models: []     # globs on the model or provider/model, e.g. ["qwen*", "ollama/*"]
        bare_json: false       # salvage only: accept a reply made only of JSON call objects
        deferred_tools: false  # salvage only: reach Tool Search (deferred) tools via tool_call
        max_calls: 8           # more calls than this in one reply: nothing is converted (1-32)
        empty_notice: "The model wrote a tool call as text instead of making it, so nothing was run. Please ask again."

An empty salvage_models list means nothing is converted, even in salvage mode. Settings are
read on every call, so a change applies without a restart. empty_notice: "" leaves a reply that
was only markup unchanged.

Formats

| Family | Shape | Seen with |
|---|---|---|
| tool_call | <tool_call>{json}</tool_call>, <tool_call><function=…>, <minimax:tool_call> | Hermes and Qwen templates, MiniMax |
| function_tag | <function=NAME><parameter=KEY>…</parameter></function> | Qwen3-Coder, quantized Qwen |
| invoke | <invoke name="NAME"><parameter name="KEY">…, bare or in <function_calls>; <|DSML|tool_calls> | DeepSeek DSML, others |
| deepseek | <|tool▁calls▁begin|>… | DeepSeek V3, V3.1 |
| kimi | <\|tool_calls_section_begin\|>… | Kimi K2 |
| harmony | <\|channel\|>commentary to=functions.NAME … <\|message\|>{json} | gpt-oss |
| mistral | [TOOL_CALLS][{json}], [TOOL_CALLS]NAME[ARGS]{json} | Mistral |
| python_tag | <\|python_tag\|>{json} | Llama 3.x |
| bare_json | {"name": …, "arguments": …} (salvage only, opt-in) | Ollama /v1 |

Disclosure

No network, no files, no shell, no subprocesses, no telemetry, no stored state. The plugin reads
its own settings and changes only the current reply. In strip mode it removes leaked tool-call
markup from the final reply. In salvage mode, for listed models only, text the model wrote can
become a real tool call: only tools offered in that request (and, with deferred_tools, the
session's deferred tools through Hermes's tool_call bridge), run through Hermes's normal
approvals.

Limits

  • With streaming on, the user may see the markup arrive before the final reply replaces it.
  • Salvage reads Chat Completions responses only. Anthropic Messages and Responses API replies
    are only stripped (Hermes already recovers Codex leaks).
  • Markup that does not end the reply, and markup in the commentary of a step that also made a
    real call, are left alone.
  • A reply that is nothing but XML in exactly the shape of a call, on its own lines and outside a
    code block (for example <function name="x"><parameter name="y">1</parameter></function>),
    is treated as a leak.
  • The proper fix belongs in Hermes's response intake, where several core PRs propose one. This
    plugin is a stopgap: remove it once Hermes handles your model's format.

Development

python -m pytest tests                                            # unit tests
HERMES_E2E_BIN=$(command -v hermes) python -m pytest tests/test_e2e_hermes.py
hermes plugins validate .

The end-to-end test runs hermes -z in a throwaway HOME against a scripted local provider.

License

MIT

README from the reviewed commit 2d0ad4f; it updates when the author re-pins.

More by Julientalbot

3 plugins by Julientalbot in the catalog.

Julientalbothermes-xaiThe xAI API surfaces core does not cover, nothing it already ships (provider, x_search, web search, TTS, STT and Imagine stay in core). Voice module: full-duplex Grok Voice (grok-voice-latest) in the Desktop composer, built like core's GPT-Live mode; the voice model hands real work to Hermes as a normal turn on the open chat (surface voice-live) and reads the result aloud; the backend mints a 300 s xAI client secret so XAI_API_KEY never reaches the renderer. Collections module: one read-only tool, xai_collections_search, over documents the user already indexed in xAI Collections (GET /v1/collections, POST /v1/documents/search). Disclosure — the voice button captures microphone audio and streams it to api.x.ai over a WebSocket; the backend calls api.x.ai once per call with XAI_API_KEY (read through agent.secret_scope) to mint the secret; delegated requests run as normal Hermes turns with the profile's full toolset and approvals, and their final text (up to 3000 characters) plus the last spoken turns are sent back to xAI. Each xai_collections_search call sends the query to api.x.ai with XAI_API_KEY and returns passages into the conversation. Nothing is uploaded, stored or deleted and no telemetry is emitted; xAI bills on the user's key.Communityvoice7Julientalbotfleet-policyFor administrators who pin settings through the managed scope: records what each agent role really sends to the provider (model, reasoning effort, service tier, plus the tier the provider says it served) next to the managed-scope state and the effective config (`hermes fleet-policy status --json`); measures cron cost per job — runs, empty runs, $/day, gate present (`crons --cost`); and `watch`, a generic wake gate for cron scripts that replays one read tool on an MCP server and wakes the agent only when new item ids appear (stock `wakeAgent` gate, no LLM call otherwise). Ships two skills (managed-scope admin, recurring tasks). Disclosure — records only model, reasoning effort and service tier per role (no message content, headers or keys) via the pre_api_request and pre_auxiliary_call hooks and a pass-through llm_execution middleware, stored in $HERMES_HOME/fleet-policy/observed.json; crons --cost opens cron/executions.db and state.db read-only and checks each cron run's final reply locally for silence; watch is an operator CLI command that calls one MCP tool at the config's server_url (or the admin-pinned watch_server_url) with config headers in which ${VAR} env values are expanded, follows redirects, and writes seen ids to a state file at the config-chosen path; the recurring-tasks skill has the agent write watch configs under $HERMES_HOME/watches, a script under $HERMES_HOME/scripts and a cron job; no other network access, no telemetry.Communityautomation