Summary
internal/driver/hermes/driver.go:200 sets HERMES_TOOL_ONLY_MODE=1 unconditionally whenever a pod declares a Discord or Slack handle. Hermes then forces tool_choice: "required" on the first LLM call of every turn (agent/chat_completion_helpers.py:817-836) — including scheduled cron invocations that have nothing to do with chat.
For an agent whose scheduled-wake contract is "if nothing is actionable, make zero tool calls and stop," this makes the contract structurally unsatisfiable: the model cannot terminate a wake without calling at least one tool, so every idle wake burns a mediated tool round (and its tokens) by construction. We spent a full day of live debugging (timeout layers, reasoning-effort propagation, provider windows) before the payload diff exposed that the caller policy itself forbade silence.
Why it's misapplied
The env is a chat-surface quality policy — "runner-native tool progress stays silent so only real replies post." That rationale is about what gets posted to a channel in response to a human message. A cron wake posts nothing; forcing a tool call there converts "no action is a good turn" into "at least one billable managed call per wake, forever."
Asks
- Scope tool-only mode per surface: apply it to chat-handle turns, not to
INVOKE/cron-originated turns. The runner already distinguishes the two (cron wakes arrive via hermes cron run).
- Failing that, make it declarable: a Clawfile/pod-level knob (
x-claw.tool-only-mode: chat|always|never) so an operator can express "this pod's handle is a coordination surface, not a product chat."
- Document that a service-level
environment: HERMES_TOOL_ONLY_MODE: "" override is (or is not) honored against the driver env — merge order is currently unspecified, and Python's falsy-empty-string semantics make the empty override a natural escape hatch if it wins.
Evidence
Pod emmett-support (HANDLE slack, INVOKE cron): every scheduled wake's stored request carries tool_choice: "required" with the 16-tool manifest; direct calls to the same cllama path without tool_choice complete in ~3s and can return zero tool calls. Session history and the request payload diff are retained.
Summary
internal/driver/hermes/driver.go:200setsHERMES_TOOL_ONLY_MODE=1unconditionally whenever a pod declares a Discord or Slack handle. Hermes then forcestool_choice: "required"on the first LLM call of every turn (agent/chat_completion_helpers.py:817-836) — including scheduled cron invocations that have nothing to do with chat.For an agent whose scheduled-wake contract is "if nothing is actionable, make zero tool calls and stop," this makes the contract structurally unsatisfiable: the model cannot terminate a wake without calling at least one tool, so every idle wake burns a mediated tool round (and its tokens) by construction. We spent a full day of live debugging (timeout layers, reasoning-effort propagation, provider windows) before the payload diff exposed that the caller policy itself forbade silence.
Why it's misapplied
The env is a chat-surface quality policy — "runner-native tool progress stays silent so only real replies post." That rationale is about what gets posted to a channel in response to a human message. A cron wake posts nothing; forcing a tool call there converts "no action is a good turn" into "at least one billable managed call per wake, forever."
Asks
INVOKE/cron-originated turns. The runner already distinguishes the two (cron wakes arrive viahermes cron run).x-claw.tool-only-mode: chat|always|never) so an operator can express "this pod's handle is a coordination surface, not a product chat."environment: HERMES_TOOL_ONLY_MODE: ""override is (or is not) honored against the driver env — merge order is currently unspecified, and Python's falsy-empty-string semantics make the empty override a natural escape hatch if it wins.Evidence
Pod
emmett-support(HANDLE slack, INVOKE cron): every scheduled wake's stored request carriestool_choice: "required"with the 16-tool manifest; direct calls to the same cllama path withouttool_choicecomplete in ~3s and can return zero tool calls. Session history and the request payload diff are retained.