{"record":{"id":"3eae2bbc7cf47474","repo":"shareAI-lab/learn-claude-code","slug":"max-20-todos-allowed","errorCode":null,"errorMessage":"Max 20 todos allowed","messagePattern":"Max 20 todos allowed","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"agents/s03_todo_write.py","lineNumber":58,"sourceCode":"    os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n\n\n# -- TodoManager: structured state the LLM writes to --\nclass TodoManager:\n    def __init__(self):\n        self.items = []\n\n    def update(self, items: list) -> str:\n        if len(items) > 20:\n            raise ValueError(\"Max 20 todos allowed\")\n        validated = []\n        in_progress_count = 0\n        for i, item in enumerate(items):\n            text = str(item.get(\"text\", \"\")).strip()\n            status = str(item.get(\"status\", \"pending\")).lower()\n            item_id = str(item.get(\"id\", str(i + 1)))\n            if not text:\n                raise ValueError(f\"Item {item_id}: text required\")\n            if status not in (\"pending\", \"in_progress\", \"completed\"):\n                raise ValueError(f\"Item {item_id}: invalid status '{status}'\")\n            if status == \"in_progress\":\n                in_progress_count += 1\n            validated.append({\"id\": item_id, \"text\": text, \"status\": status})\n        if in_progress_count > 1:\n            raise ValueError(\"Only one task can be in_progress at a time\")\n        self.items = validated\n        return self.render()\n","sourceCodeStart":40,"sourceCodeEnd":76,"githubUrl":"https://github.com/shareAI-lab/learn-claude-code/blob/985456f4adea6f4df8fbad4112245dbd97444eae/agents/s03_todo_write.py#L40-L76","documentation":"Raised by TodoManager.update() in agents/s03_todo_write.py:58 when the model passes a todo list longer than 20 items to the todo tool. The 20-item cap is a hard validation limit that exists to keep the todo state (which is re-rendered into context on every update) from bloating the conversation. It aborts the entire update, not just the excess items.","triggerScenarios":"The LLM calls todo_update with an `items` array of 21+ entries — typically when it plans a very large task up front, or when it re-submits a growing list each turn and the list crosses 20. Because update() replaces the whole list each call, one oversized submission fails wholesale and the previous list is kept.","commonSituations":"Over-planning models that decompose a big job into 30 micro-steps in one shot. Cumulative lists where completed items are never pruned. Agent loops that retry the identical oversized payload after the error instead of shrinking it.","solutions":["Split the plan: keep the todo list to at most 20 items and delete completed/irrelevant entries before adding new ones","Replace, don't append: since update() takes the full list each time, drop finished items in the same call that adds new ones","If the task genuinely needs more than 20 steps, group related steps into one todo item with sub-bullets in the text"],"exampleFix":"# before\ntodo.update([{ \"text\": f\"step {i}\" } for i in range(30)])\n# ValueError: Max 20 todos allowed\n\n# after\n todo.update([{ \"text\": f\"phase {i}: ...\" } for i in range(6)])  # 6 coarse phases","handlingStrategy":"validation","validationCode":"items = [i for i in items if str(i.get(\"text\", \"\")).strip()]  # drop empties first\nitems = items[:20]  # or trim oldest completed entries\nassert len(items) <= 20, f\"{len(items)} > 20; prune completed items first\"","typeGuard":"from typing import Any\n\ndef is_valid_todo_batch(items: Any) -> bool:\n    return (\n        isinstance(items, list)\n        and len(items) <= 20\n        and all(\n            isinstance(i, dict)\n            and str(i.get(\"text\", \"\")).strip()\n            and str(i.get(\"status\", \"pending\")).lower() in (\"pending\", \"in_progress\", \"completed\")\n            for i in items\n        )\n    )","tryCatchPattern":"try:\n    TODO.update(items)\nexcept ValueError as e:\n    if \"Max 20\" in str(e):\n        # keep last known-good list, retry with pruned items\n        pruned = [i for i in items if i.get(\"status\") != \"completed\"][:20]\n        return TODO.update(pruned) if pruned else TODO.render()\n    raise","preventionTips":["Prune completed items in the same update that adds new ones — the list is replaced wholesale each call","Plan in phases (<=20 coarse items) rather than exhaustive micro-steps","Track list length client-side before calling update"],"tags":["validation","todo","agent-tools","context-management","python"],"backgroundTag":null,"analyzedSha":"985456f4adea6f4df8fbad4112245dbd97444eae","analyzedAt":"2026-08-14T22:02:26.028Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}