Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/kimi_cli/soul/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
from kimi_cli.soul.toolset import CustomToolset
from kimi_cli.tools import SkipThisTool
from kimi_cli.tools.file import FileOpsWindow
from kimi_cli.utils.logging import logger


Expand Down Expand Up @@ -55,6 +56,7 @@ async def load_agent(
Session: runtime.session,
DenwaRenji: runtime.denwa_renji,
Approval: runtime.approval,
FileOpsWindow: FileOpsWindow(),
}
tools = agent_spec.tools
if agent_spec.exclude_tools:
Expand Down
25 changes: 22 additions & 3 deletions src/kimi_cli/tools/file/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
from enum import Enum
from pathlib import Path


class FileOpsWindow:
"""Maintains a window of file operations."""

pass
"""Track file operations within a session for safety checks."""

def __init__(self) -> None:
self._read_files: set[str] = set()

@staticmethod
def _normalize(path: Path) -> str:
"""Normalize paths for tracking."""
try:
return str(path.resolve())
except FileNotFoundError:
# Fallback to absolute path when resolving fails (e.g. deleted file)
return str(path.absolute())

def mark_read(self, path: Path) -> None:
"""Record that a file has been read in the current session."""
self._read_files.add(self._normalize(path))

def has_read(self, path: Path) -> bool:
"""Check if the file has been read previously in the session."""
return self._normalize(path) in self._read_files


class FileActions(str, Enum):
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/tools/file/patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Apply a unified diff patch to a file.
- The tool will fail with error returned if the patch doesn't apply cleanly.
- The file must exist before applying the patch.
- You should prefer this tool over WriteFile tool and Bash `sed` command when editing an existing file.
- You must use the `ReadFile` tool on the target file earlier in the session before calling this tool.
20 changes: 18 additions & 2 deletions src/kimi_cli/tools/file/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from kimi_cli.soul.approval import Approval
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
from kimi_cli.tools.file import FileActions
from kimi_cli.tools.file import FileActions, FileOpsWindow
from kimi_cli.tools.utils import ToolRejectedError, load_desc


Expand Down Expand Up @@ -52,10 +52,17 @@ class PatchFile(CallableTool2[Params]):
description: str = load_desc(Path(__file__).parent / "patch.md")
params: type[Params] = Params

def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
def __init__(
self,
builtin_args: BuiltinSystemPromptArgs,
approval: Approval,
file_ops_window: FileOpsWindow,
**kwargs: Any,
):
super().__init__(**kwargs)
self._work_dir = builtin_args.KIMI_WORK_DIR
self._approval = approval
self._file_ops_window = file_ops_window

def _validate_path(self, path: Path) -> ToolError | None:
"""Validate that the path is safe to patch."""
Expand Down Expand Up @@ -104,6 +111,15 @@ async def __call__(self, params: Params) -> ToolReturnType:
brief="Invalid path",
)

if not self._file_ops_window.has_read(p):
return ToolError(
message=(
f"You must read `{params.path}` with the ReadFile tool before patching it. "
"Call ReadFile first and then retry PatchFile."
),
brief="Read file first",
)

# Request approval
if not await self._approval.request(
self.name,
Expand Down
9 changes: 8 additions & 1 deletion src/kimi_cli/tools/file/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import BaseModel, Field

from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
from kimi_cli.tools.file import FileOpsWindow
from kimi_cli.tools.utils import load_desc, truncate_line

MAX_LINES = 1000
Expand Down Expand Up @@ -47,10 +48,13 @@ class ReadFile(CallableTool2[Params]):
)
params: type[Params] = Params

def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None:
def __init__(
self, builtin_args: BuiltinSystemPromptArgs, file_ops_window: FileOpsWindow, **kwargs: Any
) -> None:
super().__init__(**kwargs)

self._work_dir = builtin_args.KIMI_WORK_DIR
self._file_ops_window = file_ops_window

@override
async def __call__(self, params: Params) -> ToolReturnType:
Expand Down Expand Up @@ -108,6 +112,9 @@ async def __call__(self, params: Params) -> ToolReturnType:
max_bytes_reached = True
break

# Mark file as read after successfully accessing it
self._file_ops_window.mark_read(p)

