Skip to content

#480 chore: instrument Aoide main loop with dev-only debug logging#485

Merged
bonk-moltbot merged 3 commits into
mainfrom
chore/480-debug-aoide-main-loop
Apr 30, 2026
Merged

#480 chore: instrument Aoide main loop with dev-only debug logging#485
bonk-moltbot merged 3 commits into
mainfrom
chore/480-debug-aoide-main-loop

Conversation

@hoblin

@hoblin hoblin commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

Wires up dev-only debug logging into Events::Subscribers::LLMResponseHandler so we can pin down whether spurious from_* tool calls (e.g. from_melete_goal, from_zero-width-sleuthTools::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)

  1. Raw Anthropic API response payload — full structure, encoded as TOON, debug level. Captured before normalize_tool_uses runs.
  2. Raw tool_use blocks — debug level, TOON-encoded, shown pre-normalization so missing ids look missing. If a from_melete_goal block is here, the LLM produced it; if it's only in the dispatched list, something else synthesized it.
  3. Each dispatched tool execution — info level, one line per call, with name + id.

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 by lib/tui/screens/chat.rb and the tool decorators) — same project convention, ~30% fewer lines than JSON.pretty_generate, lossless round-trip.

Where it lives

  • lib/aoide.rb — new module-level logger, mirrors Melete.logger / Mneme.logger byte-for-byte: dev-only Logger.new("log/aoide.log"), returns Logger.new(File::NULL) outside Rails.env.development? so call sites stay unconditional.
  • lib/events/subscribers/llm_response_handler.rb — three log points: log_raw_response at the top of #emit, info line in dispatch_tool_executions, plus def log = Aoide.logger.
  • CLAUDE.md — adds tail -f log/aoide.log next 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:

tail -f log/aoide.log | grep -E "raw tool_use blocks|dispatching"

If a dispatching tool=from_* line appears whose tool name is also present in the matching raw tool_use blocks: payload, the LLM hallucinated it. If the from_* 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: phantom from_* pairs are assembled inbound by PendingMessage#promote! as message-array entries, and the outgoing tool registry (Tools::Registry.tool_classes_for) is built exclusively from real Tool subclasses — none of which start with from_. 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:

[10:38:34.813] INFO  session=15 — response received (1 block(s), 0 tool_use)
[10:38:34.814] DEBUG  session=15 raw response:
model: claude-opus-4-6
id: msg_01DGMeXArS2ZQMNagL4HH4AU
type: message
role: assistant
content[1]{type,text}:
  text,"…"
…
[10:38:34.814] DEBUG  session=15 raw tool_use blocks:
[0]:

Three signals firing as designed; TOON encoding reads cleanly.

Boy Scout

While in the handler, factored session.id, tool_use["id"], and tool_use["name"] reads in dispatch_tool_executions and persist_tool_call to 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 files
  • bundle 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)
  • Manual: dev brain smoke test (above) — log/aoide.log populates correctly on a benign greeting.
  • Real-session: wait for a session that exhibits the from_* bug, read the logs, decide hallucination vs internal leak, record on Research spike: source of from_* tool calls in the main loop #480, open follow-up implementation issue.

Acceptance criteria from #480

  • After a session that exhibits the bug, the logs contain enough information to determine whether the from_* tool_use block was present in the API response or synthesized later. (structurally yes — verified by spec + smoke test, awaiting real-session confirmation)
  • Decision recorded on Research spike: source of from_* tool calls in the main loop #480: hallucination vs internal leak. (blocked on a real session with the new logs)
  • Follow-up implementation issue opened with the appropriate fix. (blocked on the decision above)

Refs #480

hoblin added 3 commits April 30, 2026 10:25
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).
@hoblin hoblin self-assigned this Apr 30, 2026
@hoblin
hoblin marked this pull request as ready for review April 30, 2026 07:50
@hoblin
hoblin requested a review from bonk-moltbot as a code owner April 30, 2026 07:50

@bonk-moltbot bonk-moltbot left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐭✅ 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:

  1. Info summary at response entry — response received (N block(s), M tool_use) — scannable timeline
  2. Debug: raw response + raw tool_use blocks pre-normalization — so missing ids look missing, and from_* names are traceable to source
  3. 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.

@bonk-moltbot
bonk-moltbot merged commit 8991f58 into main Apr 30, 2026
7 checks passed
@bonk-moltbot
bonk-moltbot deleted the chore/480-debug-aoide-main-loop branch April 30, 2026 07:51
bonk-moltbot pushed a commit that referenced this pull request Apr 30, 2026
* #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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants