Skip to content

fix: skip tool_result messages in save hook exchange count#550

Open
sha2fiddy wants to merge 4 commits intoMemPalace:developfrom
sha2fiddy:fix/549-skip-tool-result-in-save-hook
Open

fix: skip tool_result messages in save hook exchange count#550
sha2fiddy wants to merge 4 commits intoMemPalace:developfrom
sha2fiddy:fix/549-skip-tool-result-in-save-hook

Conversation

@sha2fiddy
Copy link
Copy Markdown
Contributor

Closes #549

Summary

  • Skip role: "user" messages where all content blocks are type: "tool_result" in _count_human_messages() (both hooks_cli.py and the inline Python in mempal_save_hook.sh)
  • Claude Code sends tool results as role: "user", inflating the exchange count ~2.9x in tool-heavy sessions — subagent-heavy work triggers saves far more often than the intended 15-message interval
  • Safe no-op for other LLMs (e.g. OpenAI-compatible) that use role: "tool" for tool results

Related

Test plan

  • Added test_count_skips_tool_results — verifies single and multi-block tool_result messages are skipped
  • Added test_count_mixed_content_not_skipped — verifies messages with both tool_result and text blocks still count
  • All 34 tests pass

@sha2fiddy sha2fiddy marked this pull request as ready for review April 10, 2026 16:24
@web3guru888
Copy link
Copy Markdown

This is a well-diagnosed fix. The 2.9x inflation number is striking but makes sense — Claude Code subagent sessions can have dense tool-use loops where the agent barely generates "human" prose, yet every tool result pings the counter.

The predicate is correct. Filtering on "ALL content blocks are tool_result" is the right guard — a mixed message (tool result + follow-up text from the user) IS a genuine human exchange and should count. This is an important nuance that a simpler role-based filter would get wrong.

Dual-location fix matters. hooks_cli.py and the inline Python in mempal_save_hook.sh need to stay in sync — a fix to only one would leave the shell hook broken for anyone using it directly. Good that both are covered here.

One edge case to validate: Some older transcript formats (and a few OpenAI-compatible LLMs) send content as a raw string rather than a list of blocks. Worth confirming the fix handles isinstance(content, str) gracefully — either treating string content as a counting message (safe conservative default) or explicitly checking before iterating. If the code does for block in content directly, a string content will iterate characters, not blocks.

On test coverage: 34 tests for a behavioral change in a save hook is on the lean side — would love to see at least one integration-style test that exercises a full Claude Code-style transcript (interleaved tool calls + real human turns) and confirms the final exchange count is correct end-to-end. That said, the predicate logic itself is straightforward enough that unit coverage probably catches the important cases.

Safe no-op for OpenAI-compatible LLMs using role:"tool" is a nice property — no risk of regression for non-Claude users.


[MemPalace-AGI integration — production stats at https://milla-jovovich.github.io/mempalace/integrations/mempalace-agi/]

Copy link
Copy Markdown

@web3guru888 web3guru888 left a comment

Choose a reason for hiding this comment

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

Good fix for a real problem. The 2.9x inflation figure is consistent with what heavy subagent sessions look like — each tool dispatch produces a tool_result block that gets misclassified as a human message, so a session with lots of agent coordination work was triggering saves at roughly 3x the intended rate.

The all(b.get('type') == 'tool_result' for b in content) guard is the right predicate. It's conservative in the right direction: a message with mixed content (text block + tool_result) would still be counted, which is correct since it represents actual human input alongside a tool return.

The dual-site fix (both hooks_cli.py and mempal_save_hook.sh) is important — the inline Python in the shell script is the one actually running in most deployments where the hook is invoked directly, so the hooks_cli.py fix alone wouldn't have been sufficient.

Test coverage is solid. The new test cases cover: pure tool_result skipping, mixed content retention, edge cases on empty blocks. That's exactly the right surface to test.

One minor: the test at line 82 in the pre-PR version was counting list-content messages and it's now clearer how _count_human_messages handles mixed content types. The existing tests for that still pass, but a comment in the test naming what 'mixed content' means (text block + tool_result) would help future readers.

Closes #549 cleanly. LGTM.

@sha2fiddy
Copy link
Copy Markdown
Contributor Author

sha2fiddy commented Apr 13, 2026

Yeah this is handled already — isinstance(content, str) is checked first (line ~59 in _count_human_messages):

if isinstance(content, str):
    if "<command-message>" in content:
        continue
    # counts as human message
elif isinstance(content, list):
    if all(b.get("type") == "tool_result" for b in content):
        continue

String content hits the first branch and gets counted as a human message. The list iteration only runs inside isinstance(content, list), so no risk of iterating characters on a raw string.

@igorls igorls added area/hooks Claude Code hook scripts (Stop, PreCompact, SessionStart) bug Something isn't working labels Apr 14, 2026
@sha2fiddy sha2fiddy force-pushed the fix/549-skip-tool-result-in-save-hook branch 3 times, most recently from 287a955 to e52566a Compare April 20, 2026 13:47
…#549)

Claude Code sends tool results as role: "user" messages with content
blocks of type "tool_result". The save hook was counting these as human
messages, inflating the exchange count ~2.9x in tool-heavy sessions and
triggering saves far more often than the intended 15-message interval.

Filter out entries where content is a list of exclusively tool_result
blocks. Safe no-op for LLMs that use role: "tool" for tool results.
@sha2fiddy sha2fiddy force-pushed the fix/549-skip-tool-result-in-save-hook branch from e52566a to 3f2ac0c Compare April 22, 2026 13:21
@sha2fiddy
Copy link
Copy Markdown
Contributor Author

Rebased on develop (9b35d9f). Conflicts from upstream #1021 resolved — took silent-save shape. 1068/1068 tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/hooks Claude Code hook scripts (Stop, PreCompact, SessionStart) bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: save hook counts tool_result messages as human messages, inflating exchange count

3 participants