# Format output with line numbers like `cat -n`
lines_with_no: list[str] = []
for line_num, line in zip(
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/tools/file/replace.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Replace specific strings within a specified file.
- Multi-line strings are supported.
- Can specify a single edit or a list of edits in one call.
- You should prefer this tool over WriteFile tool and Bash `sed` command.
- You must use the `Read` tool on the target file earlier in the session before calling this tool.
20 changes: 18 additions & 2 deletions src/kimi_cli/tools/file/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from kimi_cli.soul.approval import Approval
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
from kimi_cli.tools.file import FileActions
from kimi_cli.tools.file import FileActions, FileOpsWindow
from kimi_cli.tools.utils import ToolRejectedError, load_desc


Expand All @@ -32,10 +32,17 @@ class StrReplaceFile(CallableTool2[Params]):
description: str = load_desc(Path(__file__).parent / "replace.md")
params: type[Params] = Params

def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
def __init__(
self,
builtin_args: BuiltinSystemPromptArgs,
approval: Approval,
file_ops_window: FileOpsWindow,
**kwargs: Any,
):
super().__init__(**kwargs)
self._work_dir = builtin_args.KIMI_WORK_DIR
self._approval = approval
self._file_ops_window = file_ops_window

def _validate_path(self, path: Path) -> ToolError | None:
"""Validate that the path is safe to edit."""
Expand Down Expand Up @@ -91,6 +98,15 @@ async def __call__(self, params: Params) -> ToolReturnType:
brief="Invalid path",
)

if not self._file_ops_window.has_read(p):
return ToolError(
message=(
f"You must read `{params.path}` with the ReadFile tool before editing it. "
"Call ReadFile first and then retry StrReplaceFile."
),
brief="Read file first",
)

# Request approval
if not await self._approval.request(
self.name,
Expand Down
25 changes: 19 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
from kimi_cli.tools.bash import Bash
from kimi_cli.tools.dmail import SendDMail
from kimi_cli.tools.file import FileOpsWindow
from kimi_cli.tools.file.glob import Glob
from kimi_cli.tools.file.grep import Grep
from kimi_cli.tools.file.patch import PatchFile
Expand Down Expand Up @@ -159,6 +160,12 @@ def think_tool() -> Think:
return Think()


@pytest.fixture
def file_ops_window() -> FileOpsWindow:
"""Track file reads within a test session."""
return FileOpsWindow()


@pytest.fixture
def set_todo_list_tool() -> SetTodoList:
"""Create a SetTodoList tool instance."""
Expand All @@ -173,9 +180,11 @@ def bash_tool(approval: Approval) -> Generator[Bash]:


@pytest.fixture
def read_file_tool(builtin_args: BuiltinSystemPromptArgs) -> ReadFile:
def read_file_tool(
builtin_args: BuiltinSystemPromptArgs, file_ops_window: FileOpsWindow
) -> ReadFile:
"""Create a ReadFile tool instance."""
return ReadFile(builtin_args)
return ReadFile(builtin_args, file_ops_window)


@pytest.fixture
Expand All @@ -201,20 +210,24 @@ def write_file_tool(

@pytest.fixture
def str_replace_file_tool(
builtin_args: BuiltinSystemPromptArgs, approval: Approval
builtin_args: BuiltinSystemPromptArgs,
approval: Approval,
file_ops_window: FileOpsWindow,
) -> Generator[StrReplaceFile]:
"""Create a StrReplaceFile tool instance."""
with tool_call_context("StrReplaceFile"):
yield StrReplaceFile(builtin_args, approval)
yield StrReplaceFile(builtin_args, approval, file_ops_window)


@pytest.fixture
def patch_file_tool(
builtin_args: BuiltinSystemPromptArgs, approval: Approval
builtin_args: BuiltinSystemPromptArgs,
approval: Approval,
file_ops_window: FileOpsWindow,
) -> Generator[PatchFile]:
"""Create a PatchFile tool instance."""
with tool_call_context("PatchFile"):
yield PatchFile(builtin_args, approval)
yield PatchFile(builtin_args, approval, file_ops_window)


@pytest.fixture
Expand Down
Loading