#480 chore: instrument Aoide main loop with dev-only debug logging#485
Conversation
Adds Aoide.logger (dev-only file logger at log/aoide.log) mirroring Melete.logger and Mneme.logger, and wires three diagnostic log points into Events::Subscribers::LLMResponseHandler so we can pin down where spurious from_* tool calls originate: * Raw Anthropic API response payload at debug, captured before any normalization or filtering runs. * Raw tool_use blocks at debug, shown pre-normalization so a reader can tell whether a tool name like from_melete_goal arrived in the API response or was synthesized later. * One info line per dispatched tool execution with name+id, so the inbound list can be correlated against what gets queued for ToolExecutionJob. Once we run a session that exhibits the bug, the logs will show whether the LLM produced the from_* tool_use block (hallucination) or whether something inside Anima synthesized it (internal leak). The codebase review for the spike already favours hallucination — phantom from_* pairs are assembled by PendingMessage#promote! on the inbound side and the outgoing tool registry is built exclusively from real Tool subclasses with non-from_ names — but the logging exists to confirm or refute that, not assume it. Boy Scout cleanup in the same handler: factor session.id, tool_use["id"], and tool_use["name"] reads in dispatch_tool_executions and persist_tool_call to local variables; reek was flagging duplicate method calls. Refs #480
Matches the project convention (Toon.encode for any structured data that is read by an agent or human in a logfile) and shrinks each debug line: a typical text-only response goes from ~30 lines of JSON to ~19 lines of TOON, with the same information in a more scannable table-header form. Lossless round-trip back to JSON if anyone needs it. Smoke-tested against the dev brain on 42135 — log/aoide.log now renders content blocks as a 1-row TOON table and empty arrays as [0]: instead of [].
Self-review surfaced one real test bug, one perf-leaning idiom fix, and
some doc/style drift from the established project conventions. Changes:
* `spec/.../llm_response_handler_spec.rb`: the "preserving missing ids"
test asserted with a JSON-shaped regex (`/"id":\s*"[0-9a-f-]{36}"/i`)
but the encoding is TOON — the regex would never have matched, making
the assertion vacuous (could pass with a synthesised UUID present).
Replaced with a UUID-shaped regex that matches in any encoding.
* `lib/events/subscribers/llm_response_handler.rb`: switch the two
`log.debug` calls in `log_raw_response` to block form so `Toon.encode`
is not evaluated unless the logger level allows it. Under the current
null-logger-defaults-to-DEBUG configuration the savings are zero, but
the form is idiomatic Ruby Logger and protects against future level
changes. Also factored the two debug payloads into a small `Hash#each`
to eliminate the duplicate `log.debug` call reek had flagged.
* `lib/events/subscribers/llm_response_handler.rb`: added `@return
[Logger]` YARD on `def log = Aoide.logger` to mirror the convention
in `lib/melete/runner.rb:344-345`. Stripped the trailing
task-referential clause ("when investigating spurious tool calls")
from the comment on `log_raw_response` per CLAUDE.md ("Don't reference
the current task, fix, or callers").
* `lib/aoide.rb`: tightened the module docstring to match Melete and
Mneme — single-clause role description plus sister-relational
framing of the Three Muses.
* `CLAUDE.md`: added the correlation use case to the Aoide log line so
a reader knows what they'd tail it for.
* `spec/.../llm_response_handler_spec.rb`: renamed the "all the way to
dispatch" test to be honest about what it asserts, and strengthened
it to actually check the raw blocks log too — so the test now verifies
the inbound→outbound trace the PR exists to produce.
Test/lint state: 13 examples 0 failures, standardrb clean, reek back
to the same 2 pre-existing FeatureEnvy warnings (none introduced).
bonk-moltbot
left a comment
There was a problem hiding this comment.
🐭✅ Research spike done right — instrument first, decide later.
Aoide module mirrors Melete/Mneme logger pattern byte-for-byte: dev-only file logger, null logger in production, no conditionals at call sites. Clean.
Three log points at the right seam:
- Info summary at response entry —
response received (N block(s), M tool_use)— scannable timeline - Debug: raw response + raw tool_use blocks pre-normalization — so missing
ids look missing, andfrom_*names are traceable to source - Info: each dispatched tool name+id — the "what got acted on" counterpart
The correlation workflow is elegant: if from_melete_goal appears in raw blocks AND dispatch → LLM hallucinated it. If only in dispatch → internal leak. The architectural prior (phantom from_* pairs built inbound by PendingMessage#promote!, not in outbound registry) already points at hallucination, but now there's data.
Boy scout bonus: local variable extraction in persist_tool_call and dispatch_tool_executions — fewer repeated hash lookups, cleaner reading. Block form on log.debug so Toon.encode never runs unless debug level — no perf impact in production.
Test coverage: 5 specs including the "trace a spurious from_* call from raw blocks to dispatch" scenario — directly tests the diagnostic use case.
* #480 fix: silently strip from_* tool calls before dispatch Issue #480's research spike (PR #485) confirmed the spurious from_* tool calls — `from_shell-runner`, `from_zero-width-sleuth`, etc., that produce `Tools::UnknownToolError` — are LLM hallucinations, not internal leaks. The model emits them as real tool_use blocks in the Anthropic response, complete with a fabricated input schema, despite PR #445's from_* naming intervention + system-prompt sister block + explicit Level 2 memory documenting the anti-pattern. The fix is a small pure filter that drops every block where `type == "tool_use" && name.start_with?("from_")` from the API response's `content` array, applied at the entry point of the main loop's response handler. The model's tool_use is removed before `normalize_tool_uses` and `extract_text` run, so: * a from_*-only response falls through to `response_complete!` (clean end of turn — no failed tool_result, no error round-trip, no token waste), * a response with mixed legitimate + from_* tool calls dispatches only the legitimate ones, * a response with text + from_* tool calls keeps the text and ends the turn without dispatch. The filter (`Aoide::PhantomCallFilter.call`) is a pure hash → hash function with no I/O, AR, or event coupling, so it specs in isolation against fixture payloads. It runs in `LLMResponseHandler#emit` immediately *after* `log_raw_response`, so the dev-only diagnostic log from PR #485 keeps observing the pre-filter API response — letting us continue to measure hallucination rate post-fix and decide later whether a feedback loop (synthesised tool_result, Mneme nudge) is worth adding. Three integration edge cases covered in the handler spec, plus 13 unit specs against the filter directly (including symbol-keyed content, missing content keys, malformed blocks). Closes #480 * Address self-review findings on PR #487 Self-review surfaced one real subtle bug and one boundary case worth locking explicitly: * `lib/aoide/phantom_call_filter.rb`: `response.merge("content" => filtered)` on a symbol-keyed input produced a hash containing BOTH `:content` (with the phantom block intact) AND `"content"` (filtered). Downstream callers were saved by `content_blocks`'s `response["content"] || response[:content]` precedence, but the post-filter hash itself was inconsistent — any caller that iterated keys directly would see duplicates with conflicting content. Filter now detects the input's key style and rewrites at the same key. * `spec/lib/aoide/phantom_call_filter_spec.rb`: rewrote the symbol-key test to assert (a) the result is sanitized at the symbol key and (b) no string-key copy was added. Also added an explicit boundary case for `name: "from_"` (the bare prefix) — the implementation already handled it via `start_with?`, the test now documents the intent. Test/lint state: 14 filter specs, 16 handler specs (30 total) — 0 failures. standardrb clean. reek clean on the filter.
Summary
Wires up dev-only debug logging into
Events::Subscribers::LLMResponseHandlerso we can pin down whether spuriousfrom_*tool calls (e.g.from_melete_goal,from_zero-width-sleuth→Tools::UnknownToolError) originate from the LLM (hallucination) or from somewhere inside Anima (internal leak).This is the research-spike data-gathering step of #480 — not a fix. The decision (hallucination vs internal leak) and the follow-up implementation issue come after a session that exhibits the bug is run against the new logs.
What's logged (at the point the API response enters the main loop)
normalize_tool_usesruns.tool_useblocks — debug level, TOON-encoded, shown pre-normalization so missingids look missing. If afrom_melete_goalblock is here, the LLM produced it; if it's only in the dispatched list, something else synthesized it.Plus a one-line info summary at session boundary (
response received (N block(s), M tool_use)) so a reader can scan the timeline without--debug.Payloads are encoded with
Toon.encode(already used bylib/tui/screens/chat.rband the tool decorators) — same project convention, ~30% fewer lines thanJSON.pretty_generate, lossless round-trip.Where it lives
lib/aoide.rb— new module-level logger, mirrorsMelete.logger/Mneme.loggerbyte-for-byte: dev-onlyLogger.new("log/aoide.log"), returnsLogger.new(File::NULL)outsideRails.env.development?so call sites stay unconditional.lib/events/subscribers/llm_response_handler.rb— three log points:log_raw_responseat the top of#emit, info line indispatch_tool_executions, plusdef log = Aoide.logger.CLAUDE.md— addstail -f log/aoide.lognext to the existing Melete tip.How to use the logs
After this lands, run a session in dev that's likely to surface the bug (it tends to happen when sub-agents are messaging in or skills/goals are being injected). Then:
If a
dispatching tool=from_*line appears whose tool name is also present in the matchingraw tool_use blocks:payload, the LLM hallucinated it. If thefrom_*name is absent from the raw blocks but appears in dispatch, something inside Anima is synthesizing the tool_use after the API response.Architectural prior
The codebase review for this spike (PR #445 + drain-principles note +
PendingMessage#promote_as_phantom_pair!) already favours the hallucination hypothesis: phantomfrom_*pairs are assembled inbound byPendingMessage#promote!as message-array entries, and the outgoing tool registry (Tools::Registry.tool_classes_for) is built exclusively from realToolsubclasses — none of which start withfrom_. So an internal leak is architecturally implausible under the current design. The logging confirms or refutes that without us having to assume.Smoke test (against dev brain on 42135)
Restarted the brain after this branch was on disk, said hi, read
log/aoide.log— got:Three signals firing as designed; TOON encoding reads cleanly.
Boy Scout
While in the handler, factored
session.id,tool_use["id"], andtool_use["name"]reads indispatch_tool_executionsandpersist_tool_callto local variables — reek was flagging the duplicate method calls.Test plan
bundle exec rspec spec/lib/events/subscribers/llm_response_handler_spec.rb— 13 examples, 0 failures (5 new, all asserting the logger receives the right calls)bundle exec rspec spec/jobs/drain_job_spec.rb— 11 examples, 0 failures (sanity check on the upstream emitter)bundle exec standardrb— clean on changed filesbundle exec reek lib/events/subscribers/llm_response_handler.rb— only 2 pre-existing FeatureEnvy warnings remain (inherent iteration shape; not introduced by this PR and not obviously improved by extraction)log/aoide.logpopulates correctly on a benign greeting.from_*bug, read the logs, decide hallucination vs internal leak, record on Research spike: source offrom_*tool calls in the main loop #480, open follow-up implementation issue.Acceptance criteria from #480
from_*tool_use block was present in the API response or synthesized later. (structurally yes — verified by spec + smoke test, awaiting real-session confirmation)from_*tool calls in the main loop #480: hallucination vs internal leak. (blocked on a real session with the new logs)Refs #480