Skip to content

fix(tools): defensive type coercion for todos in todo_tool#14187

Open
JayGwod wants to merge 1 commit into
NousResearch:mainfrom
JayGwod:fix/todo-tool-type-coercion
Open

fix(tools): defensive type coercion for todos in todo_tool#14187
JayGwod wants to merge 1 commit into
NousResearch:mainfrom
JayGwod:fix/todo-tool-type-coercion

Conversation

@JayGwod

@JayGwod JayGwod commented Apr 22, 2026

Copy link
Copy Markdown

Fixes #14185.

What

Three additive defensive guards in tools/todo_tool.py against schema-violating todos inputs.

Why

See #14185 for full context. TL;DR: Claude-family models occasionally emit todos as a json.dumps(...)-ed string (especially after a prior tool_call rejection), which crashes todo_tool with AttributeError: 'str' object has no attribute 'get'.

Changes

  1. todo_tool() entryjson.loads() string todos; reject non-list/None with a clear error.
  2. TodoStore._validate() — coerce non-dict items to an error placeholder instead of crashing on .get().
  3. TodoStore._dedupe_by_id()isinstance(dict) guard before key access.

All guards fail-closed: invalid input → structured error response, never a raised exception.

Non-goals

  • No change to the declared schema — well-formed callers are unaffected.
  • No change to persistence format — only the validation/ingest path is hardened.

Testing

  • Unit tests added for all three guard paths (double-JSON-encoded string, non-dict item, non-list non-None).
  • Live-tested on this installation's gateway (PID 2473729): Todo_tool with a stringified todos now routes to the handler and returns a normal result.
  • No regression in existing test_todo*.py (passed on venv/bin/pytest tests/test_todo_*.py).

Risk

Minimal. ~30 LOC, pure additive, only reachable on schema violations that currently crash.

Related

Companion PR for the sibling dispatch-layer defense: will be linked once it lands.

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/tools Tool registry, model_tools, toolsets labels Apr 22, 2026
@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch 3 times, most recently from 1c05ee1 to 32a0d11 Compare April 28, 2026 21:09
@JayGwod

JayGwod commented May 2, 2026

Copy link
Copy Markdown
Author

Bumping — open for 10 days with no review activity.

Status check on current upstream/main (f903ceece):

  • Patch still applies cleanly, no rebase needed.
  • All 12 tests in tests/tools/test_todo_tool.py pass on a worktree at f903ceece with this commit on top.
  • Running locally on top of main for ~10 days without regression — the JSON-string todos payload happens reliably from a few model providers under tool-calling, so the symptom is reproducible and the fix has been load-bearing.

Happy to address any feedback or close as superseded if there's a preferred path. Thanks!

@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch 4 times, most recently from ffe25d2 to a51128c Compare May 8, 2026 14:24
@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch from a51128c to cedf341 Compare May 9, 2026 15:23
@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch 4 times, most recently from 374d230 to 61a8886 Compare May 18, 2026 09:52
@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch 2 times, most recently from 7bc55fd to bb75231 Compare May 22, 2026 18:14
Symptoms:
  - todo_tool crashes with "'str' object has no attribute 'get'"
    when the LLM emits `todos` as a JSON-encoded string instead of an array.
  - Also crashes if individual items are non-dict (schema violation).

Root cause:
  - todo_tool() and TodoStore._validate/_dedupe_by_id assumed inputs
    strictly follow the declared schema; no defensive parsing.
  - After a prior tool_call rejection, the model sometimes self-corrects
    by wrapping the list in json.dumps, triggering the crash.

Fix (3 guards, pure additive, ~30 lines):
  1. todo_tool() entry: parse `todos` if str, reject non-list with clear error.
  2. TodoStore._validate: coerce non-dict item to placeholder instead of .get() crash.
  3. TodoStore._dedupe_by_id: skip .get() on non-dict, keep slot via synthetic key.

Rollback:
  - Backup at tools/todo_tool.py.bak.pre-str-coerce-20260422-170109
  - Or: git revert <this-sha>

See also skill: devops/hermes-todo-str-coerce-patch (reapply procedure after `hermes update`).
@JayGwod JayGwod force-pushed the fix/todo-tool-type-coercion branch from bb75231 to 52575a8 Compare May 27, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

todo_tool: AttributeError 'str' object has no attribute 'get' when LLM emits todos as JSON string

2 participants