Skip to content
Merged
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
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

- 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

## 1.30.0 (2026-04-02)
Expand Down
4 changes: 2 additions & 2 deletions docs/en/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,12 @@ When `run_in_background=true`, the command is launched as a background task and
### `ReadFile`

- **Path**: `kimi_cli.tools.file:ReadFile`
- **Description**: Read text file content. Max 1000 lines per read, max 2000 characters per line. Files outside working directory require absolute paths.
- **Description**: Read text file content. Max 1000 lines per read, max 2000 characters per line. Files outside working directory require absolute paths. Every read returns the total number of lines in the file.

| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line_offset` | int | Starting line number, default 1 |
| `line_offset` | int | Starting line number, default 1. Supports negative values to read from the end of the file (e.g. `-100` reads the last 100 lines); absolute value cannot exceed 1000 |
| `n_lines` | int | Number of lines to read, default/max 1000 |

### `ReadMediaFile`
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

- 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

## 1.30.0 (2026-04-02)
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,12 @@ agent:
### `ReadFile`

- **路径**:`kimi_cli.tools.file:ReadFile`
- **描述**:读取文本文件内容。单次最多读取 1000 行,每行最多 2000 字符。工作目录外的文件需使用绝对路径。
- **描述**:读取文本文件内容。单次最多读取 1000 行,每行最多 2000 字符。工作目录外的文件需使用绝对路径。每次读取都会在消息中返回文件总行数。

| 参数 | 类型 | 说明 |
|------|------|------|
| `path` | string | 文件路径 |
| `line_offset` | int | 起始行号,默认 1 |
| `line_offset` | int | 起始行号,默认 1。支持负数表示从文件末尾读取(如 `-100` 读取最后 100 行),绝对值不超过 1000 |
| `n_lines` | int | 读取行数,默认/最大 1000 |

### `ReadMediaFile`
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 @@

## 未发布

- 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"` 在非黑色背景终端上泄露

## 1.30.0 (2026-04-02)
Expand Down
2 changes: 2 additions & 0 deletions src/kimi_cli/tools/file/read.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ Read text content from a file.
- If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.
- Content will be returned with a line number before each line like `cat -n` format.
- Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.
- Use negative `line_offset` to read from the end of the file (e.g. `line_offset=-100` reads the last 100 lines). This is useful for viewing the tail of log files. The absolute value cannot exceed ${MAX_LINES}.
- The tool always returns the total number of lines in the file in its message, which you can use to plan subsequent reads.
- The maximum number of lines that can be read at once is ${MAX_LINES}.
- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with "...".
207 changes: 154 additions & 53 deletions src/kimi_cli/tools/file/read.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from collections import deque
from pathlib import Path
from typing import override

from kaos.path import KaosPath
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator

from kimi_cli.soul.agent import Runtime
from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, detect_file_type
Expand All @@ -27,10 +28,11 @@ class Params(BaseModel):
description=(
"The line number to start reading from. "
"By default read from the beginning of the file. "
"Set this when the file is too large to read at once."
"Set this when the file is too large to read at once. "
"Negative values read from the end of the file (e.g. -100 reads the last 100 lines). "
f"The absolute value of negative offset cannot exceed {MAX_LINES}."
),
default=1,
ge=1,
)
n_lines: int = Field(
description=(
Expand All @@ -42,6 +44,20 @@ class Params(BaseModel):
ge=1,
)

@model_validator(mode="after")
def _validate_line_offset(self) -> "Params":
if self.line_offset == 0:
raise ValueError(
"line_offset cannot be 0; use 1 for the first line or -1 for the last line"
)
if self.line_offset < -MAX_LINES:
raise ValueError(
f"line_offset cannot be less than -{MAX_LINES}. "
"Use a positive line_offset with the total line count "
"to read from a specific position."
)
Comment on lines +47 to +58
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

PR description says negative line_offset values are capped at MAX_LINES, but the implementation currently rejects values less than -MAX_LINES with a ValueError. Please align behavior with the documented intent: either clamp the value during validation or update the PR description/docs to state that values beyond -MAX_LINES are invalid.

Copilot uses AI. Check for mistakes.
return self


class ReadFile(CallableTool2[Params]):
name: str = "ReadFile"
Expand Down Expand Up @@ -138,60 +154,145 @@ async def __call__(self, params: Params) -> ToolReturnValue:
brief="File not readable",
)

