mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-10-31 14:00:53 +00:00
fix(agent-core-v2): make large file reads resumable (#3645)
* fix(agent-core-v2): avoid repeated truncation of file reads * fix(agent-core-v2): resume reads within long lines Add column offsets and forward recovery for bounded long-line reads. Reject malformed UTF-16 in Read instead of replacing invalid input. * fix(agent-core-v2): warn on lossy UTF-16 reads Return readable text after strict UTF-16 decoding fails, with a warning retained in budgeted read continuations. * fix(agent-core-v2): avoid redundant tail scans and detect file changes
This commit is contained in:
parent
80480c01d2
commit
5000f981c3
19 changed files with 1043 additions and 429 deletions
5
.changeset/read-character-budgets.md
Normal file
5
.changeset/read-character-budgets.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Add configurable character limits and resumable long-line file reads without repeated output truncation.
|
||||
5
.changeset/read-invalid-utf16.md
Normal file
5
.changeset/read-invalid-utf16.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Read malformed UTF-16 files with an explicit lossy-decoding warning.
|
||||
5
.changeset/read-tail-consistency.md
Normal file
5
.changeset/read-tail-consistency.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Avoid repeated scanning for common tail reads and report file changes detected during tail rereads.
|
||||
|
|
@ -431,6 +431,23 @@ disabled = ["EnterPlanMode", "ExitPlanMode", "mcp__github__*"]
|
|||
Like the `tools` / `disallowedTools` fields of an agent file, this section shapes the tools shown to the model and is enforced again before execution. [Permission rules](#permission) remain a separate control for operations that require approval.
|
||||
:::
|
||||
|
||||
## `read`
|
||||
|
||||
`read` controls the character limits for the [`Read` tool](../reference/tools.md). The limit includes file content, line numbers, and the status block; it does not impose a separate line-count or UTF-8 byte limit.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `default_max_chars` | `integer` | `100000` | Character budget when the tool call omits `max_chars` |
|
||||
| `max_chars` | `integer` | `500000` | Maximum character budget a tool call may request |
|
||||
|
||||
```toml
|
||||
[read]
|
||||
default_max_chars = 100000
|
||||
max_chars = 500000
|
||||
```
|
||||
|
||||
Both values must be positive integers. A call's `max_chars` overrides the default, but is capped at the configured maximum; the result reports the effective budget. If the configured default exceeds the maximum, the maximum also limits default reads. Raise `default_max_chars` when you want larger documents to be returned in one call without the agent requesting a larger budget.
|
||||
|
||||
## `image`
|
||||
|
||||
`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on).
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ File tools handle reading, writing, and searching the local filesystem — the f
|
|||
| `Glob` | Auto-allow | Find files by glob pattern |
|
||||
| `ReadMediaFile` | Auto-allow | Read an image or video file |
|
||||
|
||||
**`Read`** accepts a file path (`path`) plus optional `line_offset` (starting line number; negative values count from the end) and `n_lines` (maximum number of lines to read). Returns at most 1000 lines or 100 KB per call; content beyond that limit is accompanied by a truncation notice. If the file is an image or video, the tool suggests using `ReadMediaFile` instead.
|
||||
**`Read`** accepts a file path (`path`) plus optional `line_offset` (starting line number; negative values count from the end), `column_offset` (zero-based position within the first line of a forward read), `n_lines` (requested number of source lines), and `max_chars` (maximum characters in the result, including line numbers and status). Omitting `n_lines` reads toward the end of the file. The default is 100,000 characters, and calls can request up to 500,000; both values can be changed in the [`read` configuration](../configuration/config-files.md#read). Characters and column offsets use JavaScript string length in the displayed text, excluding the line-number prefix for column offsets: common letters and Chinese characters count as one, while many emoji count as two.
|
||||
|
||||
`Read` prefers complete lines and its results are not shortened again by the general tool-output limit. A line that cannot fit on its own page is returned in fragments; the status reports the column range and `Next Read` arguments to retrieve the rest without raising the budget. Join fragments of the same line without adding a newline. A partial line remains in the requested `n_lines` range until its ending is returned. Invalid column positions return an error rather than skipping content.
|
||||
|
||||
Tail reads return the newest complete lines in the requested range first. If no complete line fits, the result includes forward `Next Read` arguments for the unread range; `column_offset` cannot be combined with a negative `line_offset`. Continuation positions refer to the current file contents, so start a new read if the file changes. If a tail read reports that the file changed during reading, retry against the updated file. UTF-16 LE/BE files up to 10 MiB are checked with strict decoding first. If decoding fails, `Read` returns readable text with malformed sequences replaced by `<60>`, and every page warns that decoding was lossy and the text may differ from the original. The warning counts toward the character budget; a literal `<60>` in a valid file does not trigger it. Use `ReadMediaFile` for images or videos.
|
||||
|
||||
**`Write`** accepts `path`, `content`, and an optional `mode` (`overwrite` or `append`; defaults to overwrite). Missing parent directories are created automatically; `append` mode appends content to the end of the file without automatically adding a newline. Writing to an existing file — in either `overwrite` or `append` mode — requires a prior `Read` of that file in the session; the write is rejected if the file changed on disk since the last read, while creating a new file is exempt.
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,23 @@ disabled = ["EnterPlanMode", "ExitPlanMode", "mcp__github__*"]
|
|||
与 Agent 文件中的 `tools` / `disallowedTools` 一样,本节不仅决定模型能"看到"哪些工具,还会在执行前再次强制检查。[权限规则](#permission)仍是独立的控制层,用于决定哪些操作需要审批。
|
||||
:::
|
||||
|
||||
## `read`
|
||||
|
||||
`read` 控制 [`Read` 工具](../reference/tools.md) 的字符额度,包含文件正文、行号和状态信息,不额外叠加行数或 UTF-8 字节数上限。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `default_max_chars` | `integer` | `100000` | 工具调用未指定 `max_chars` 时的字符额度 |
|
||||
| `max_chars` | `integer` | `500000` | 单次工具调用可申请的最大字符额度 |
|
||||
|
||||
```toml
|
||||
[read]
|
||||
default_max_chars = 100000
|
||||
max_chars = 500000
|
||||
```
|
||||
|
||||
两个值都必须是正整数。调用中的 `max_chars` 覆盖默认值,但不会超过配置的最大值;结果会说明实际生效的额度。如果配置的默认值超过最大值,默认读取也会按最大值执行。如果希望较大的文档默认就能一次返回,无需 Agent 主动申请更大额度,可以提高 `default_max_chars`。
|
||||
|
||||
## `image`
|
||||
|
||||
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@
|
|||
| `Glob` | 自动放行 | 按 glob 模式查找文件 |
|
||||
| `ReadMediaFile` | 自动放行 | 读取图片或视频文件 |
|
||||
|
||||
**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)和 `n_lines`(读取行数上限)。单次最多返回 1000 行或 100 KB,超出部分会附带截断提示。如果文件是图片或视频,工具会提示改用 `ReadMediaFile`。
|
||||
**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限为 100,000 字符,调用可申请到 500,000 字符;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。
|
||||
|
||||
`Read` 优先返回完整行,结果不会再被通用工具输出限制缩短。如果单独一行也无法在一页中容纳,工具会返回片段,并在状态中说明列范围及 `Next Read` 参数,无需提高额度即可继续读取。拼接同一行的片段时不要额外插入换行。一行只返回部分内容时,仍会计入剩余的 `n_lines` 范围,直到行尾返回为止。非法列偏移会明确报错,不会跳过内容。
|
||||
|
||||
尾读优先返回请求范围中较新的完整行。如果连一条完整行都无法容纳,结果会给出未读范围的正向 `Next Read` 参数;`column_offset` 不能与负数 `line_offset` 同时使用。续读位置针对文件的当前内容,文件变化后应重新读取。如果尾读提示读取期间文件发生变化,请基于更新后的文件重试。10 MiB 以内的 UTF-16 LE/BE 文件会先尝试严格解码;失败后,`Read` 会将损坏序列替换为 `<60>` 并返回可读文本,同时在每一页提示发生了有损解码、文本可能与原文不同。告警也计入字符额度;有效文件中原本就有的 `<60>` 不会触发告警。图片和视频请使用 `ReadMediaFile`。
|
||||
|
||||
**`Write`** 接受 `path`、`content` 和可选的 `mode`(`overwrite` 或 `append`,默认覆盖)。缺失的父目录会自动创建;`append` 模式将内容追加到文件末尾,不自动添加换行。写入已存在的文件(无论 `overwrite` 还是 `append` 模式)要求本会话中先用 `Read` 读过该文件——若文件自上次读取后在磁盘上发生变化,写入会被拒绝;新建文件不受此限。
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
# commented "# field: type" lines describe the remaining schema fields.
|
||||
# Values resolve as: default -> config.toml -> env overlay -> memory.
|
||||
|
||||
# Index (27 sections · 2 overlay(s))
|
||||
# Index (28 sections · 2 overlay(s))
|
||||
# background src/agent/task/configSection.ts
|
||||
# builtinProductSkills src/features/skill/catalog/configSection.ts
|
||||
# cron src/features/cron/configSection.ts
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
# models src/app/kosongConfig/configSection.ts
|
||||
# permission src/agent/permissionRules/configSection.ts
|
||||
# providers src/app/kosongConfig/configSection.ts
|
||||
# read src/agent/tools/os/read/configSection.ts
|
||||
# secondaryModel src/session/subagent/configSection.ts
|
||||
# services src/app/auth/configSection.ts
|
||||
# subagent src/session/subagent/configSection.ts
|
||||
|
|
@ -338,6 +339,16 @@ merge_all_available_skills = true
|
|||
# env: record<string, string>
|
||||
# source: record<string, any>
|
||||
|
||||
# ##########################################################################
|
||||
# read
|
||||
# owner: src/agent/tools/os/read/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
[read]
|
||||
# default_max_chars: integer
|
||||
# max_chars: integer
|
||||
|
||||
# ##########################################################################
|
||||
# secondaryModel (config.toml: secondary_model)
|
||||
# owner: src/session/subagent/configSection.ts
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ If you need exact command output, file contents, error text, or the wording of a
|
|||
- Layout: one file per agent. agents/main/ is the main agent; each subagent has its own agents/<agentId>/wire.jsonl. A parent's log holds only the Agent tool call and the subagent's returned result — the subagent's own steps are in its own file.
|
||||
- Format: one JSON record per line, append-only; `type` says what it is. The conversation is in `context.append_message` (user prompts) and `context.append_loop_event` (event.type: step.begin | content.part [text|think] | tool.call | tool.result | step.end). Every other type (llm.request, usage.record, token_counting.measured, metadata, profile.bind, …) is bookkeeping — skip it.
|
||||
- Boundaries: `context.apply_compaction` marks a compaction (older lines stay in the file; grep for it to find exact boundaries). `context.undo` count=N retracts the previous N messages — treat retracted content as never having happened. `context.clear` resets the conversation.
|
||||
- Externalized content: tool results over 50k chars are stored truncated, with an `output_path` to a tool-results/*.txt file holding the full text. Media parts are blob references, not inline.
|
||||
- Reading: lines are long JSON (often 10k+ chars). Grep the file for a keyword to get line numbers, then Read exactly that line (line_offset=N, n_lines=1) — Read returns wire.jsonl lines whole up to ~150k chars. To pull one field with real newlines: sed -n 'Np' wire.jsonl | jq -r '.event.result.output'. Never Read large ranges — a handful of records can exceed the per-call byte cap.
|
||||
- Externalized content: tool results over 50k chars may contain an `output_path` pointing to saved output; check the result's preservation notice before assuming the file contains everything. Read results are bounded by their own character budget and are not spilled again. Media parts are blob references, not inline.
|
||||
- Reading: lines are long JSON (often 10k+ chars). Grep the file for a keyword to get line numbers, then Read exactly that line (line_offset=N, n_lines=1). Long records can span several Read results: follow Next Read with its column_offset until the line is complete, joining fragments without inserting newlines. To pull one field with real newlines when Bash is available: sed -n 'Np' wire.jsonl | jq -r '.event.result.output'. Prefer individual records over large ranges.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { registerConfigSection } from '#/app/config/configSectionContributions';
|
||||
|
||||
export const READ_SECTION = 'read';
|
||||
|
||||
export const ReadConfigSchema = z.object({
|
||||
defaultMaxChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type ReadConfig = z.infer<typeof ReadConfigSchema>;
|
||||
|
||||
registerConfigSection(READ_SECTION, ReadConfigSchema, { defaultValue: {} });
|
||||
|
|
@ -5,14 +5,17 @@ If the user provides a concrete file path to a text file, call Read directly. Do
|
|||
When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn.
|
||||
|
||||
- Relative paths resolve against the working directory; a path outside the working directory must be absolute.
|
||||
- Returns up to ${MAX_LINES} lines or ${MAX_BYTES_KB} KB per call, whichever comes first; lines longer than ${MAX_LINE_LENGTH} chars are truncated mid-line (recover the elided content with Bash, e.g. `cut` or `sed`).
|
||||
- Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the ${MAX_LINES}-line cap.
|
||||
- Kimi Code agent event logs (`wire.jsonl` under the sessions directory) are returned with whole lines (up to ~150k chars per record); read them one record at a time with `n_lines=1` after locating the line with Grep.
|
||||
- Returns text within `max_chars`, including line numbers and the status block, preferring complete lines. The configured default is ${DEFAULT_MAX_CHARS} characters; calls can request up to ${MAX_CHARS}. Characters use JavaScript string length, not UTF-8 bytes or tokens. Read results are not spilled or shortened again by the general tool-output limit.
|
||||
- Omit `n_lines` to read toward the end of the file. There is no fixed line-count cap. When the task requires the full text of a large file, request a larger `max_chars`, up to ${MAX_CHARS}, in the first call.
|
||||
- Page larger files with `line_offset` (1-based start line) and `n_lines`. If the result is incomplete, copy the `Next Read` arguments in the status block to continue without gaps or overlaps. Do not answer from a partial page when the task requires the remaining content.
|
||||
- If a single line cannot fit on its own page, Read returns a fragment and reports its column range. Continue on the same line with the supplied `column_offset`; do not insert a newline between fragments of one source line. A partial line still counts toward the remaining `n_lines` until its ending is returned.
|
||||
- `column_offset` is a zero-based position in the first line's displayed text, excluding its line-number prefix. It is supported only for forward reads. Offsets past the line or inside a Unicode surrogate pair return an error. Continuation refers to the current file contents; start a new read if the file changed.
|
||||
- Kimi Code agent event logs (`wire.jsonl` under the sessions directory) follow the same character budget; locate a record with Grep, read it with `n_lines=1`, and follow `Next Read` to retrieve every fragment of a long record.
|
||||
- Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally.
|
||||
- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and transcoded to UTF-8 for display; the status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused.
|
||||
- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed ${MAX_LINES}.
|
||||
- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and checked with strict decoding first. If malformed sequences are found, Read returns readable text with U+FFFD replacements and a lossy-decoding warning on every page; do not treat this view as exact original text. The status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused.
|
||||
- Negative `line_offset` reads from the end of the file (for example, -100 reads the last 100 lines). If the requested tail range exceeds the character budget, the newest complete lines in that range are returned first; `Next Read` covers the omitted earlier range. If no complete line fits, Read reports this and supplies forward `Next Read` arguments for the entire unread range. Omit `column_offset` when using a negative `line_offset`.
|
||||
- Output format: `<line-number>\t<content>` per line.
|
||||
- A `<system>...</system>` status block is appended after the file content; it summarizes how much was read (line and byte counts, truncation, line-ending notes) and is not part of the file itself.
|
||||
- A `<system>...</system>` status block is appended after the file content. It reports the actual returned range, total lines, effective character budget, whether the requested range is complete, and whether EOF was reached. The block is not part of the file itself.
|
||||
- Pure CRLF files are displayed with LF line endings; `Edit` matches this output and preserves CRLF when writing back.
|
||||
- Mixed or lone carriage-return line endings are shown as `\r` and require exact `Edit.old_string` escapes.
|
||||
- After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing.
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import { z } from 'zod';
|
|||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
import { type AgentTool } from '#/tool/toolContract';
|
||||
|
||||
export const MAX_LINES: number = 1000;
|
||||
export const MAX_LINE_LENGTH: number = 2000;
|
||||
export const MAX_BYTES: number = 100 * 1024;
|
||||
export const EVENT_LOG_MAX_LINE_LENGTH: number = 150_000;
|
||||
export const DEFAULT_MAX_CHARS = 100_000;
|
||||
export const DEFAULT_MAX_CHARS_LIMIT = 500_000;
|
||||
|
||||
export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024;
|
||||
|
||||
const PositiveLineOffsetSchema = z.number().int().min(1);
|
||||
const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1);
|
||||
const TailLineOffsetSchema = z.number().int().negative();
|
||||
|
||||
export const ReadInputSchema = z.object({
|
||||
path: z
|
||||
|
|
@ -23,16 +21,22 @@ export const ReadInputSchema = z.object({
|
|||
.union([PositiveLineOffsetSchema, TailLineOffsetSchema])
|
||||
.optional()
|
||||
.describe(
|
||||
`The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file; the absolute value cannot exceed ${String(MAX_LINES)}.`,
|
||||
'The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file (for example, -100 reads the last 100 lines).',
|
||||
),
|
||||
column_offset: z.number().int().nonnegative().optional().describe(
|
||||
'Zero-based character offset within the first line of a forward read, excluding its line-number prefix. Uses JavaScript string length in the displayed text. Copy continuation arguments from the previous result to resume a long line.',
|
||||
),
|
||||
n_lines: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
`The number of lines to read; the tool also applies its internal cap. Omit to read up to the internal cap of ${String(MAX_LINES)} lines.`,
|
||||
'The number of lines to read. Omit to read toward the end of the file. Results are bounded by max_chars, with continuation arguments when the requested range is incomplete.',
|
||||
),
|
||||
max_chars: z.number().int().positive().optional().describe(
|
||||
'Maximum characters in the returned text, including line numbers and status. Omit for the configured default; requests above the configured maximum are capped.',
|
||||
),
|
||||
});
|
||||
|
||||
export const ReadOutputSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView';
|
|||
import { unwrapErrorCause } from '#/_base/errors/errors';
|
||||
import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
|
||||
import {
|
||||
ToolAccesses,
|
||||
type ExecutableToolResult,
|
||||
|
|
@ -18,19 +20,18 @@ import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type';
|
|||
import { toInputJsonSchema } from '#/tool/input-schema';
|
||||
import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match';
|
||||
import { makeCarriageReturnsVisible, splitLinesKeepingTerminator, type LineEndingStyle } from '#/_base/text/line-endings';
|
||||
import { decodeUtfText, detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding';
|
||||
import { detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding';
|
||||
import { renderPrompt } from '#/_base/utils/render-prompt';
|
||||
import {
|
||||
EVENT_LOG_MAX_LINE_LENGTH,
|
||||
DEFAULT_MAX_CHARS,
|
||||
DEFAULT_MAX_CHARS_LIMIT,
|
||||
IReadTool,
|
||||
MAX_BYTES,
|
||||
MAX_LINE_LENGTH,
|
||||
MAX_LINES,
|
||||
ReadInputSchema,
|
||||
TRANSCODE_MAX_BYTES,
|
||||
type ReadInput,
|
||||
} from './read';
|
||||
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
|
||||
import { READ_SECTION, type ReadConfig } from './configSection';
|
||||
import readDescriptionTemplate from './read.md?raw';
|
||||
|
||||
interface LineEndingFlags {
|
||||
|
|
@ -44,39 +45,40 @@ interface ReadLineEntry {
|
|||
readonly rawContent: string;
|
||||
}
|
||||
|
||||
interface RenderedLine {
|
||||
readonly line: string;
|
||||
readonly wasTruncated: boolean;
|
||||
interface ReadTailEntry extends ReadLineEntry {
|
||||
readonly minChars: number;
|
||||
}
|
||||
|
||||
interface FinishReadResultInput {
|
||||
readonly renderedLines: readonly string[];
|
||||
readonly truncatedLineNumbers: readonly number[];
|
||||
readonly maxLinesReached: boolean;
|
||||
readonly maxBytesReached: boolean;
|
||||
readonly lineEndingStyle: LineEndingStyle;
|
||||
readonly startLine: number;
|
||||
readonly totalLines: number;
|
||||
readonly requestedLines: number;
|
||||
interface ReadRequest {
|
||||
readonly args: ReadInput;
|
||||
readonly maxChars: number;
|
||||
readonly maxCharsLimit: number;
|
||||
readonly detectedEncoding?: UtfTextEncoding;
|
||||
readonly lossyDecoding: boolean;
|
||||
readonly eventLog: boolean;
|
||||
}
|
||||
|
||||
function lineLengthLimit(eventLog: boolean): number {
|
||||
return eventLog ? EVENT_LOG_MAX_LINE_LENGTH : MAX_LINE_LENGTH;
|
||||
}
|
||||
|
||||
function truncateLine(line: string, maxLength: number): string {
|
||||
if (line.length <= maxLength) return line;
|
||||
const marker = '...';
|
||||
const target = Math.max(maxLength, marker.length);
|
||||
return line.slice(0, target - marker.length) + marker;
|
||||
interface ReadPage {
|
||||
readonly request: ReadRequest;
|
||||
readonly renderedLines: readonly string[];
|
||||
readonly startLine: number;
|
||||
readonly rangeStart: number;
|
||||
readonly rangeEnd: number;
|
||||
readonly totalLines: number;
|
||||
readonly fromTail: boolean;
|
||||
readonly lineEndingStyle: LineEndingStyle;
|
||||
}
|
||||
|
||||
function stripTrailingLf(line: string): string {
|
||||
return line.endsWith('\n') ? line.slice(0, -1) : line;
|
||||
}
|
||||
|
||||
function splitsSurrogatePair(text: string, offset: number): boolean {
|
||||
const previous = text.charCodeAt(offset - 1);
|
||||
const next = text.charCodeAt(offset);
|
||||
return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff;
|
||||
}
|
||||
|
||||
function updateLineEndingFlags(flags: LineEndingFlags, text: string): void {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.codePointAt(i);
|
||||
|
|
@ -99,62 +101,14 @@ function lineEndingStyleFromFlags(flags: LineEndingFlags): LineEndingStyle {
|
|||
return 'lf';
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
entry: ReadLineEntry,
|
||||
lineEndingStyle: LineEndingStyle,
|
||||
maxLineLength: number,
|
||||
): RenderedLine {
|
||||
function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): string {
|
||||
const modelContent =
|
||||
lineEndingStyle === 'crlf' && entry.rawContent.endsWith('\r')
|
||||
? entry.rawContent.slice(0, -1)
|
||||
: entry.rawContent;
|
||||
const truncated = truncateLine(modelContent, maxLineLength);
|
||||
const renderedContent =
|
||||
lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(truncated) : truncated;
|
||||
return {
|
||||
line: `${String(entry.lineNo)}\t${renderedContent}`,
|
||||
wasTruncated: truncated !== modelContent,
|
||||
};
|
||||
}
|
||||
|
||||
function renderedLineBytes(renderedLine: string, isFirst: boolean): number {
|
||||
return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8');
|
||||
}
|
||||
|
||||
function renderEntries(
|
||||
entries: readonly ReadLineEntry[],
|
||||
lineEndingStyle: LineEndingStyle,
|
||||
maxLineLength: number,
|
||||
): {
|
||||
renderedLines: string[];
|
||||
truncatedLineNumbers: number[];
|
||||
maxBytesReached: boolean;
|
||||
} {
|
||||
const renderedLines: string[] = [];
|
||||
const truncatedLineNumbers: number[] = [];
|
||||
let bytes = 0;
|
||||
let maxBytesReached = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
const rendered = renderLine(entry, lineEndingStyle, maxLineLength);
|
||||
const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0);
|
||||
if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) {
|
||||
maxBytesReached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (rendered.wasTruncated) {
|
||||
truncatedLineNumbers.push(entry.lineNo);
|
||||
}
|
||||
renderedLines.push(rendered.line);
|
||||
bytes += lineBytes;
|
||||
if (bytes >= MAX_BYTES) {
|
||||
maxBytesReached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { renderedLines, truncatedLineNumbers, maxBytesReached };
|
||||
lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(modelContent) : modelContent;
|
||||
return `${String(entry.lineNo)}\t${renderedContent}`;
|
||||
}
|
||||
|
||||
function isFileNotFoundError(error: unknown): boolean {
|
||||
|
|
@ -204,29 +158,42 @@ function notUtf8DecodableFileOutput(path: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
const READ_DESCRIPTION = renderPrompt(readDescriptionTemplate, {
|
||||
MAX_LINES,
|
||||
MAX_BYTES_KB: MAX_BYTES / 1024,
|
||||
MAX_LINE_LENGTH,
|
||||
});
|
||||
|
||||
export class ReadTool implements IReadTool {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly name = 'Read' as const;
|
||||
readonly description = READ_DESCRIPTION;
|
||||
get description(): string {
|
||||
const limits = this.limits();
|
||||
return renderPrompt(readDescriptionTemplate, {
|
||||
DEFAULT_MAX_CHARS: limits.defaultMaxChars,
|
||||
MAX_CHARS: limits.maxChars,
|
||||
});
|
||||
}
|
||||
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadInputSchema);
|
||||
constructor(
|
||||
@IAgentRuntimeService private readonly runtime: IAgentRuntimeService,
|
||||
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
|
||||
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
|
||||
@IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
) {}
|
||||
|
||||
private limits(): { defaultMaxChars: number; maxChars: number } {
|
||||
const section = this.config.get<ReadConfig | undefined>(READ_SECTION);
|
||||
const maxChars = section?.maxChars ?? DEFAULT_MAX_CHARS_LIMIT;
|
||||
return {
|
||||
defaultMaxChars: Math.min(section?.defaultMaxChars ?? DEFAULT_MAX_CHARS, maxChars),
|
||||
maxChars,
|
||||
};
|
||||
}
|
||||
|
||||
private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig {
|
||||
return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs };
|
||||
}
|
||||
|
||||
resolveExecution(args: ReadInput): ToolExecution {
|
||||
if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) {
|
||||
return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' };
|
||||
}
|
||||
const inspected = inspectAgentRuntime(this.runtime);
|
||||
const view = new RuntimeWorkspaceView(inspected, {
|
||||
workDir: this.workspaceCtx.workDir,
|
||||
|
|
@ -258,9 +225,7 @@ export class ReadTool implements IReadTool {
|
|||
}
|
||||
const eventLog = this.resultTruncation.isWireJournalPath(path);
|
||||
const result = await this.execution(lease.runtime.fs!, args, path, eventLog);
|
||||
return eventLog || this.resultTruncation.isSpillFilePath(path)
|
||||
? { ...result, spillExempt: true as const }
|
||||
: result;
|
||||
return { ...result, spillExempt: true };
|
||||
} finally {
|
||||
lease.dispose();
|
||||
}
|
||||
|
|
@ -298,8 +263,9 @@ export class ReadTool implements IReadTool {
|
|||
}
|
||||
|
||||
const detection = detectTextEncoding(header);
|
||||
let lines: AsyncIterable<string>;
|
||||
let readLines: () => AsyncIterable<string>;
|
||||
let detectedEncoding: UtfTextEncoding | undefined;
|
||||
let lossyDecoding = false;
|
||||
if (!detection.seemsBinary && detection.encoding !== 'utf-8') {
|
||||
if (stat.size > TRANSCODE_MAX_BYTES) {
|
||||
return {
|
||||
|
|
@ -310,42 +276,48 @@ export class ReadTool implements IReadTool {
|
|||
'Convert it to UTF-8 first (e.g. with `iconv`).',
|
||||
};
|
||||
}
|
||||
const decoded = decodeUtfText(await fs.readBytes(safePath), detection.encoding);
|
||||
const bytes = await fs.readBytes(safePath);
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder(detection.encoding, { fatal: true }).decode(bytes);
|
||||
} catch (error) {
|
||||
if (!isTextDecodeError(error)) throw error;
|
||||
decoded = new TextDecoder(detection.encoding, { fatal: false }).decode(bytes);
|
||||
lossyDecoding = true;
|
||||
}
|
||||
detectedEncoding = detection.encoding;
|
||||
lines = decodedLines(splitLinesKeepingTerminator(decoded));
|
||||
const decodedContent = splitLinesKeepingTerminator(decoded);
|
||||
readLines = () => decodedLines(decodedContent);
|
||||
} else if (fileType.kind === 'unknown') {
|
||||
return {
|
||||
isError: true,
|
||||
output: notReadableFileOutput(args.path),
|
||||
};
|
||||
} else {
|
||||
lines = fs.readLines(safePath, { errors: 'strict' });
|
||||
readLines = () => fs.readLines(safePath, { errors: 'strict' });
|
||||
}
|
||||
|
||||
const lineOffset = args.line_offset ?? 1;
|
||||
const requestedLines = args.n_lines ?? MAX_LINES;
|
||||
const effectiveLimit = Math.min(requestedLines, MAX_LINES);
|
||||
|
||||
if (lineOffset < 0) {
|
||||
return await this.readTail(
|
||||
args.path,
|
||||
lines,
|
||||
lineOffset,
|
||||
effectiveLimit,
|
||||
requestedLines,
|
||||
eventLog,
|
||||
detectedEncoding,
|
||||
);
|
||||
}
|
||||
return await this.readForward(
|
||||
args.path,
|
||||
lines,
|
||||
lineOffset,
|
||||
effectiveLimit,
|
||||
requestedLines,
|
||||
eventLog,
|
||||
const limits = this.limits();
|
||||
const request: ReadRequest = {
|
||||
args,
|
||||
maxChars: Math.min(args.max_chars ?? limits.defaultMaxChars, limits.maxChars),
|
||||
maxCharsLimit: limits.maxChars,
|
||||
detectedEncoding,
|
||||
);
|
||||
lossyDecoding,
|
||||
eventLog,
|
||||
};
|
||||
const lineOffset = args.line_offset ?? 1;
|
||||
if (lineOffset >= 0) return await this.readForward(readLines(), request);
|
||||
const rereadsFile = detectedEncoding === undefined && (args.n_lines ?? Infinity) < -lineOffset;
|
||||
const result = await this.readTail(readLines, request);
|
||||
if (!result.isError && rereadsFile) {
|
||||
const currentStat = await fs.stat(safePath);
|
||||
if (!currentStat.isFile || currentStat.size !== stat.size ||
|
||||
currentStat.mtimeMs !== stat.mtimeMs || currentStat.ino !== stat.ino) {
|
||||
return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isTextDecodeError(error)) {
|
||||
return { isError: true, output: notUtf8DecodableFileOutput(args.path) };
|
||||
|
|
@ -358,212 +330,250 @@ export class ReadTool implements IReadTool {
|
|||
}
|
||||
|
||||
private async readForward(
|
||||
displayPath: string,
|
||||
lines: AsyncIterable<string>,
|
||||
lineOffset: number,
|
||||
effectiveLimit: number,
|
||||
requestedLines: number,
|
||||
eventLog: boolean,
|
||||
detectedEncoding?: UtfTextEncoding,
|
||||
request: ReadRequest,
|
||||
): Promise<ExecutableToolResult> {
|
||||
const { args, maxChars } = request;
|
||||
const lineOffset = args.line_offset ?? 1;
|
||||
const columnOffset = args.column_offset ?? 0;
|
||||
const requestedLines = args.n_lines ?? Infinity;
|
||||
const selectedEntries: ReadLineEntry[] = [];
|
||||
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
|
||||
let currentLineNo = 0;
|
||||
let maxLinesReached = false;
|
||||
let collectionClosed = false;
|
||||
let minimumChars = 0;
|
||||
|
||||
for await (const rawLine of lines) {
|
||||
if (containsNulByte(rawLine)) {
|
||||
return { isError: true, output: notReadableFileOutput(displayPath) };
|
||||
return { isError: true, output: notReadableFileOutput(args.path) };
|
||||
}
|
||||
currentLineNo += 1;
|
||||
updateLineEndingFlags(flags, rawLine);
|
||||
if (collectionClosed) {
|
||||
if (effectiveLimit >= MAX_LINES && currentLineNo >= lineOffset) {
|
||||
maxLinesReached = true;
|
||||
}
|
||||
if (collectionClosed) continue;
|
||||
if (currentLineNo < lineOffset) continue;
|
||||
if (selectedEntries.length >= requestedLines) {
|
||||
collectionClosed = true;
|
||||
continue;
|
||||
}
|
||||
if (currentLineNo < lineOffset) continue;
|
||||
if (selectedEntries.length >= effectiveLimit) {
|
||||
if (effectiveLimit >= MAX_LINES) {
|
||||
maxLinesReached = true;
|
||||
}
|
||||
const rawContent = stripTrailingLf(rawLine);
|
||||
const lineChars = String(currentLineNo).length + 1 + Math.max(
|
||||
0,
|
||||
rawContent.length - (rawContent.endsWith('\r') ? 1 : 0) -
|
||||
(currentLineNo === lineOffset ? columnOffset : 0),
|
||||
) + (selectedEntries.length === 0 ? 0 : 1);
|
||||
if (minimumChars + lineChars > maxChars && selectedEntries.length > 0) {
|
||||
collectionClosed = true;
|
||||
continue;
|
||||
}
|
||||
selectedEntries.push({
|
||||
lineNo: currentLineNo,
|
||||
rawContent: stripTrailingLf(rawLine),
|
||||
rawContent,
|
||||
});
|
||||
if (selectedEntries.length >= effectiveLimit) {
|
||||
minimumChars += lineChars;
|
||||
if (selectedEntries.length >= requestedLines || minimumChars >= maxChars) {
|
||||
collectionClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const lineEndingStyle = lineEndingStyleFromFlags(flags);
|
||||
const rendered = renderEntries(selectedEntries, lineEndingStyle, lineLengthLimit(eventLog));
|
||||
|
||||
return this.finishReadResult({
|
||||
renderedLines: rendered.renderedLines,
|
||||
truncatedLineNumbers: rendered.truncatedLineNumbers,
|
||||
maxLinesReached,
|
||||
maxBytesReached: rendered.maxBytesReached,
|
||||
lineEndingStyle,
|
||||
startLine: selectedEntries.length > 0 ? lineOffset : 0,
|
||||
const renderedLines = selectedEntries.map((entry) => renderLine(entry, lineEndingStyle));
|
||||
const firstLine = renderedLines[0];
|
||||
if (columnOffset > 0) {
|
||||
const prefix = `${String(lineOffset)}\t`;
|
||||
const text = firstLine?.slice(prefix.length);
|
||||
if (text === undefined || columnOffset > text.length) {
|
||||
return { isError: true, output: `column_offset=${String(columnOffset)} is past the end of the starting line ${String(lineOffset)}. Read the line from column 0 to inspect its current contents.` };
|
||||
}
|
||||
if (splitsSurrogatePair(text, columnOffset)) {
|
||||
return { isError: true, output: `column_offset=${String(columnOffset)} splits a Unicode character in line ${String(lineOffset)}. Use a character boundary or the Next Read arguments.` };
|
||||
}
|
||||
renderedLines[0] = prefix + text.slice(columnOffset);
|
||||
}
|
||||
return this.finishPage({
|
||||
request,
|
||||
renderedLines,
|
||||
startLine: lineOffset,
|
||||
rangeStart: lineOffset,
|
||||
rangeEnd: Math.min(currentLineNo, lineOffset + requestedLines - 1),
|
||||
totalLines: currentLineNo,
|
||||
requestedLines,
|
||||
detectedEncoding,
|
||||
eventLog,
|
||||
fromTail: false,
|
||||
lineEndingStyle,
|
||||
});
|
||||
}
|
||||
|
||||
private finishPage(page: ReadPage): ExecutableToolResult {
|
||||
const { args, maxChars, maxCharsLimit, eventLog, detectedEncoding, lossyDecoding } = page.request;
|
||||
let first = 0;
|
||||
let end = page.renderedLines.length;
|
||||
let contentChars = page.renderedLines.reduce((sum, line) => sum + line.length + 1, -1);
|
||||
const firstColumn = page.fromTail ? 0 : args.column_offset ?? 0;
|
||||
const firstPrefix = `${String(page.startLine)}\t`;
|
||||
const firstText = page.renderedLines[0]?.slice(firstPrefix.length) ?? '';
|
||||
let fragmentEnd: number | undefined;
|
||||
|
||||
while (true) {
|
||||
const count = end - first;
|
||||
const startLine = page.startLine + first;
|
||||
const endLine = page.startLine + end - 1;
|
||||
const lineIncomplete = fragmentEnd !== undefined;
|
||||
const complete = page.rangeStart > page.rangeEnd ||
|
||||
(count > 0 && startLine === page.rangeStart && endLine === page.rangeEnd && !lineIncomplete);
|
||||
const parts = [
|
||||
count > 0
|
||||
? `${String(count)} ${count === 1 ? 'line' : 'lines'} read from file starting from line ${String(startLine)}.`
|
||||
: 'No lines read from file.',
|
||||
`Total lines in file: ${String(page.totalLines)}.`,
|
||||
complete ? 'Requested range complete.' : 'Character limit reached.',
|
||||
`Effective max_chars: ${String(maxChars)}.`,
|
||||
];
|
||||
if (!lineIncomplete && ((count > 0 && endLine === page.totalLines) || page.rangeStart > page.totalLines)) {
|
||||
parts.push('End of file reached.');
|
||||
}
|
||||
if (count > 0 && !page.fromTail && (firstColumn > 0 || fragmentEnd !== undefined)) {
|
||||
parts.push(`Line ${String(startLine)} fragment: columns [${String(firstColumn)}, ${String(firstColumn + (fragmentEnd ?? firstText.length))}) of ${String(firstColumn + firstText.length)}. ${lineIncomplete ? 'Line continues.' : 'Line complete.'}`);
|
||||
}
|
||||
if (args.max_chars !== undefined && args.max_chars > maxCharsLimit) {
|
||||
parts.push(`Requested max_chars=${String(args.max_chars)} was capped at the configured maximum ${String(maxCharsLimit)}.`);
|
||||
}
|
||||
if (!complete && (count > 0 || page.fromTail)) {
|
||||
const nextStart = page.fromTail ? page.rangeStart : lineIncomplete ? startLine : endLine + 1;
|
||||
const nextEnd = page.fromTail && count > 0 ? startLine - 1 : page.rangeEnd;
|
||||
const next = {
|
||||
path: args.path,
|
||||
line_offset: nextStart,
|
||||
column_offset: fragmentEnd !== undefined ? firstColumn + fragmentEnd : undefined,
|
||||
n_lines: page.fromTail || args.n_lines !== undefined ? nextEnd - nextStart + 1 : undefined,
|
||||
max_chars: maxChars,
|
||||
};
|
||||
parts.push(`Next Read: ${JSON.stringify(next)}`);
|
||||
}
|
||||
if (eventLog) {
|
||||
parts.push('Kimi Code agent event log: read one record at a time (n_lines=1); increase max_chars for a longer record or extract fields with Bash.');
|
||||
}
|
||||
if (page.lineEndingStyle === 'mixed') {
|
||||
parts.push('Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.');
|
||||
}
|
||||
if (detectedEncoding !== undefined) {
|
||||
parts.push(`Detected file encoding: ${encodingDisplayName(detectedEncoding)}; content transcoded to UTF-8 for display. Edit and Write expect UTF-8 — convert the file's encoding first (e.g. \`iconv\` via Bash).`);
|
||||
}
|
||||
if (lossyDecoding) {
|
||||
parts.push('Lossy UTF-16 decoding: malformed sequences were replaced with U+FFFD. The decoded text may differ from the original file.');
|
||||
}
|
||||
const note = `<system>${parts.join(' ')}</system>`;
|
||||
const renderedChars = count === 0
|
||||
? renderToolResultForModel({ output: '', note }).reduce(
|
||||
(sum, part) => sum + (part.type === 'text' ? part.text.length : 0),
|
||||
0,
|
||||
)
|
||||
: contentChars + 1 + note.length;
|
||||
if (renderedChars <= maxChars && (complete || count > 0)) {
|
||||
return {
|
||||
output: fragmentEnd !== undefined
|
||||
? firstPrefix + firstText.slice(0, fragmentEnd)
|
||||
: page.renderedLines.slice(first, end).join('\n'),
|
||||
note,
|
||||
truncated: complete ? undefined : true,
|
||||
};
|
||||
}
|
||||
if (count === 1 && !page.fromTail) {
|
||||
const previousEnd = fragmentEnd ?? firstText.length;
|
||||
fragmentEnd = Math.min(previousEnd - 1, previousEnd - (renderedChars - maxChars));
|
||||
if (splitsSurrogatePair(firstText, fragmentEnd)) fragmentEnd -= 1;
|
||||
if (fragmentEnd <= 0) {
|
||||
return { isError: true, output: `max_chars=${String(maxChars)} is too small for file text and the Read status. Increase max_chars.` };
|
||||
}
|
||||
contentChars = firstPrefix.length + fragmentEnd;
|
||||
continue;
|
||||
}
|
||||
if (count === 0) {
|
||||
if (!complete) {
|
||||
const recovery: ExecutableToolResult = {
|
||||
isError: true,
|
||||
output: 'No complete line fits. Continue with the forward Next Read.',
|
||||
note,
|
||||
truncated: true,
|
||||
};
|
||||
const recoveryChars = renderToolResultForModel(recovery).reduce(
|
||||
(sum, part) => sum + (part.type === 'text' ? part.text.length : 0),
|
||||
0,
|
||||
);
|
||||
if (recoveryChars <= maxChars) return recovery;
|
||||
}
|
||||
return { isError: true, output: `max_chars=${String(maxChars)} is too small for the Read status. Increase max_chars.` };
|
||||
}
|
||||
const dropped = page.fromTail ? first++ : --end;
|
||||
contentChars -= page.renderedLines[dropped]!.length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async readTail(
|
||||
displayPath: string,
|
||||
lines: AsyncIterable<string>,
|
||||
lineOffset: number,
|
||||
effectiveLimit: number,
|
||||
requestedLines: number,
|
||||
eventLog: boolean,
|
||||
detectedEncoding?: UtfTextEncoding,
|
||||
readLines: () => AsyncIterable<string>,
|
||||
request: ReadRequest,
|
||||
): Promise<ExecutableToolResult> {
|
||||
const tailCount = Math.abs(lineOffset);
|
||||
const entries: ReadLineEntry[] = [];
|
||||
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
|
||||
let currentLineNo = 0;
|
||||
|
||||
for await (const rawLine of lines) {
|
||||
if (containsNulByte(rawLine)) {
|
||||
return { isError: true, output: notReadableFileOutput(displayPath) };
|
||||
const { args, maxChars } = request;
|
||||
const lineOffset = args.line_offset ?? 1;
|
||||
const requestedLines = args.n_lines ?? Infinity;
|
||||
const tailCount = -lineOffset;
|
||||
const singlePass = requestedLines >= tailCount;
|
||||
let entries: (ReadTailEntry | undefined)[] = [];
|
||||
let first = 0;
|
||||
let chars = 0;
|
||||
const retainLine = (rawLine: string, lineNo: number): void => {
|
||||
const rawContent = stripTrailingLf(rawLine);
|
||||
const minChars = String(lineNo).length + 2 + rawContent.length - (rawContent.endsWith('\r') ? 1 : 0);
|
||||
entries.push({ lineNo, rawContent, minChars });
|
||||
chars += minChars;
|
||||
while (first < entries.length && (chars - 1 > maxChars || entries.length - first > tailCount)) {
|
||||
chars -= entries[first]!.minChars;
|
||||
entries[first++] = undefined;
|
||||
}
|
||||
currentLineNo += 1;
|
||||
updateLineEndingFlags(flags, rawLine);
|
||||
entries.push({
|
||||
lineNo: currentLineNo,
|
||||
rawContent: stripTrailingLf(rawLine),
|
||||
});
|
||||
if (entries.length > tailCount) {
|
||||
entries.shift();
|
||||
if (first > 1024 && first >= entries.length / 2) {
|
||||
entries = entries.slice(first);
|
||||
first = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return this.finishTailEntries({
|
||||
entries,
|
||||
lineEndingFlags: flags,
|
||||
effectiveLimit,
|
||||
totalLines: currentLineNo,
|
||||
requestedLines,
|
||||
eventLog,
|
||||
detectedEncoding,
|
||||
});
|
||||
}
|
||||
|
||||
private finishTailEntries(input: {
|
||||
entries: readonly ReadLineEntry[];
|
||||
lineEndingFlags: LineEndingFlags;
|
||||
effectiveLimit: number;
|
||||
totalLines: number;
|
||||
requestedLines: number;
|
||||
eventLog: boolean;
|
||||
detectedEncoding?: UtfTextEncoding;
|
||||
}): ExecutableToolResult {
|
||||
const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags);
|
||||
const maxLineLength = lineLengthLimit(input.eventLog);
|
||||
let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => {
|
||||
return { entry, rendered: renderLine(entry, lineEndingStyle, maxLineLength) };
|
||||
});
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const [index, candidate] of renderedCandidates.entries()) {
|
||||
totalBytes += renderedLineBytes(candidate.rendered.line, index === 0);
|
||||
}
|
||||
|
||||
let maxBytesReached = false;
|
||||
if (totalBytes > MAX_BYTES) {
|
||||
maxBytesReached = true;
|
||||
const kept: typeof renderedCandidates = [];
|
||||
let bytes = 0;
|
||||
for (let i = renderedCandidates.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = renderedCandidates[i];
|
||||
if (candidate === undefined) continue;
|
||||
const lineBytes = renderedLineBytes(candidate.rendered.line, kept.length === 0);
|
||||
if (kept.length > 0 && bytes + lineBytes > MAX_BYTES) break;
|
||||
kept.unshift(candidate);
|
||||
bytes += lineBytes;
|
||||
}
|
||||
renderedCandidates = kept;
|
||||
}
|
||||
|
||||
const renderedLines: string[] = [];
|
||||
const truncatedLineNumbers: number[] = [];
|
||||
for (const candidate of renderedCandidates) {
|
||||
renderedLines.push(candidate.rendered.line);
|
||||
if (candidate.rendered.wasTruncated) {
|
||||
truncatedLineNumbers.push(candidate.entry.lineNo);
|
||||
}
|
||||
}
|
||||
|
||||
return this.finishReadResult({
|
||||
renderedLines,
|
||||
truncatedLineNumbers,
|
||||
maxLinesReached: false,
|
||||
maxBytesReached,
|
||||
lineEndingStyle,
|
||||
startLine: renderedCandidates[0]?.entry.lineNo ?? 0,
|
||||
totalLines: input.totalLines,
|
||||
requestedLines: input.requestedLines,
|
||||
detectedEncoding: input.detectedEncoding,
|
||||
eventLog: input.eventLog,
|
||||
});
|
||||
}
|
||||
|
||||
private finishReadResult(input: FinishReadResultInput): ExecutableToolResult {
|
||||
return {
|
||||
output: input.renderedLines.join('\n'),
|
||||
note: `<system>${this.finishMessage(input)}</system>`,
|
||||
};
|
||||
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
|
||||
let totalLines = 0;
|
||||
for await (const rawLine of readLines()) {
|
||||
if (containsNulByte(rawLine)) {
|
||||
return { isError: true, output: notReadableFileOutput(args.path) };
|
||||
}
|
||||
totalLines += 1;
|
||||
updateLineEndingFlags(flags, rawLine);
|
||||
if (singlePass) retainLine(rawLine, totalLines);
|
||||
}
|
||||
|
||||
const rangeStart = Math.max(1, totalLines + lineOffset + 1);
|
||||
const rangeEnd = Math.min(totalLines, rangeStart + requestedLines - 1);
|
||||
const lineEndingStyle = lineEndingStyleFromFlags(flags);
|
||||
if (!singlePass) {
|
||||
let currentLine = 0;
|
||||
for await (const rawLine of readLines()) {
|
||||
currentLine += 1;
|
||||
if (currentLine > rangeEnd) break;
|
||||
if (currentLine < rangeStart) continue;
|
||||
if (containsNulByte(rawLine)) {
|
||||
return { isError: true, output: notReadableFileOutput(args.path) };
|
||||
}
|
||||
retainLine(rawLine, currentLine);
|
||||
}
|
||||
if (currentLine < rangeEnd || currentLine > totalLines) {
|
||||
return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' };
|
||||
}
|
||||
}
|
||||
const selected = entries.slice(first).map((entry) => renderLine(entry!, lineEndingStyle));
|
||||
return this.finishPage({
|
||||
request,
|
||||
renderedLines: selected,
|
||||
startLine: rangeEnd - selected.length + 1,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
totalLines,
|
||||
fromTail: true,
|
||||
lineEndingStyle,
|
||||
});
|
||||
}
|
||||
|
||||
private finishMessage(input: FinishReadResultInput): string {
|
||||
const lineCount = input.renderedLines.length;
|
||||
const lineWord = lineCount === 1 ? 'line' : 'lines';
|
||||
const parts =
|
||||
lineCount > 0
|
||||
? [
|
||||
`${String(lineCount)} ${lineWord} read from file starting from line ${String(input.startLine)}.`,
|
||||
]
|
||||
: ['No lines read from file.'];
|
||||
|
||||
parts.push(`Total lines in file: ${String(input.totalLines)}.`);
|
||||
if (input.maxLinesReached) {
|
||||
parts.push(`Max ${String(MAX_LINES)} lines reached.`);
|
||||
} else if (input.maxBytesReached) {
|
||||
parts.push(`Max ${String(MAX_BYTES)} bytes reached.`);
|
||||
} else if (lineCount < input.requestedLines) {
|
||||
parts.push('End of file reached.');
|
||||
}
|
||||
if (input.truncatedLineNumbers.length > 0) {
|
||||
parts.push(
|
||||
`Lines [${input.truncatedLineNumbers.join(', ')}] were truncated to ${String(lineLengthLimit(input.eventLog))} characters; use Bash (e.g. cut or sed) to read the elided content of those lines.`,
|
||||
);
|
||||
}
|
||||
if (input.eventLog) {
|
||||
parts.push(
|
||||
`Kimi Code agent event log: records are returned whole up to ${String(EVENT_LOG_MAX_LINE_LENGTH)} characters per line; read one record at a time (n_lines=1). For a longer record, extract fields with Bash: sed -n 'Np' <file> | jq. A primer on this format appears in your compaction note once a compaction has run.`,
|
||||
);
|
||||
}
|
||||
if (input.lineEndingStyle === 'mixed') {
|
||||
parts.push(
|
||||
'Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.',
|
||||
);
|
||||
}
|
||||
if (input.detectedEncoding !== undefined) {
|
||||
parts.push(
|
||||
`Detected file encoding: ${encodingDisplayName(input.detectedEncoding)}; content transcoded to UTF-8 for display. Edit and Write expect UTF-8 — convert the file's encoding first (e.g. \`iconv\` via Bash).`,
|
||||
);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
registerAgentToolService(IReadTool, ReadTool, {
|
||||
|
|
|
|||
|
|
@ -673,7 +673,7 @@ describe('FullCompaction', () => {
|
|||
event: 'compaction_finished',
|
||||
properties: expect.objectContaining({
|
||||
source: 'manual',
|
||||
tokens_before: 17_923,
|
||||
tokens_before: 18_510,
|
||||
retry_count: 1,
|
||||
trace_id: 'trace-compact-1',
|
||||
}),
|
||||
|
|
@ -1175,7 +1175,7 @@ describe('FullCompaction', () => {
|
|||
properties: expect.objectContaining({
|
||||
agent_id: 'main',
|
||||
source: 'manual',
|
||||
tokens_before: 17_923,
|
||||
tokens_before: 18_510,
|
||||
duration_ms: expect.any(Number),
|
||||
round: 1,
|
||||
retry_count: 0,
|
||||
|
|
@ -1400,7 +1400,7 @@ describe('FullCompaction', () => {
|
|||
event: 'compaction_failed',
|
||||
properties: expect.objectContaining({
|
||||
source: 'manual',
|
||||
tokens_before: 17_923,
|
||||
tokens_before: 18_510,
|
||||
duration_ms: expect.any(Number),
|
||||
retry_count: 4,
|
||||
error_type: 'APIConnectionError',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,5 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -34,6 +34,19 @@ import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorServi
|
|||
import { parseToolCallArguments } from '#/tool/tool-args-parse';
|
||||
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
|
||||
import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService';
|
||||
import { ReadTool } from '#/agent/tools/os/read/readTool';
|
||||
import { ReadInputSchema, type ReadInput } from '#/agent/tools/os/read/read';
|
||||
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
|
||||
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
|
||||
import { FakeRuntime } from '#/runtime/fakeRuntime';
|
||||
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
|
||||
import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
|
||||
import { stubWorkspaceContext } from '../../session/workspaceContext/stub-workspace-context';
|
||||
import { ConfigRegistry, ConfigService } from '#/app/config/configService';
|
||||
import { IConfigRegistry, IConfigService } from '#/app/config/config';
|
||||
import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { makeAgentScopeContext, IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
|
|
@ -43,7 +56,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry';
|
|||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { registerLogServices, stubLog } from '../../_base/log/stubs';
|
||||
import { stubBootstrap } from '../../app/bootstrap/stubs';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import { registerStateServices } from '../../state/stubs';
|
||||
|
|
@ -1028,6 +1041,7 @@ describe('parseToolCallArguments', () => {
|
|||
|
||||
describe('truncation pipeline', () => {
|
||||
let homeDir: string;
|
||||
let readConfig: IConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'tool-executor-truncation-'));
|
||||
|
|
@ -1047,6 +1061,30 @@ describe('truncation pipeline', () => {
|
|||
);
|
||||
const truncation = truncationContainer.get(IAgentToolResultTruncationService);
|
||||
truncateForModel = (input) => truncation.truncateForModel(input);
|
||||
truncationContainer.stub(ILogService, stubLog());
|
||||
truncationContainer.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
|
||||
truncationContainer.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
|
||||
truncationContainer.set(IConfigService, new SyncDescriptor(ConfigService));
|
||||
readConfig = truncationContainer.get(IConfigService);
|
||||
await readConfig.ready;
|
||||
const runtime = Object.assign(new FakeRuntime(
|
||||
{ workspaceId: 'workspace', runtimeId: 'local', generation: 'test' },
|
||||
{ capabilities: ['fs'] },
|
||||
), { fs: new HostFileSystem() });
|
||||
const binding: IAgentRuntimeService = {
|
||||
_serviceBrand: undefined,
|
||||
onDidChange: () => ({ dispose: () => {} }),
|
||||
isAvailable: () => true,
|
||||
inspect: () => runtime,
|
||||
acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }),
|
||||
};
|
||||
registry.register(new ReadTool(
|
||||
binding,
|
||||
stubWorkspaceContext(homeDir),
|
||||
{ catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog,
|
||||
truncation,
|
||||
readConfig,
|
||||
));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1169,6 +1207,90 @@ describe('truncation pipeline', () => {
|
|||
expect(result?.output).toBe(output);
|
||||
expect(result?.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delivers a bounded Read result above 50000 characters without replacing its text', async () => {
|
||||
const content = `${'x'.repeat(100)}\n`.repeat(650);
|
||||
const path = join(homeDir, 'paper.md');
|
||||
await writeFile(path, content);
|
||||
|
||||
const [result] = await execute([toolCall('call_read_paper', 'Read', { path })]);
|
||||
|
||||
expect(result?.isError).not.toBe(true);
|
||||
expect(typeof result?.output).toBe('string');
|
||||
if (typeof result?.output !== 'string') throw new TypeError('expected Read text');
|
||||
expect(result.output.length).toBeGreaterThan(50_000);
|
||||
expect(result.output.replaceAll(/^\d+\t/gm, '')).toBe(content.trimEnd());
|
||||
expect(result.truncated).toBeUndefined();
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(result.output).not.toContain('output_path:');
|
||||
});
|
||||
|
||||
it('recovers a large line through the model-facing Read pipeline without shell tools', async () => {
|
||||
const content = '0123456789'.repeat(110_000);
|
||||
const path = join(homeDir, 'record.jsonl');
|
||||
await writeFile(path, content);
|
||||
const fragments: string[] = [];
|
||||
let args: ReadInput | undefined = { path, n_lines: 1, max_chars: 100_000 };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 30; page += 1) {
|
||||
const [result] = await execute([toolCall(`read_fragment_${String(page)}`, 'Read', args)]);
|
||||
expect(result?.isError).not.toBe(true);
|
||||
if (typeof result?.output !== 'string') throw new TypeError('expected Read text');
|
||||
expect(result.output.startsWith('1\t')).toBe(true);
|
||||
const visible = renderToolResultForModel(result)
|
||||
.map((part) => part.type === 'text' ? part.text : '').join('');
|
||||
expect(visible.length).toBeLessThanOrEqual(100_000);
|
||||
if (page === 0) expect(result.output.length).toBeGreaterThan(50_000);
|
||||
fragments.push(result.output.slice(2));
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(fragments.length).toBeGreaterThan(10);
|
||||
expect(fragments.join('')).toBe(content);
|
||||
});
|
||||
|
||||
it('keeps valid lines readable and exposes the warning when later UTF-16 bytes are malformed', async () => {
|
||||
const path = join(homeDir, 'malformed.txt');
|
||||
await writeFile(path, Buffer.concat([
|
||||
Buffer.from([0xff, 0xfe]),
|
||||
Buffer.from('good\n', 'utf16le'),
|
||||
Buffer.from([0x00, 0xd8]),
|
||||
]));
|
||||
|
||||
const [result] = await execute([toolCall('read_lossy', 'Read', { path, n_lines: 1, max_chars: 1200 })]);
|
||||
|
||||
expect(result?.isError).not.toBe(true);
|
||||
expect(result?.output).toBe('1\tgood');
|
||||
if (result === undefined) throw new Error('expected a Read result');
|
||||
const visible = renderToolResultForModel(result)
|
||||
.map((part) => part.type === 'text' ? part.text : '').join('');
|
||||
expect(visible).toContain('Lossy UTF-16 decoding');
|
||||
expect(visible).toContain('may differ from the original file');
|
||||
expect(visible.length).toBeLessThanOrEqual(1200);
|
||||
});
|
||||
|
||||
it('applies persisted Read defaults and caps explicit character requests', async () => {
|
||||
await readConfig.set('read', { defaultMaxChars: 1500, maxChars: 3000 });
|
||||
await readConfig.reload();
|
||||
const path = join(homeDir, 'configured.md');
|
||||
await writeFile(path, `${'x'.repeat(100)}\n`.repeat(100));
|
||||
|
||||
const [defaultResult] = await execute([toolCall('read_default', 'Read', { path })]);
|
||||
const [largerResult] = await execute([toolCall('read_larger', 'Read', { path, max_chars: 10_000 })]);
|
||||
|
||||
expect(defaultResult?.isError).not.toBe(true);
|
||||
expect(largerResult?.isError).not.toBe(true);
|
||||
if (typeof defaultResult?.output !== 'string' || typeof largerResult?.output !== 'string') {
|
||||
throw new TypeError('expected Read text');
|
||||
}
|
||||
expect(defaultResult.output.length + 1 + (defaultResult.note?.length ?? 0)).toBeLessThanOrEqual(1500);
|
||||
expect(largerResult.output.length + 1 + (largerResult.note?.length ?? 0)).toBeLessThanOrEqual(3000);
|
||||
expect(largerResult.output.length).toBeGreaterThan(defaultResult.output.length);
|
||||
expect(largerResult.note).toContain('Requested max_chars=10000 was capped at the configured maximum 3000.');
|
||||
expect(readFileSync(join(homeDir, 'config.toml'), 'utf8')).toContain('default_max_chars = 1500');
|
||||
});
|
||||
});
|
||||
|
||||
function renderedOutputPath(output: string): string {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import '#/agent/permissionMode/configSection';
|
|||
import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection';
|
||||
import '#/agent/media/configSection';
|
||||
import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection';
|
||||
import { READ_SECTION } from '#/agent/tools/os/read/configSection';
|
||||
import '#/agent/tokenCounting/configSection';
|
||||
import {
|
||||
TOKEN_COUNTING_SECTION,
|
||||
|
|
@ -793,6 +794,20 @@ describe('defaultPermissionMode config section', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Read config section', () => {
|
||||
it('accepts positive character budgets and rejects invalid limits', () => {
|
||||
const registry = new ConfigRegistry();
|
||||
|
||||
expect(registry.validate(READ_SECTION, { defaultMaxChars: 200_000, maxChars: 750_000 }))
|
||||
.toEqual({ defaultMaxChars: 200_000, maxChars: 750_000 });
|
||||
expect(registry.validate(READ_SECTION, { maxChars: 1_000 })).toEqual({ maxChars: 1_000 });
|
||||
expect(() => registry.validate(READ_SECTION, { defaultMaxChars: 0 })).toThrow();
|
||||
expect(() => registry.validate(READ_SECTION, { maxChars: -1 })).toThrow();
|
||||
expect(() => registry.validate(READ_SECTION, { maxChars: 1.5 })).toThrow();
|
||||
expect(() => registry.validate(READ_SECTION, { maxChars: Infinity })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image config section', () => {
|
||||
it('registers the image section with an empty default and a positive-int schema', () => {
|
||||
const registry = new ConfigRegistry();
|
||||
|
|
|
|||
|
|
@ -6,16 +6,14 @@ import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog
|
|||
import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context';
|
||||
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
import {
|
||||
EVENT_LOG_MAX_LINE_LENGTH,
|
||||
MAX_BYTES,
|
||||
MAX_LINE_LENGTH,
|
||||
MAX_LINES,
|
||||
type ReadInput,
|
||||
ReadInputSchema,
|
||||
TRANSCODE_MAX_BYTES,
|
||||
} from '#/agent/tools/os/read/read';
|
||||
import { ReadTool } from '#/agent/tools/os/read/readTool';
|
||||
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
|
||||
import { stubToolResultTruncationService } from '../../../../agent/toolResultTruncation/stubs';
|
||||
import { stubConfigService } from '../../../../app/config/stubs';
|
||||
import type { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
|
||||
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
|
||||
import { FakeRuntime } from '#/runtime/fakeRuntime';
|
||||
|
|
@ -90,7 +88,7 @@ function createReadTool(
|
|||
inspect: () => runtime,
|
||||
acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }),
|
||||
};
|
||||
return new ReadTool(resolver, workspace, skillCatalog, truncation);
|
||||
return new ReadTool(resolver, workspace, skillCatalog, truncation, stubConfigService());
|
||||
}
|
||||
|
||||
function createSpiedFs(content: string) {
|
||||
|
|
@ -179,6 +177,182 @@ async function execute(tool: ReadTool, args: ReadInput): Promise<ExecutableToolR
|
|||
}
|
||||
|
||||
describe('ReadTool', () => {
|
||||
it('reads a 4621-line document in full when the requested character budget fits', async () => {
|
||||
const content = Array.from({ length: 4621 }, (_, index) =>
|
||||
`section ${String(index + 1)} ${'x'.repeat(55)}`,
|
||||
).join('\n');
|
||||
const tool = toolWithContent(content);
|
||||
|
||||
const result = await execute(tool, ReadInputSchema.parse({
|
||||
path: '/tmp/paper.md',
|
||||
max_chars: 500_000,
|
||||
}));
|
||||
const output = toolContentString(result);
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.split('\n')).toHaveLength(4621);
|
||||
expect(output.replaceAll(/^\d+\t/gm, '')).toBe(content);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(500_000);
|
||||
});
|
||||
|
||||
it('recovers a line above the maximum request size through fixed-budget column continuations', async () => {
|
||||
const content = Array.from({ length: 60_000 }, (_, index) => `${String(index).padStart(5, '0')}value`).join('');
|
||||
const tool = toolWithContent(content);
|
||||
const fragments: string[] = [];
|
||||
let args: ReadInput | undefined = { path: '/tmp/record.jsonl', line_offset: 1, n_lines: 1, max_chars: 100_000 };
|
||||
let consumed = 0;
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.startsWith('1\t')).toBe(true);
|
||||
expect(output).not.toContain('\n');
|
||||
const fragment = output.slice(2);
|
||||
expect(fragment.length).toBeGreaterThan(0);
|
||||
expect(fragment).toBe(content.slice(consumed, consumed + fragment.length));
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(100_000);
|
||||
expect(result.note).toContain(`columns [${String(consumed)}, ${String(consumed + fragment.length)})`);
|
||||
fragments.push(fragment);
|
||||
consumed += fragment.length;
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
if (args !== undefined) {
|
||||
expect(args).toMatchObject({ line_offset: 1, column_offset: consumed, n_lines: 1, max_chars: 100_000 });
|
||||
expect(result.note).not.toContain('End of file reached.');
|
||||
expect(result.truncated).toBe(true);
|
||||
} else {
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(result.note).toContain('End of file reached.');
|
||||
}
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(fragments.length).toBeGreaterThan(5);
|
||||
expect(fragments.join('')).toBe(content);
|
||||
});
|
||||
|
||||
it.each([650, 651])('keeps every Unicode fragment well formed with a %i-character budget', async (maxChars) => {
|
||||
const content = '文🙂'.repeat(400) + 'END';
|
||||
const tool = toolWithContent(content);
|
||||
const fragments: string[] = [];
|
||||
let args: ReadInput | undefined = { path: '/tmp/unicode.txt', n_lines: 1, max_chars: maxChars };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
expect(result.isError).not.toBe(true);
|
||||
const fragment = output.slice(2);
|
||||
expect(fragment.length).toBeGreaterThan(0);
|
||||
expect(Buffer.from(fragment, 'utf8').toString('utf8')).toBe(fragment);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(maxChars);
|
||||
fragments.push(fragment);
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(fragments.join('')).toBe(content);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ line_offset: 1, column_offset: 1 },
|
||||
{ line_offset: 1, column_offset: 100 },
|
||||
{ line_offset: 2, column_offset: 1 },
|
||||
{ line_offset: -1, column_offset: 1 },
|
||||
])('rejects an invalid starting column $column_offset at line $line_offset', async (offsets) => {
|
||||
const result = await execute(toolWithContent('🙂tail'), { path: '/tmp/column.txt', ...offsets });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toContain('column_offset');
|
||||
});
|
||||
|
||||
it('resumes mixed short and long lines without changing the requested ending line', async () => {
|
||||
const lines = ['outside before', `START${'🙂文'.repeat(400)}`, 'short target', 'last target '.repeat(150), 'outside after'];
|
||||
const tool = toolWithContent(lines.join('\n'));
|
||||
const returned = new Map<number, string>();
|
||||
let args: ReadInput | undefined = { path: '/tmp/mixed.txt', line_offset: 2, column_offset: 5, n_lines: 3, max_chars: 650 };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 50; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(650);
|
||||
expect(result.note).not.toContain('End of file reached.');
|
||||
for (const row of output.split('\n')) {
|
||||
const separator = row.indexOf('\t');
|
||||
const line = Number(row.slice(0, separator));
|
||||
expect([2, 3, 4]).toContain(line);
|
||||
const fragment = row.slice(separator + 1);
|
||||
expect(Buffer.from(fragment, 'utf8').toString('utf8')).toBe(fragment);
|
||||
returned.set(line, (returned.get(line) ?? '') + fragment);
|
||||
}
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
if (args !== undefined) expect(args.n_lines).toBe(5 - args.line_offset!);
|
||||
else expect(result.note).toContain('Requested range complete.');
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(returned.get(2)).toBe(lines[1]!.slice(5));
|
||||
expect(returned.get(3)).toBe(lines[2]);
|
||||
expect(returned.get(4)).toBe(lines[3]);
|
||||
});
|
||||
|
||||
it('pages through the requested range without losing text or exceeding the character budget', async () => {
|
||||
const lines = Array.from({ length: 20 }, (_, index) =>
|
||||
`section ${String(index + 1)} ${'文'.repeat(70)}`,
|
||||
);
|
||||
const tool = toolWithContent(lines.join('\n'));
|
||||
const contents: string[] = [];
|
||||
let args: ReadInput | undefined = { path: '/tmp/range.md', line_offset: 3, n_lines: 12, max_chars: 650 };
|
||||
let last: ExecutableToolResult | undefined;
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(650);
|
||||
contents.push(output.replaceAll(/^\d+\t/gm, ''));
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
last = result;
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(contents.length).toBeGreaterThan(1);
|
||||
expect(contents.join('\n')).toBe(lines.slice(2, 14).join('\n'));
|
||||
expect(last?.note).toContain('Requested range complete.');
|
||||
expect(last?.note).not.toContain('End of file reached.');
|
||||
});
|
||||
|
||||
it('returns the newest part of a tail range and lets the caller recover the omitted earlier lines', async () => {
|
||||
const lines = Array.from({ length: 20 }, (_, index) =>
|
||||
`section ${String(index + 1)} ${'x'.repeat(70)}`,
|
||||
);
|
||||
const tool = toolWithContent(lines.join('\n'));
|
||||
const returned: string[] = [];
|
||||
let args: ReadInput | undefined = { path: '/tmp/tail.md', line_offset: -15, n_lines: 10, max_chars: 650 };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(650);
|
||||
expect(result.note).not.toContain('End of file reached.');
|
||||
if (page === 0) expect(output.split('\n').at(-1)).toBe(`15\t${lines[14]}`);
|
||||
returned.push(...output.split('\n'));
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(returned).toHaveLength(10);
|
||||
expect(returned.toSorted((left, right) => Number(left.split('\t')[0]) - Number(right.split('\t')[0]))
|
||||
.map((line) => line.replace(/^\d+\t/, '')).join('\n')).toBe(lines.slice(5, 15).join('\n'));
|
||||
});
|
||||
|
||||
it('exposes current metadata and schema', () => {
|
||||
const tool = toolWithContent('');
|
||||
|
||||
|
|
@ -197,7 +371,7 @@ describe('ReadTool', () => {
|
|||
false,
|
||||
);
|
||||
expect(
|
||||
ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: -(MAX_LINES + 1) }).success,
|
||||
ReadInputSchema.safeParse({ path: '/tmp/test.txt', max_chars: 0 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -215,10 +389,10 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/tmp/a.txt' });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
output: '1\talpha\n2\tbeta',
|
||||
note: readNote(
|
||||
'2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.',
|
||||
'2 lines read from file starting from line 1. Total lines in file: 2. Requested range complete. Effective max_chars: 100000. End of file reached.',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
|
@ -241,7 +415,7 @@ describe('ReadTool', () => {
|
|||
expect(result.output).toBe(['1\talpha', '2\tbeta'].join('\n'));
|
||||
expect(result.note).toBe(
|
||||
readNote(
|
||||
'2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.',
|
||||
'2 lines read from file starting from line 1. Total lines in file: 2. Requested range complete. Effective max_chars: 100000. End of file reached.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -254,7 +428,7 @@ describe('ReadTool', () => {
|
|||
expect(result.output).toBe(['1\talpha\\r', '2\tbeta', '3\tgamma\\rdone'].join('\n'));
|
||||
expect(result.note).toBe(
|
||||
readNote(
|
||||
'3 lines read from file starting from line 1. Total lines in file: 3. End of file reached. Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.',
|
||||
'3 lines read from file starting from line 1. Total lines in file: 3. Requested range complete. Effective max_chars: 100000. End of file reached. Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -264,9 +438,9 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: 2, n_lines: 2 });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
output: '2\tb\n3\tc',
|
||||
note: readNote('2 lines read from file starting from line 2. Total lines in file: 5.'),
|
||||
note: readNote('2 lines read from file starting from line 2. Total lines in file: 5. Requested range complete. Effective max_chars: 100000.'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -275,9 +449,9 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: 20 });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
output: '',
|
||||
note: readNote('No lines read from file. Total lines in file: 2. End of file reached.'),
|
||||
note: readNote('No lines read from file. Total lines in file: 2. Requested range complete. Effective max_chars: 100000. End of file reached.'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -286,14 +460,25 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: -3 });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
output: '3\tc\n4\td\n5\te',
|
||||
note: readNote(
|
||||
'3 lines read from file starting from line 3. Total lines in file: 5. End of file reached.',
|
||||
'3 lines read from file starting from line 3. Total lines in file: 5. Requested range complete. Effective max_chars: 100000. End of file reached.',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, 3, 5])('reads the last three lines in one file scan with n_lines=%s', async (nLines) => {
|
||||
const { fs, readLines } = createSpiedFs('a\nb\nc\nd\ne');
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/tail.log', line_offset: -3, n_lines: nLines });
|
||||
|
||||
expect(result.output).toBe('3\tc\n4\td\n5\te');
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(readLines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies n_lines from the start of the negative line_offset tail window', async () => {
|
||||
const tool = toolWithContent('a\nb\nc\nd\ne');
|
||||
|
||||
|
|
@ -301,10 +486,100 @@ describe('ReadTool', () => {
|
|||
|
||||
expect(result.output).toBe('1\ta\n2\tb');
|
||||
expect(result.note).toBe(
|
||||
readNote('2 lines read from file starting from line 1. Total lines in file: 5.'),
|
||||
readNote('2 lines read from file starting from line 1. Total lines in file: 5. Requested range complete. Effective max_chars: 100000.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a partial tail range when the file grows during reading', async () => {
|
||||
let content = 'a\nb\nc\n';
|
||||
const { fs, readLines, stat } = createSpiedFs(content);
|
||||
stat.mockImplementation(async () => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
size: Buffer.byteLength(content),
|
||||
}));
|
||||
readLines.mockImplementation(async function* () {
|
||||
yield* generateLines(content);
|
||||
content += 'd\n';
|
||||
});
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/growing.log', line_offset: -3, n_lines: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
output: 'File changed while reading its tail. Retry Read with the updated file.',
|
||||
});
|
||||
expect(result.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns newly appended lines observed during a single tail scan', async () => {
|
||||
let content = 'a\nb\nc\n';
|
||||
const { fs, readLines, stat } = createSpiedFs(content);
|
||||
stat.mockImplementation(async () => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
size: Buffer.byteLength(content),
|
||||
}));
|
||||
readLines.mockImplementation(async function* () {
|
||||
yield 'a\n';
|
||||
content += 'd\n';
|
||||
yield* generateLines(content.slice(2));
|
||||
});
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/growing.log', line_offset: -1 });
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe('4\td');
|
||||
expect(result.note).toContain('Total lines in file: 4.');
|
||||
expect(result.note).toContain('End of file reached.');
|
||||
expect(readLines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a tail reread that observes a line beyond the counted EOF', async () => {
|
||||
const { fs, readLines } = createSpiedFs('a\nb\nc\n');
|
||||
readLines.mockReturnValueOnce(generateLines('a\nb\nc\n'))
|
||||
.mockReturnValueOnce(generateLines('a\nb\nc\nd\n'));
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/growing.log', line_offset: -10, n_lines: 3 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
output: 'File changed while reading its tail. Retry Read with the updated file.',
|
||||
});
|
||||
expect(result.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ change: 'same-size edit', mtimeMs: 2, ino: 1 },
|
||||
{ change: 'file replacement', mtimeMs: 1, ino: 2 },
|
||||
])('rejects a partial tail range after a $change', async ({ mtimeMs, ino }) => {
|
||||
let changed = false;
|
||||
const { fs, readLines, stat } = createSpiedFs('a\nb\n');
|
||||
stat.mockImplementation(async () => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
size: 4,
|
||||
mtimeMs: changed ? mtimeMs : 1,
|
||||
ino: changed ? ino : 1,
|
||||
}));
|
||||
readLines.mockImplementation(async function* () {
|
||||
yield* generateLines(changed ? 'x\ny\n' : 'a\nb\n');
|
||||
changed = true;
|
||||
});
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/changing.log', line_offset: -2, n_lines: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
output: 'File changed while reading its tail. Retry Read with the updated file.',
|
||||
});
|
||||
expect(result.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects relative traversal before reading', async () => {
|
||||
const { fs, readText } = createSpiedFs('secret');
|
||||
const tool = createReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace/project'));
|
||||
|
|
@ -344,7 +619,7 @@ describe('ReadTool', () => {
|
|||
expect(result.output).toBe('1\texternal');
|
||||
expect(result.note).toBe(
|
||||
readNote(
|
||||
'1 line read from file starting from line 1. Total lines in file: 1. End of file reached.',
|
||||
'1 line read from file starting from line 1. Total lines in file: 1. Requested range complete. Effective max_chars: 100000. End of file reached.',
|
||||
),
|
||||
);
|
||||
expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES);
|
||||
|
|
@ -357,7 +632,7 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/workspace/missing.txt' });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
output: '"/workspace/missing.txt" does not exist.',
|
||||
});
|
||||
|
|
@ -373,7 +648,7 @@ describe('ReadTool', () => {
|
|||
|
||||
const result = await execute(tool, { path: '/workspace/src' });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
output: '"/workspace/src" is not a file.',
|
||||
});
|
||||
|
|
@ -390,7 +665,7 @@ describe('ReadTool', () => {
|
|||
expect(result.output).toBe('1\thome note');
|
||||
expect(result.note).toBe(
|
||||
readNote(
|
||||
'1 line read from file starting from line 1. Total lines in file: 1. End of file reached.',
|
||||
'1 line read from file starting from line 1. Total lines in file: 1. Requested range complete. Effective max_chars: 100000. End of file reached.',
|
||||
),
|
||||
);
|
||||
expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES);
|
||||
|
|
@ -547,6 +822,84 @@ describe('ReadTool', () => {
|
|||
expect(output).not.toContain('encoded data was not valid');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['utf16-le-unpaired', [0xff, 0xfe, 0x00, 0xd8]],
|
||||
['utf16-be-unpaired', [0xfe, 0xff, 0xd8, 0x00]],
|
||||
['utf16-le-truncated', [0xff, 0xfe, 0x41]],
|
||||
] as const)('returns malformed %s with an explicit lossy-decoding warning', async (name, bytes) => {
|
||||
const path = `/tmp/${name}.txt`;
|
||||
const { fs } = createSpiedMapFs({ [path]: { bytes: Buffer.from(bytes) } });
|
||||
const result = await execute(createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE), { path });
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe('1\t\uFFFD');
|
||||
expect(result.note).toContain('Lossy UTF-16 decoding');
|
||||
expect(result.note).toContain('may differ from the original file');
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
});
|
||||
|
||||
it.each(['utf8', 'utf16le'] as const)('does not flag a literal replacement character in valid %s text', async (encoding) => {
|
||||
const content = 'valid \uFFFD and 🙂';
|
||||
const encoded = Buffer.from(content, encoding);
|
||||
const bytes = encoding === 'utf16le' ? Buffer.concat([Buffer.from([0xff, 0xfe]), encoded]) : encoded;
|
||||
const { fs } = createSpiedMapFs({ '/tmp/literal.txt': { bytes } });
|
||||
const result = await execute(createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE), { path: '/tmp/literal.txt' });
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe(`1\t${content}`);
|
||||
expect(result.note).not.toContain('Lossy');
|
||||
});
|
||||
|
||||
it.each([2, -2])('preserves the lossy warning and budget through Read continuation from offset %i', async (lineOffset) => {
|
||||
const rawLine = '文'.repeat(1000) + '\uD800' + '🙂'.repeat(500) + 'tail';
|
||||
const expected = '文'.repeat(1000) + '\uFFFD' + '🙂'.repeat(500) + 'tail';
|
||||
const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(`first\n${rawLine}\nlast`, 'utf16le')]);
|
||||
const path = '/tmp/lossy-range.txt';
|
||||
const { fs } = createSpiedMapFs({ [path]: { bytes } });
|
||||
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
|
||||
const fragments: string[] = [];
|
||||
let args: ReadInput | undefined = { path, line_offset: lineOffset, n_lines: 1, max_chars: 1200 };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
expect(result.note).toContain('Lossy UTF-16 decoding');
|
||||
if (result.isError) {
|
||||
expect(lineOffset).toBe(-2);
|
||||
expect(page).toBe(0);
|
||||
expect(result.note).toContain('Next Read:');
|
||||
} else {
|
||||
const output = toolContentString(result);
|
||||
expect(output.startsWith('2\t')).toBe(true);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(1200);
|
||||
fragments.push(output.slice(2));
|
||||
}
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(fragments.length).toBeGreaterThan(1);
|
||||
expect(fragments.join('')).toBe(expected);
|
||||
});
|
||||
|
||||
it('fits a lossy tail recovery hint within the model-visible character budget', async () => {
|
||||
const path = '/tmp/edge.txt';
|
||||
const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('x'.repeat(2000) + '\uD800', 'utf16le')]);
|
||||
const { fs } = createSpiedMapFs({ [path]: { bytes } });
|
||||
const result = await execute(createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE), {
|
||||
path,
|
||||
line_offset: -1,
|
||||
max_chars: 650,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.note).toContain('Lossy UTF-16 decoding');
|
||||
expect(result.note).toContain('Next Read:');
|
||||
const visible = renderToolResultForModel(result)
|
||||
.map((part) => part.type === 'text' ? part.text : '').join('');
|
||||
expect(visible.length).toBeLessThanOrEqual(650);
|
||||
});
|
||||
|
||||
it('reads a UTF-16 LE file with BOM by transcoding to UTF-8', async () => {
|
||||
const bytes = Buffer.concat([
|
||||
Buffer.from([0xff, 0xfe]),
|
||||
|
|
@ -637,18 +990,55 @@ describe('ReadTool', () => {
|
|||
expect(output).toContain('too large to transcode');
|
||||
});
|
||||
|
||||
it('truncates long lines and surfaces the affected line numbers', async () => {
|
||||
const long = 'x'.repeat(MAX_LINE_LENGTH + 10);
|
||||
it('returns long lines whole without losing Unicode characters', async () => {
|
||||
const long = '文'.repeat(5_000) + '🙂END';
|
||||
const tool = toolWithContent([long, 'short', long].join('\n'));
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/long.txt' });
|
||||
|
||||
expect(result.note).toContain('Lines [1, 3] were truncated to 2000 characters; use Bash (e.g. cut or sed) to read the elided content of those lines.');
|
||||
expect(result.output).toContain('...');
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe(`1\t${long}\n2\tshort\n3\t${long}`);
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(result.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([2, -3])('recovers the entire oversized range at offset %i using only Read', async (lineOffset) => {
|
||||
const long = '文'.repeat(700) + '🙂END';
|
||||
const tool = toolWithContent(['outside before', 'target head', long, 'outside after'].join('\n'));
|
||||
const returned = new Map<number, string>();
|
||||
let args: ReadInput | undefined = { path: '/tmp/line.txt', line_offset: lineOffset, n_lines: 2, max_chars: 650 };
|
||||
|
||||
for (let page = 0; args !== undefined && page < 20; page += 1) {
|
||||
const result = await execute(tool, args);
|
||||
const output = toolContentString(result);
|
||||
if (result.isError) {
|
||||
expect(lineOffset).toBe(-3);
|
||||
expect(page).toBe(0);
|
||||
expect(output).not.toContain('Bash');
|
||||
expect(result.note).toContain('Next Read:');
|
||||
} else {
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(650);
|
||||
for (const row of output.split('\n')) {
|
||||
const separator = row.indexOf('\t');
|
||||
const line = Number(row.slice(0, separator));
|
||||
expect([2, 3]).toContain(line);
|
||||
returned.set(line, (returned.get(line) ?? '') + row.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
expect(result.note).not.toContain('End of file reached.');
|
||||
const next = result.note?.match(/Next Read: (\{[^\n]*\})/);
|
||||
args = next === undefined || next === null ? undefined : ReadInputSchema.parse(JSON.parse(next[1]!));
|
||||
if (lineOffset < 0 && page === 0) {
|
||||
expect(args).toMatchObject({ line_offset: 2, n_lines: 2, max_chars: 650 });
|
||||
}
|
||||
}
|
||||
|
||||
expect(args).toBeUndefined();
|
||||
expect(returned.get(2)).toBe('target head');
|
||||
expect(returned.get(3)).toBe(long);
|
||||
});
|
||||
|
||||
it('returns agent event log lines untruncated and marks the read spill-exempt', async () => {
|
||||
const long = 'x'.repeat(MAX_LINE_LENGTH + 10);
|
||||
const long = 'x'.repeat(2_000 + 10);
|
||||
const truncation: IAgentToolResultTruncationService = {
|
||||
...stubToolResultTruncationService(),
|
||||
isWireJournalPath: (path) => path.endsWith('/wire.jsonl'),
|
||||
|
|
@ -673,84 +1063,67 @@ describe('ReadTool', () => {
|
|||
expect(result.spillExempt).toBe(true);
|
||||
});
|
||||
|
||||
it('caps a single event log record and reports the cap in the note', async () => {
|
||||
const huge = 'y'.repeat(EVENT_LOG_MAX_LINE_LENGTH + 100);
|
||||
it('reads a whole long event log record with an explicit character budget', async () => {
|
||||
const huge = 'y'.repeat(150_100);
|
||||
const truncation: IAgentToolResultTruncationService = {
|
||||
...stubToolResultTruncationService(),
|
||||
isWireJournalPath: (path) => path.endsWith('/wire.jsonl'),
|
||||
};
|
||||
const tool = createReadTool(
|
||||
createSpiedFs(`${huge}\nshort`).fs,
|
||||
createTestEnv(),
|
||||
PERMISSIVE_WORKSPACE,
|
||||
undefined,
|
||||
truncation,
|
||||
);
|
||||
|
||||
const tool = createReadTool(createSpiedFs(`${huge}\nshort`).fs, createTestEnv(), PERMISSIVE_WORKSPACE, undefined, truncation);
|
||||
const result = await execute(tool, {
|
||||
path: '/home/user/.kimi-code/sessions/ws/session/agents/main/wire.jsonl',
|
||||
line_offset: 1,
|
||||
n_lines: 1,
|
||||
max_chars: 500_000,
|
||||
});
|
||||
const output = toolContentString(result);
|
||||
|
||||
expect(output.length).toBeLessThanOrEqual(EVENT_LOG_MAX_LINE_LENGTH + 20);
|
||||
expect(output).toContain('...');
|
||||
expect(result.note).toContain(`truncated to ${String(EVENT_LOG_MAX_LINE_LENGTH)} characters`);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe(`1\t${huge}`);
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(result.spillExempt).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the last oversized event log record when reading from the tail', async () => {
|
||||
const huge = 'z'.repeat(MAX_BYTES + 5_000);
|
||||
const truncation: IAgentToolResultTruncationService = {
|
||||
...stubToolResultTruncationService(),
|
||||
isWireJournalPath: (path) => path.endsWith('/wire.jsonl'),
|
||||
};
|
||||
const tool = createReadTool(
|
||||
createSpiedFs(`first\n${huge}`).fs,
|
||||
createTestEnv(),
|
||||
PERMISSIVE_WORKSPACE,
|
||||
undefined,
|
||||
truncation,
|
||||
);
|
||||
|
||||
it('returns a whole large final event log record within the requested character budget', async () => {
|
||||
const huge = 'z'.repeat(105_000);
|
||||
const tool = toolWithContent(`first\n${huge}`);
|
||||
const result = await execute(tool, {
|
||||
path: '/home/user/.kimi-code/sessions/ws/session/agents/main/wire.jsonl',
|
||||
path: '/tmp/wire.jsonl',
|
||||
line_offset: -1,
|
||||
max_chars: 500_000,
|
||||
});
|
||||
const output = toolContentString(result);
|
||||
|
||||
expect(output).toContain('z'.repeat(1_000));
|
||||
expect(result.note).not.toContain('No lines read');
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe(`2\t${huge}`);
|
||||
expect(result.note).toContain('End of file reached.');
|
||||
});
|
||||
|
||||
it('keeps truncating long lines when the truncation service does not recognize the path', async () => {
|
||||
const long = 'x'.repeat(MAX_LINE_LENGTH + 10);
|
||||
it('uses the same whole-line and spill policy for ordinary paths', async () => {
|
||||
const long = 'x'.repeat(2_010);
|
||||
const tool = toolWithContent([long, 'short'].join('\n'));
|
||||
const result = await execute(tool, { path: '/tmp/ordinary.txt' });
|
||||
|
||||
const result = await execute(tool, {
|
||||
path: '/home/user/.kimi-code/sessions/ws/session/agents/main/wire.jsonl',
|
||||
});
|
||||
|
||||
expect(result.note).toContain('Lines [1] were truncated to 2000 characters');
|
||||
expect(result.spillExempt).toBeUndefined();
|
||||
expect(result.output).toBe(`1\t${long}\n2\tshort`);
|
||||
expect(result.truncated).toBeUndefined();
|
||||
expect(result.spillExempt).toBe(true);
|
||||
});
|
||||
|
||||
it('checks the byte cap before adding the next rendered line', async () => {
|
||||
const line = 'x'.repeat(MAX_LINE_LENGTH);
|
||||
const content = Array.from({ length: 80 }, () => line).join('\n');
|
||||
const tool = toolWithContent(content);
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/bytes.txt' });
|
||||
it('fits complete lines and status within the default character budget', async () => {
|
||||
const line = '文'.repeat(2_000);
|
||||
const tool = toolWithContent(Array.from({ length: 80 }, () => line).join('\n'));
|
||||
const result = await execute(tool, { path: '/tmp/characters.txt' });
|
||||
const output = toolContentString(result);
|
||||
|
||||
expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(MAX_BYTES);
|
||||
expect(result.note).toContain(`Max ${String(MAX_BYTES)} bytes reached.`);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(100_000);
|
||||
expect(Buffer.byteLength(output, 'utf8')).toBeGreaterThan(100_000);
|
||||
expect(output.split('\n').every((entry) => entry.replace(/^\d+\t/, '') === line)).toBe(true);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.note).toContain('Next Read:');
|
||||
});
|
||||
|
||||
it('reads through bounded byte preflight and streams line iteration without full readText', async () => {
|
||||
const bytes = Buffer.from(
|
||||
Array.from({ length: MAX_LINES + 5 }, (_, i) => `line ${String(i + 1)}`).join('\n'),
|
||||
Array.from({ length: 1_000 + 5 }, (_, i) => `line ${String(i + 1)}`).join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
const readText = vi.fn(async () => {
|
||||
|
|
@ -758,7 +1131,7 @@ describe('ReadTool', () => {
|
|||
});
|
||||
let consumed = 0;
|
||||
const readLines = vi.fn().mockImplementation(async function* (): AsyncGenerator<string> {
|
||||
for (let i = 1; i <= MAX_LINES + 5; i += 1) {
|
||||
for (let i = 1; i <= 1_000 + 5; i += 1) {
|
||||
consumed = i;
|
||||
yield `line ${String(i)}\n`;
|
||||
}
|
||||
|
|
@ -775,29 +1148,26 @@ describe('ReadTool', () => {
|
|||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(output).toContain('1\tline 1');
|
||||
expect(output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`);
|
||||
expect(result.note).toContain(`Total lines in file: ${String(MAX_LINES + 5)}.`);
|
||||
expect(result.note).toContain(`Max ${String(MAX_LINES)} lines reached.`);
|
||||
expect(consumed).toBe(MAX_LINES + 5);
|
||||
expect(output).toContain(`${String(1_000)}\tline ${String(1_000)}`);
|
||||
expect(result.note).toContain(`Total lines in file: ${String(1_000 + 5)}.`);
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(consumed).toBe(1_000 + 5);
|
||||
expect(readBytes).toHaveBeenCalledWith('/tmp/large.txt', MEDIA_SNIFF_BYTES);
|
||||
expect(readText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caps default reads at MAX_LINES', async () => {
|
||||
const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join(
|
||||
'\n',
|
||||
);
|
||||
const tool = toolWithContent(content);
|
||||
it('reads beyond 1000 lines by default when the character budget fits', async () => {
|
||||
const content = Array.from({ length: 1001 }, (_, i) => `line ${String(i + 1)}`).join('\n');
|
||||
const result = await execute(toolWithContent(content), { path: '/tmp/big.txt' });
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/big.txt' });
|
||||
|
||||
expect(result.note).toContain(`Max ${String(MAX_LINES)} lines reached.`);
|
||||
expect(result.output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`);
|
||||
expect(result.output).not.toContain(`${String(MAX_LINES + 1)}\tline ${String(MAX_LINES + 1)}`);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toContain('1001\tline 1001');
|
||||
expect(result.note).toContain('Requested range complete.');
|
||||
expect(result.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tail byte truncation keeps the newest lines closest to EOF', async () => {
|
||||
const numLines = Math.floor(MAX_BYTES / 1001) + 20;
|
||||
it('tail character pagination keeps the newest lines closest to EOF', async () => {
|
||||
const numLines = Math.floor(100_000 / 1001) + 20;
|
||||
const content = Array.from({ length: numLines }, (_, i) => {
|
||||
return `${String(i + 1).padStart(4, '0')}${'B'.repeat(996)}`;
|
||||
}).join('\n');
|
||||
|
|
@ -807,12 +1177,12 @@ describe('ReadTool', () => {
|
|||
const output = toolContentString(result);
|
||||
const outputLines = output.split('\n').filter((line) => line.includes('\t'));
|
||||
|
||||
expect(result.note).toContain(`Max ${String(MAX_BYTES)} bytes reached.`);
|
||||
expect(result.note).toContain('Character limit reached.');
|
||||
expect(outputLines.at(-1)).toContain(String(numLines).padStart(4, '0'));
|
||||
expect(outputLines[0]).not.toContain('0001');
|
||||
});
|
||||
|
||||
it('tail n_lines is applied before byte truncation', async () => {
|
||||
it('tail n_lines is applied before character pagination', async () => {
|
||||
const numLines = 500;
|
||||
const content = Array.from({ length: numLines }, (_, i) => {
|
||||
return `${String(i + 1).padStart(4, '0')}${'X'.repeat(1996)}`;
|
||||
|
|
@ -832,8 +1202,8 @@ describe('ReadTool', () => {
|
|||
|
||||
it('interpolates the cap constants into the description and references the Grep tool', () => {
|
||||
const tool = toolWithContent('');
|
||||
expect(tool.description).toContain(String(MAX_LINES));
|
||||
expect(tool.description).toContain(String(MAX_LINE_LENGTH));
|
||||
expect(tool.description).toContain('100000');
|
||||
expect(tool.description).toContain('500000');
|
||||
expect(tool.description).toContain('Grep');
|
||||
});
|
||||
|
||||
|
|
@ -866,10 +1236,18 @@ describe('ReadTool', () => {
|
|||
expect(result.isError).toBeFalsy();
|
||||
expect(result.output).toBe('');
|
||||
expect(result.note).toBe(
|
||||
readNote('No lines read from file. Total lines in file: 0. End of file reached.'),
|
||||
readNote('No lines read from file. Total lines in file: 0. Requested range complete. Effective max_chars: 100000. End of file reached.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty read when its model-visible status cannot fit the character budget', async () => {
|
||||
const result = await execute(toolWithContent(''), { path: '/tmp/empty.txt', max_chars: 150 });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toContain('too small');
|
||||
expect(result.output).toContain('Increase max_chars');
|
||||
});
|
||||
|
||||
it('reads unicode (CJK + emoji + accented Latin) without loss', async () => {
|
||||
const tool = toolWithContent('Hello 世界 🌍\nUnicode test: café, naïve, résumé');
|
||||
|
||||
|
|
@ -896,14 +1274,14 @@ describe('ReadTool', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('schema validation accepts -1 and -MAX_LINES but rejects -(MAX_LINES + 1)', () => {
|
||||
it('accepts large positive and negative line ranges without a fixed line cap', () => {
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -1 }).success).toBe(true);
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -MAX_LINES }).success).toBe(
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -10_000, n_lines: 10_000 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -(MAX_LINES + 1) }).success,
|
||||
).toBe(false);
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: 10_000 }).success).toBe(true);
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: 0 }).success).toBe(false);
|
||||
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -1.5 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('reads non-sensitive dotfiles like .gitignore successfully', async () => {
|
||||
|
|
@ -923,7 +1301,7 @@ describe('ReadTool', () => {
|
|||
expect(result.isError).toBeFalsy();
|
||||
expect(result.output).toContain('1\ta');
|
||||
expect(result.output).toContain('5\te');
|
||||
expect(result.note).toContain('Total lines in file: 5.');
|
||||
expect(result.note).toContain('Total lines in file: 5. Requested range complete. Effective max_chars: 100000.');
|
||||
});
|
||||
|
||||
it('tail mode on an empty file returns empty output without erroring', async () => {
|
||||
|
|
@ -932,7 +1310,7 @@ describe('ReadTool', () => {
|
|||
const result = await execute(tool, { path: '/tmp/empty-tail.txt', line_offset: -10 });
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.note).toContain('Total lines in file: 0.');
|
||||
expect(result.note).toContain('Total lines in file: 0. Requested range complete. Effective max_chars: 100000.');
|
||||
});
|
||||
|
||||
it('line_offset=-1 returns only the last line with its absolute line number', async () => {
|
||||
|
|
@ -945,17 +1323,16 @@ describe('ReadTool', () => {
|
|||
expect(result.note).toContain('1 line read from file starting from line 5.');
|
||||
});
|
||||
|
||||
it('tail mode reports absolute line numbers when long lines are truncated', async () => {
|
||||
const shortLine = 'short';
|
||||
const longLine = 'X'.repeat(MAX_LINE_LENGTH + 500);
|
||||
const content = [shortLine, longLine, shortLine, longLine, shortLine].join('\n');
|
||||
const tool = toolWithContent(content);
|
||||
it('keeps absolute line numbers and whole long lines in tail mode', async () => {
|
||||
const longLine = 'X'.repeat(2_500);
|
||||
const result = await execute(toolWithContent(['short', longLine, 'short', longLine, 'short'].join('\n')), {
|
||||
path: '/tmp/tail-long.txt',
|
||||
line_offset: -3,
|
||||
});
|
||||
|
||||
const result = await execute(tool, { path: '/tmp/tail-trunc.txt', line_offset: -3 });
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.note).toContain('Total lines in file: 5.');
|
||||
expect(result.note).toContain('Lines [4] were truncated to 2000 characters; use Bash (e.g. cut or sed) to read the elided content of those lines.');
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.output).toBe(`3\tshort\n4\t${longLine}\n5\tshort`);
|
||||
expect(result.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rechecks runtime availability when execution starts after the tool was shown', async () => {
|
||||
|
|
@ -989,6 +1366,7 @@ describe('ReadTool', () => {
|
|||
stubWorkspaceContext('/workspace'),
|
||||
{ catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog,
|
||||
stubToolResultTruncationService(),
|
||||
stubConfigService(),
|
||||
);
|
||||
const execution = tool.resolveExecution({ path: '/workspace/a.txt' });
|
||||
expect('execute' in execution).toBe(true);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue