Skip to content

fix(todo_tool): add defensive type coercion for LLM schema drift#22505

Open
AllynSheep wants to merge 2 commits into
NousResearch:mainfrom
AllynSheep:fix/todo-tool-type-coercion
Open

fix(todo_tool): add defensive type coercion for LLM schema drift#22505
AllynSheep wants to merge 2 commits into
NousResearch:mainfrom
AllynSheep:fix/todo-tool-type-coercion

Conversation

@AllynSheep

Copy link
Copy Markdown
Contributor

Summary

Fixes #14185: Add three guards to handle cases where the LLM emits todos as a JSON-encoded string instead of the declared array.

Changes

  1. todo_tool() entry — if todos is a str, attempt json.loads(); if it is neither list nor None, return a clear error instead of crashing.
  2. TodoStore._validate() — coerce non-dict items to a placeholder error record instead of calling .get() on them.
  3. TodoStore._dedupe_by_id() — defensive isinstance check before key access; skips non-dict items.

All three guards fail closed (return structured error, never raise).

Related

@liuhao1024 liuhao1024 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: todos=None (read path) is now rejected

The new type-validation guards are placed before the existing if todos is not None: block:

# (new code)
if not isinstance(todos, list):
    return tool_error(f"todo: todos must be a list, got {type(todos).__name__}")

# (existing code)
if todos is not None:
    items = store.write(todos, merge)
else:
    items = store.read()        # ← this path is now unreachable

When the LLM calls the tool with no todos argument, todos defaults to None. Since None is not a list, the new guard returns an error and the read path (store.read()) is never reached.

Fix: add an explicit None bypass before the list check:

if todos is not None and not isinstance(todos, list):
    return tool_error(f"todo: todos must be a list or null, got {type(todos).__name__}")

Or move the new validation inside the if todos is not None: branch.

allyn.liu added 2 commits May 11, 2026 10:56
Fixes NousResearch#14185: Add three guards to handle cases where the LLM
emits todos as a JSON-encoded string instead of an array.

- todo_tool(): coerce string -> list via json.loads(); return
  structured error for non-list/non-string inputs
- TodoStore._validate(): return placeholder error record for
  non-dict items instead of crashing on .get()
- TodoStore._dedupe_by_id(): skip non-dict items defensively

All guards fail closed (return structured error, never raise).
Move the isinstance(todos, list) check inside the if todos is not None
block to avoid rejecting the read path (todos=None).

Previously, calling the tool without a todos argument would hit the
type guard first and return an error, making store.read() unreachable.

Reported-by: liuhao1024
Fixes: NousResearch#22505 review feedback
@AllynSheep AllynSheep force-pushed the fix/todo-tool-type-coercion branch from 0dcd8e1 to 9ba78d2 Compare May 11, 2026 02:56
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competing PRs for #14185: #14785, #14187. All add defensive type coercion in todo_tool when LLM emits todos as string.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/tools Tool registry, model_tools, toolsets labels May 11, 2026
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 P3 Low — cosmetic, nice to have 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

3 participants