assert params.line_offset >= 1
assert params.n_lines >= 1
assert params.line_offset != 0

lines: list[str] = []
n_bytes = 0
truncated_line_numbers: list[int] = []
max_lines_reached = False
max_bytes_reached = False
current_line_no = 0
async for line in p.read_lines(errors="replace"):
current_line_no += 1
if current_line_no < params.line_offset:
continue
truncated = truncate_line(line, MAX_LINE_LENGTH)
if truncated != line:
truncated_line_numbers.append(current_line_no)
lines.append(truncated)
n_bytes += len(truncated.encode("utf-8"))
if len(lines) >= params.n_lines:
break
if len(lines) >= MAX_LINES:
max_lines_reached = True
break
if n_bytes >= MAX_BYTES:
max_bytes_reached = True
break

# Format output with line numbers like `cat -n`
lines_with_no: list[str] = []
for line_num, line in zip(
range(params.line_offset, params.line_offset + len(lines)), lines, strict=True
):
# Use 6-digit line number width, right-aligned, with tab separator
lines_with_no.append(f"{line_num:6d}\t{line}")

message = (
f"{len(lines)} lines read from file starting from line {params.line_offset}."
if len(lines) > 0
else "No lines read from file."
)
if max_lines_reached:
message += f" Max {MAX_LINES} lines reached."
elif max_bytes_reached:
message += f" Max {MAX_BYTES} bytes reached."
elif len(lines) < params.n_lines:
message += " End of file reached."
if truncated_line_numbers:
message += f" Lines {truncated_line_numbers} were truncated."
return ToolOk(
output="".join(lines_with_no), # lines already contain \n, just join them
message=message,
)
if params.line_offset < 0:
return await self._read_tail(p, params)
else:
return await self._read_forward(p, params)
except Exception as e:
return ToolError(
message=f"Failed to read {params.path}. Error: {e}",
brief="Failed to read file",
)

async def _read_forward(self, p: KaosPath, params: Params) -> ToolReturnValue:
"""Read file from a positive line_offset, counting total lines."""
lines: list[str] = []
n_bytes = 0
truncated_line_numbers: list[int] = []
max_lines_reached = False
max_bytes_reached = False
collecting = True # False once we've collected enough lines
current_line_no = 0
async for line in p.read_lines(errors="replace"):
current_line_no += 1
if not collecting:
continue
Comment on lines +181 to +182
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 Stop scanning the entire file after forward window is filled

After this change, _read_forward keeps iterating through p.read_lines() even after it has already collected the requested slice (collecting is set to False but the loop continues). In practice, reads like line_offset=1, n_lines=1 now become O(file size), which can cause major latency/timeouts on large logs and defeats the bounded-read behavior users relied on for quick peeks.

Useful? React with 👍 / 👎.

if current_line_no < params.line_offset:
continue
truncated = truncate_line(line, MAX_LINE_LENGTH)
if truncated != line:
truncated_line_numbers.append(current_line_no)
lines.append(truncated)
n_bytes += len(truncated.encode("utf-8"))
if len(lines) >= params.n_lines:
collecting = False
elif len(lines) >= MAX_LINES:
max_lines_reached = True
collecting = False
elif n_bytes >= MAX_BYTES:
max_bytes_reached = True
collecting = False

total_lines = current_line_no
Comment on lines +170 to +199
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

_read_forward continues iterating through the entire file after reaching n_lines / MAX_LINES / MAX_BYTES in order to compute total_lines. This can turn a small read into an O(file size) scan and effectively removes the operational protection that MAX_BYTES/MAX_LINES previously provided for large files (especially log files, which are a stated motivation). Consider caching total line counts keyed by (path, mtime, size) to avoid rescans on subsequent reads, and/or making the exact total line count conditional (e.g., only compute when requested, or report a lower bound when early limits are hit).

