Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710)
- ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES)
- Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals

Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710)
- ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES)
- Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals

Expand Down
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 未发布

- Todo:重构 `SetTodoList` 工具,支持状态持久化并防止工具调用风暴——待办事项现在会持久化到会话状态(主 Agent)和独立状态文件(子 Agent);新增查询模式(省略 `todos` 参数可读取当前状态)和清空模式(传 `[]` 清空);工具描述中增加了防风暴指导,防止在没有实际进展的情况下反复调用(修复 #1710)
- ReadFile:每次读取返回文件总行数,并支持负数 `line_offset` 实现 tail 模式——工具现在会在消息中报告 `Total lines in file: N.`,方便模型规划后续读取;负数 `line_offset`(如 `-100`)通过滑动窗口读取文件末尾 N 行,适用于无需 Shell 命令即可查看最新日志输出的场景;绝对值上限为 1000(MAX_LINES)
- Shell:修复 Markdown 渲染中行内代码和代码块出现黑色背景的问题——`NEUTRAL_MARKDOWN_THEME` 现在将所有 Rich 默认的 `markdown.*` 样式覆盖为 `"none"`,防止 Rich 内置的 `"cyan on black"` 在非黑色背景终端上泄露

Expand Down
10 changes: 10 additions & 0 deletions src/kimi_cli/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field, ValidationError

Expand All @@ -16,6 +17,13 @@ class ApprovalStateData(BaseModel):
auto_approve_actions: set[str] = Field(default_factory=set)


class TodoItemState(BaseModel):
"""A single todo item stored in session or subagent state."""

title: str
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce non-empty titles in persisted todo state

TodoItemState allows empty title values, but root query mode later reconstructs each item as Todo(title=...) (which requires min_length=1). If state.json contains an empty title (e.g., from a manual edit or another writer), SetTodoList query mode will raise a runtime error instead of returning the list or recovering, so a malformed-but-parseable root state can break the tool. Adding the same title constraint to TodoItemState (or handling invalid entries during _load_root_todos) avoids this crash path.

Useful? React with 👍 / 👎.

status: Literal["pending", "in_progress", "done"]


class SessionState(BaseModel):
version: int = 1
approval: ApprovalStateData = Field(default_factory=ApprovalStateData)
Expand All @@ -31,6 +39,8 @@ class SessionState(BaseModel):
archived: bool = False
archived_at: float | None = None
auto_archive_exempt: bool = False
# Todo list state
todos: list[TodoItemState] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]


_LEGACY_METADATA_FILENAME = "metadata.json"
Expand Down
143 changes: 139 additions & 4 deletions src/kimi_cli/tools/todo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json
from pathlib import Path
from typing import Literal, override
from typing import Any, Literal, cast, override

from kosong.tooling import CallableTool2, ToolReturnValue
from pydantic import BaseModel, Field

from kimi_cli.session_state import TodoItemState
from kimi_cli.soul.agent import Runtime
from kimi_cli.tools.display import TodoDisplayBlock, TodoDisplayItem
from kimi_cli.tools.utils import load_desc
from kimi_cli.utils.logging import logger


class Todo(BaseModel):
Expand All @@ -14,20 +18,151 @@ class Todo(BaseModel):


class Params(BaseModel):
todos: list[Todo] = Field(description="The updated todo list")
todos: list[Todo] | None = Field(
default=None,
description=(
"The updated todo list. "
"If not provided, returns the current todo list without making changes."
),
)


class SetTodoList(CallableTool2[Params]):
name: str = "SetTodoList"
description: str = load_desc(Path(__file__).parent / "set_todo_list.md")
params: type[Params] = Params

def __init__(self, runtime: Runtime) -> None:
super().__init__()
self._runtime = runtime

@override
async def __call__(self, params: Params) -> ToolReturnValue:
items = [TodoDisplayItem(title=todo.title, status=todo.status) for todo in params.todos]
if params.todos is None:
return self._read_todos()
return self._write_todos(params.todos)

# ---- Write mode --------------------------------------------------------

def _write_todos(self, todos: list[Todo]) -> ToolReturnValue:
"""Persist the todo list and return confirmation."""
self._save_todos(todos)

items = [TodoDisplayItem(title=todo.title, status=todo.status) for todo in todos]
return ToolReturnValue(
is_error=False,
output="",
output="Todo list updated",
message="Todo list updated",
display=[TodoDisplayBlock(items=items)],
)

# ---- Read mode ---------------------------------------------------------

def _read_todos(self) -> ToolReturnValue:
"""Return the current todo list as text output for the model."""
todos = self._load_todos()
if not todos:
return ToolReturnValue(
is_error=False,
output="Todo list is empty.",
message="",
display=[],
)

lines: list[str] = ["Current todo list:"]
for todo in todos:
lines.append(f"- [{todo.status}] {todo.title}")
return ToolReturnValue(
is_error=False,
output="\n".join(lines),
message="",
display=[],
)

# ---- Persistence -------------------------------------------------------