Copilot uses AI. Check for mistakes.

# Format output with line numbers like `cat -n`
start_line = params.line_offset
lines_with_no: list[str] = []
for line_num, line in zip(range(start_line, start_line + len(lines)), lines, strict=True):
lines_with_no.append(f"{line_num:6d}\t{line}")

message = (
f"{len(lines)} lines read from file starting from line {start_line}."
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

Minor grammar: messages always use "{n} lines read..." which produces "1 lines" when a single line is returned (e.g. tail/offset reads). Consider switching to singular/plural wording so user-facing messages read correctly.

Suggested change
f"{len(lines)} lines read from file starting from line {start_line}."
f"{len(lines)} {'line' if len(lines) == 1 else 'lines'} read from file starting from line {start_line}."

Copilot uses AI. Check for mistakes.
if len(lines) > 0
else "No lines read from file."
)
message += f" Total lines in file: {total_lines}."
if max_lines_reached:
message += f" Max {MAX_LINES} lines reached."
elif max_bytes_reached:
message += f" Max {MAX_BYTES} bytes reached."
elif len(lines) < params.n_lines:
message += " End of file reached."
if truncated_line_numbers:
message += f" Lines {truncated_line_numbers} were truncated."
return ToolOk(
output="".join(lines_with_no),
message=message,
)

async def _read_tail(self, p: KaosPath, params: Params) -> ToolReturnValue:
"""Read file from a negative line_offset (tail mode)."""
tail_count = abs(params.line_offset)

# Use a deque to keep the last `tail_count` lines with their line numbers
# Each entry: (line_no, truncated_line, was_truncated)
tail_buf: deque[tuple[int, str, bool]] = deque(maxlen=tail_count)
current_line_no = 0
async for line in p.read_lines(errors="replace"):
current_line_no += 1
truncated = truncate_line(line, MAX_LINE_LENGTH)
tail_buf.append((current_line_no, truncated, truncated != line))

total_lines = current_line_no

# Step 1: Apply n_lines / MAX_LINES from head of tail_buf.
# This preserves the user's requested start position.
all_entries = list(tail_buf)
line_limit = min(params.n_lines, MAX_LINES)
candidates = all_entries[:line_limit]
max_lines_reached = len(all_entries) > MAX_LINES and len(candidates) == MAX_LINES

# Step 2: Apply MAX_BYTES — if candidates exceed the byte budget,
# reverse-scan to keep the newest (closest to EOF) lines that fit.
total_candidate_bytes = sum(len(entry[1].encode("utf-8")) for entry in candidates)
if total_candidate_bytes > MAX_BYTES:
max_bytes_reached = True
kept = 0
n_bytes = 0
for entry in reversed(candidates):
n_bytes += len(entry[1].encode("utf-8"))
if n_bytes > MAX_BYTES:
break
kept += 1
candidates = candidates[len(candidates) - kept :]
else:
max_bytes_reached = False

# Step 3: Collect results from candidates
lines: list[str] = []
line_numbers: list[int] = []
truncated_line_numbers: list[int] = []

for line_no, truncated, was_truncated in candidates:
if was_truncated:
truncated_line_numbers.append(line_no)
lines.append(truncated)
line_numbers.append(line_no)

# Format output with absolute line numbers
lines_with_no: list[str] = []
for line_num, line in zip(line_numbers, lines, strict=True):
lines_with_no.append(f"{line_num:6d}\t{line}")

start_line = line_numbers[0] if line_numbers else total_lines + 1
message = (
f"{len(lines)} lines read from file starting from line {start_line}."
if len(lines) > 0
else "No lines read from file."
)
message += f" Total lines in file: {total_lines}."
if max_lines_reached:
message += f" Max {MAX_LINES} lines reached."
elif max_bytes_reached:
message += f" Max {MAX_BYTES} bytes reached."
elif len(lines) < params.n_lines:
message += " End of file reached."
if truncated_line_numbers:
message += f" Lines {truncated_line_numbers} were truncated."
return ToolOk(
output="".join(lines_with_no),
message=message,
)
Loading
Loading