def _save_todos(self, todos: list[Todo]) -> None:
"""Persist todos to the appropriate state file."""
items = [TodoItemState(title=t.title, status=t.status) for t in todos]

if self._runtime.role == "root":
self._save_root_todos(items)
else:
self._save_subagent_todos(items)

def _load_todos(self) -> list[Todo]:
"""Load todos from the appropriate state file."""
if self._runtime.role == "root":
return self._load_root_todos()
else:
return self._load_subagent_todos()

def _save_root_todos(self, items: list[TodoItemState]) -> None:
session = self._runtime.session
session.state.todos = items
session.save_state()

def _load_root_todos(self) -> list[Todo]:
from kimi_cli.session_state import load_session_state

session = self._runtime.session
fresh = load_session_state(session.dir)
session.state.todos = fresh.todos
result: list[Todo] = []
for t in fresh.todos:
try:
result.append(Todo(title=t.title, status=t.status))
except Exception:
logger.warning("Skipping malformed todo item in root state: {t}", t=t)
return result

def _save_subagent_todos(self, items: list[TodoItemState]) -> None:
state_file = self._subagent_state_file()
if state_file is None:
return
data = self._read_subagent_state(state_file)
data["todos"] = [item.model_dump() for item in items]
self._write_subagent_state(state_file, data)

def _load_subagent_todos(self) -> list[Todo]:
state_file = self._subagent_state_file()
if state_file is None:
return []
data = self._read_subagent_state(state_file)
raw_todos_val = data.get("todos", [])
raw_todos = cast(list[Any], raw_todos_val) if isinstance(raw_todos_val, list) else []
result: list[Todo] = []
for item in raw_todos:
try:
result.append(Todo(**item))
except Exception:
logger.warning("Skipping malformed todo item in subagent state: {item}", item=item)
return result

def _subagent_state_file(self) -> Path | None:
store = self._runtime.subagent_store
agent_id = self._runtime.subagent_id
if store is None or agent_id is None:
return None
return store.instance_dir(agent_id) / "state.json"

@staticmethod
def _read_subagent_state(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
logger.warning("Corrupted subagent todo state, using defaults: {path}", path=path)
return {}
if not isinstance(data, dict):
logger.warning("Invalid subagent todo state type, using defaults: {path}", path=path)
return {}
return cast(dict[str, Any], data)

@staticmethod
def _write_subagent_state(path: Path, data: dict[str, Any]) -> None:
from kimi_cli.utils.io import atomic_json_write

path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(data, path)
12 changes: 10 additions & 2 deletions src/kimi_cli/tools/todo/set_todo_list.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
Update the whole todo list.
Manage your todo list for tracking task progress.

Todo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.

This is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.
**Usage modes:**

- **Update mode**: Pass `todos` to set the entire todo list. The previous list is replaced.
- **Query mode**: Omit `todos` (or pass null) to retrieve the current todo list without changes.
- **Clear mode**: Pass an empty array `[]` to clear all todos.

This is the only todo list tool available to you. That said, each time you want to update the todo list, you need to provide the whole list. Make sure to maintain the todo items and their statuses properly.

Once you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.

Expand All @@ -13,3 +19,5 @@ Abusing this tool to track too small steps will just waste your time and make yo
- When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. "Replace xxx to yyy in the file zzz", "Create a file xxx with content yyy."

However, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.

IMPORTANT: Do not call this tool repeatedly without making real progress on at least one task between calls. If you are unsure about the current state, use Query mode (omit `todos`) to check before updating. If you find yourself unable to advance any task with your available tools, inform the user about what is blocking you instead of replanning. Repeatedly updating the todo list without doing actual work is counterproductive.
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,9 @@ def think_tool() -> Think:


@pytest.fixture
def set_todo_list_tool() -> SetTodoList:
def set_todo_list_tool(runtime: Runtime) -> SetTodoList:
"""Create a SetTodoList tool instance."""
return SetTodoList()
return SetTodoList(runtime)


@pytest.fixture
Expand Down
17 changes: 17 additions & 0 deletions tests/core/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,23 @@ async def test_save_state_reload_does_not_lose_worker_fields(
assert result.custom_title == "External Title"


async def test_save_state_preserves_in_memory_todos(isolated_share_dir: Path, work_dir: KaosPath):
"""save_state() should persist in-memory todos (worker-owned) to disk."""
from kimi_cli.session_state import TodoItemState, load_session_state

session = await Session.create(work_dir)

# Simulate SetTodoList setting todos in memory before calling save_state()
session.state.todos = [TodoItemState(title="Worker todo", status="pending")]
session.save_state()

# Verify todos were persisted to disk
result = load_session_state(session.dir)
assert len(result.todos) == 1
assert result.todos[0].title == "Worker todo"
assert result.todos[0].status == "pending"


async def test_is_empty_with_only_metadata_records(
isolated_share_dir: Path, work_dir: KaosPath
) -> None:
Expand Down
Loading
Loading