From 174cf6a927cb9afb474af05967ac690678f93080 Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 30 Oct 2026 00:48:31 -0700 Subject: [PATCH 1/4] fix(agent-core-v2): cap Read at about 25k tokens and trim file tool results - Cap the max_chars a Read call can request at 100k characters (was 500k) and drop the description sentence that told the model to request a larger budget for whole large files; the event log note now points to Next Read instead of a larger max_chars. - Stop a page at about max_chars / 4 estimated tokens. The limit is applied while the page is assembled: drop whole lines, or cut a single long line by token weight, without re-reading the file. It rounds up, so pages of ASCII text never reach it and keep their exact character pagination. A tail read whose last line cannot fit returns the bounded forward recovery. - Omit the Read status block when a call returns the whole file with nothing to report. - On an incomplete page, point to Grep for a specific section instead of answering from that page alone. - After a successful Edit or Write, tell the model it does not need to Read the file back. --- .changeset/cap-read-output.md | 5 + docs/en/configuration/config-files.md | 6 +- docs/en/reference/tools.md | 4 +- docs/zh/configuration/config-files.md | 6 +- docs/zh/reference/tools.md | 4 +- .../src/agent/tools/edit/editTool.ts | 5 +- .../src/agent/tools/os/read/read.md | 4 +- .../src/agent/tools/os/read/read.ts | 2 +- .../src/agent/tools/os/read/readTool.ts | 68 ++++++-- .../src/agent/tools/os/write/writeTool.ts | 1 + .../src/llm-adapter/contract/tokens.ts | 25 ++- .../agent/toolExecutor/toolExecutor.test.ts | 4 +- .../os/backends/node-local/tools/read.test.ts | 164 +++++++++++++----- 13 files changed, 223 insertions(+), 75 deletions(-) create mode 100644 .changeset/cap-read-output.md diff --git a/.changeset/cap-read-output.md b/.changeset/cap-read-output.md new file mode 100644 index 00000000000..ca2f16147a3 --- /dev/null +++ b/.changeset/cap-read-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +A single file read by the agent now returns at most 100,000 characters or about 25,000 tokens, whichever comes first, instead of up to 500,000 characters; set `max_chars` under `[read]` in config.toml to raise both limits again. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 03fd508afe2..ff9a3cb875d 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -452,15 +452,15 @@ Like the `tools` / `disallowedTools` fields of an agent file, this section shape | 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 | +| `max_chars` | `integer` | `100000` | Maximum character budget a tool call may request | ```toml [read] default_max_chars = 100000 -max_chars = 500000 +max_chars = 100000 ``` -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. +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. Each call also stops at about `max_chars` / 4 estimated tokens, counting one token per non-ASCII character and per four ASCII characters, so text with many non-ASCII characters, such as Chinese, returns fewer characters per call. Raise `max_chars` to let a call return more than 100000 characters or about 25000 tokens, and raise `default_max_chars` as well when you want larger documents to be returned in one call without the agent requesting a larger budget. ## `image` diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 48c011f4018..7caab03d426 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -17,9 +17,9 @@ 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), `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`** 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 and the most a call can request are both 100,000 characters, and a call also stops at about a quarter of the maximum in estimated tokens (25,000 by default), so text with many non-ASCII characters, such as Chinese, returns fewer characters per call; 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. +`Read` prefers complete lines and its results are not shortened again by the general tool-output limit. A status block follows the content unless the whole file was returned with nothing to report. 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 `�`, 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 `�` in a valid file does not trigger it. Use `ReadMediaFile` for images or videos. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 1c5381fbd7c..8e1ec2fc2fa 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -451,15 +451,15 @@ disabled = ["WebSearch", "FetchURL", "mcp__github__*"] | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `default_max_chars` | `integer` | `100000` | 工具调用未指定 `max_chars` 时的字符额度 | -| `max_chars` | `integer` | `500000` | 单次工具调用可申请的最大字符额度 | +| `max_chars` | `integer` | `100000` | 单次工具调用可申请的最大字符额度 | ```toml [read] default_max_chars = 100000 -max_chars = 500000 +max_chars = 100000 ``` -两个值都必须是正整数。调用中的 `max_chars` 覆盖默认值,但不会超过配置的最大值;结果会说明实际生效的额度。如果配置的默认值超过最大值,默认读取也会按最大值执行。如果希望较大的文档默认就能一次返回,无需 Agent 主动申请更大额度,可以提高 `default_max_chars`。 +两个值都必须是正整数。调用中的 `max_chars` 覆盖默认值,但不会超过配置的最大值;结果会说明实际生效的额度。如果配置的默认值超过最大值,默认读取也会按最大值执行。单次调用还会在估算 token 数达到 `max_chars` 的四分之一时停止(每个非 ASCII 字符计 1 个 token,每 4 个 ASCII 字符计 1 个),因此中文等非 ASCII 字符较多的文本每次返回的字符更少。如果希望单次调用返回超过 100000 个字符或约 25000 个 token,需要提高 `max_chars`;如果还希望较大的文档默认就能一次返回,无需 Agent 主动申请更大额度,再同时提高 `default_max_chars`。 ## `image` diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index c10895c51d5..1ffea137a8e 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -17,9 +17,9 @@ | `Glob` | 自动放行 | 按 glob 模式查找文件 | | `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`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限和调用可申请的最大值都是 100,000 字符,单次调用还会在估算 token 数达到最大值的四分之一(默认 25,000)时停止,因此中文等非 ASCII 字符较多的文本每次返回的字符更少;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。 -`Read` 优先返回完整行,结果不会再被通用工具输出限制缩短。如果单独一行也无法在一页中容纳,工具会返回片段,并在状态中说明列范围及 `Next Read` 参数,无需提高额度即可继续读取。拼接同一行的片段时不要额外插入换行。一行只返回部分内容时,仍会计入剩余的 `n_lines` 范围,直到行尾返回为止。非法列偏移会明确报错,不会跳过内容。 +`Read` 优先返回完整行,结果不会再被通用工具输出限制缩短。除非一次就返回了整个文件且没有需要说明的情况,否则内容后面会附带状态信息。如果单独一行也无法在一页中容纳,工具会返回片段,并在状态中说明列范围及 `Next Read` 参数,无需提高额度即可继续读取。拼接同一行的片段时不要额外插入换行。一行只返回部分内容时,仍会计入剩余的 `n_lines` 范围,直到行尾返回为止。非法列偏移会明确报错,不会跳过内容。 尾读优先返回请求范围中较新的完整行。如果连一条完整行都无法容纳,结果会给出未读范围的正向 `Next Read` 参数;`column_offset` 不能与负数 `line_offset` 同时使用。续读位置针对文件的当前内容,文件变化后应重新读取。如果尾读提示读取期间文件发生变化,请基于更新后的文件重试。10 MiB 以内的 UTF-16 LE/BE 文件会先尝试严格解码;失败后,`Read` 会将损坏序列替换为 `�` 并返回可读文本,同时在每一页提示发生了有损解码、文本可能与原文不同。告警也计入字符额度;有效文件中原本就有的 `�` 不会触发告警。图片和视频请使用 `ReadMediaFile`。 diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts index a3ef1f35cb0..e441ebf16fb 100644 --- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -108,7 +108,10 @@ export class EditTool implements IEditTool { return { isError: true, output: result.error }; } const word = result.count === 1 ? 'occurrence' : 'occurrences'; - return { output: `Replaced ${String(result.count)} ${word} in ${args.path}` }; + return { + output: `Replaced ${String(result.count)} ${word} in ${args.path}`, + note: 'The change is applied; there is no need to Read the file back to check it.', + }; } } diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.md b/packages/agent-core-v2/src/agent/tools/os/read/read.md index 632a4204c53..a15aa297aa3 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.md +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.md @@ -4,7 +4,7 @@ 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. -- Output is `\t` per line, followed by a `` status block that is not part of the file. -- Results are bounded by `max_chars`: ${DEFAULT_MAX_CHARS} by default, up to ${MAX_CHARS}. When the task requires the full text of a large file, request a larger `max_chars` in the first call. +- Output is `\t` per line. A `` status block may follow; it is not part of the file. +- Results are bounded by `max_chars`: ${DEFAULT_MAX_CHARS} by default, up to ${MAX_CHARS}. - If the status block reports an incomplete range, continue with its `Next Read` arguments exactly. Do not answer from a partial page when the task requires the remaining content. - After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.ts b/packages/agent-core-v2/src/agent/tools/os/read/read.ts index 060f82571c8..1b896059d81 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.ts @@ -4,7 +4,7 @@ import { createDecorator } from '#/_base/di/instantiation'; import { type AgentTool } from '#/tool/toolContract'; export const DEFAULT_MAX_CHARS = 100_000; -export const DEFAULT_MAX_CHARS_LIMIT = 500_000; +export const DEFAULT_MAX_CHARS_LIMIT = 100_000; export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024; diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 456f9d10b86..5c81ed9885b 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -12,6 +12,7 @@ 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 { countTokenChars, tokensForCharCounts } from '#/llm-adapter/contract/tokens'; import { ToolAccesses, type ExecutableToolResult, @@ -58,6 +59,7 @@ interface ReadTailEntry extends ReadLineEntry { interface ReadRequest { readonly args: ReadInput; readonly maxChars: number; + readonly tokenLimit: number; readonly maxCharsLimit: number; readonly detectedEncoding?: UtfTextEncoding; readonly lossyDecoding: boolean; @@ -87,6 +89,17 @@ function splitsSurrogatePair(text: string, offset: number): boolean { return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; } +function cutForTokens(text: string, end: number, overflow: number): number { + let index = end; + let removed = 0; + while (index > 0 && removed < overflow) { + const code = text.codePointAt(index - 1)!; + index -= code >= 0xdc00 && code <= 0xdfff && index > 1 ? 2 : 1; + removed += code <= 127 ? 0.25 : 1; + } + return index; +} + function updateLineEndingFlags(flags: LineEndingFlags, text: string): void { for (let i = 0; i < text.length; i += 1) { const code = text.codePointAt(i); @@ -345,6 +358,7 @@ export class ReadTool implements IReadTool { const request: ReadRequest = { args, maxChars: Math.min(args.max_chars ?? limits.defaultMaxChars, limits.maxChars), + tokenLimit: Math.ceil(limits.maxChars / 4), maxCharsLimit: limits.maxChars, detectedEncoding, lossyDecoding, @@ -446,10 +460,13 @@ export class ReadTool implements IReadTool { } private finishPage(page: ReadPage): ExecutableToolResult { - const { args, maxChars, maxCharsLimit, eventLog, detectedEncoding, lossyDecoding } = page.request; + const { args, maxChars, tokenLimit, 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 lineCounts = page.renderedLines.map(countTokenChars); + let contentAscii = lineCounts.reduce((sum, counts) => sum + counts.ascii, 0); + let contentNonAscii = lineCounts.reduce((sum, counts) => sum + counts.nonAscii, 0); const firstColumn = page.fromTail ? 0 : args.column_offset ?? 0; const firstPrefix = `${String(page.startLine)}\t`; const firstText = page.renderedLines[0]?.slice(firstPrefix.length) ?? ''; @@ -462,7 +479,7 @@ export class ReadTool implements IReadTool { const lineIncomplete = fragmentEnd !== undefined; const complete = page.rangeStart > page.rangeEnd || (count > 0 && startLine === page.rangeStart && endLine === page.rangeEnd && !lineIncomplete); - const parts = [ + const summary = [ count > 0 ? `${String(count)} ${count === 1 ? 'line' : 'lines'} read from file starting from line ${String(startLine)}.` : 'No lines read from file.', @@ -471,13 +488,14 @@ export class ReadTool implements IReadTool { `Effective max_chars: ${String(maxChars)}.`, ]; if (!lineIncomplete && ((count > 0 && endLine === page.totalLines) || page.rangeStart > page.totalLines)) { - parts.push('End of file reached.'); + summary.push('End of file reached.'); } + const notices: string[] = []; 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.'}`); + notices.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)}.`); + notices.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; @@ -489,28 +507,39 @@ export class ReadTool implements IReadTool { n_lines: page.fromTail || args.n_lines !== undefined ? nextEnd - nextStart + 1 : undefined, max_chars: maxChars, }; - parts.push(`Next Read: ${JSON.stringify(next)}`); + notices.push(`Next Read: ${JSON.stringify(next)}`); + if (count > 0) { + notices.push('If the answer may be further on, continue with Next Read or find the section with Grep instead of answering from this page alone.'); + } } 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.'); + notices.push('Kimi Code agent event log: read one record at a time (n_lines=1); follow Next Read for the rest of 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.'); + notices.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).`); + notices.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.'); + notices.push('Lossy UTF-16 decoding: malformed sequences were replaced with U+FFFD. The decoded text may differ from the original file.'); } - const note = `${parts.join(' ')}`; + const wholeFile = complete && count > 0 && firstColumn === 0 && startLine === 1 && endLine === page.totalLines; + const note = wholeFile && notices.length === 0 + ? undefined + : `${[...summary, ...notices].join(' ')}`; 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)) { + : contentChars + (note === undefined ? 0 : 1 + note.length); + const noteCounts = note === undefined ? undefined : countTokenChars(note); + const overTokens = count === 0 ? 0 : tokensForCharCounts({ + ascii: contentAscii + count - 1 + (noteCounts === undefined ? 0 : 1 + noteCounts.ascii), + nonAscii: contentNonAscii + (noteCounts?.nonAscii ?? 0), + }) - tokenLimit; + if (renderedChars <= maxChars && overTokens <= 0 && (complete || count > 0)) { return { output: fragmentEnd !== undefined ? firstPrefix + firstText.slice(0, fragmentEnd) @@ -522,11 +551,13 @@ export class ReadTool implements IReadTool { if (count === 1 && !page.fromTail) { const previousEnd = fragmentEnd ?? firstText.length; fragmentEnd = Math.min(previousEnd - 1, previousEnd - (renderedChars - maxChars)); + if (overTokens > 0) fragmentEnd = Math.min(fragmentEnd, cutForTokens(firstText, previousEnd, overTokens)); 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; + ({ ascii: contentAscii, nonAscii: contentNonAscii } = countTokenChars(firstPrefix + firstText.slice(0, fragmentEnd))); continue; } if (count === 0) { @@ -545,8 +576,15 @@ export class ReadTool implements IReadTool { } 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; + let remainingTokens = renderedChars <= maxChars && overTokens > 0 ? overTokens : 0; + do { + const dropped = page.fromTail ? first++ : --end; + const counts = lineCounts[dropped]!; + contentChars -= page.renderedLines[dropped]!.length + 1; + contentAscii -= counts.ascii; + contentNonAscii -= counts.nonAscii; + remainingTokens -= counts.nonAscii + (counts.ascii + 1) / 4; + } while (remainingTokens > 0 && end - first > (page.fromTail ? 0 : 1)); } } diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts index e9cc37ddb3f..52ff733ea7a 100644 --- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -94,6 +94,7 @@ export class WriteTool implements IWriteTool { const bytesWritten = Buffer.byteLength(args.content, 'utf8'); return { output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, + note: 'The change is applied; there is no need to Read the file back to check it.', }; } catch (error) { const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; diff --git a/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts b/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts index 79cad0ae7a2..b6feac93976 100644 --- a/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts +++ b/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts @@ -2,17 +2,30 @@ import type { ContentPart, Message, Tool } from './message'; const messageTokenEstimateCache = new WeakMap(); -export function estimateTokens(text: string): number { - let asciiCount = 0; - let nonAsciiCount = 0; +export interface TokenCharCounts { + readonly ascii: number; + readonly nonAscii: number; +} + +export function countTokenChars(text: string): TokenCharCounts { + let ascii = 0; + let nonAscii = 0; for (const char of text) { if (char.codePointAt(0)! <= 127) { - asciiCount++; + ascii++; } else { - nonAsciiCount++; + nonAscii++; } } - return Math.ceil(asciiCount / 4) + nonAsciiCount; + return { ascii, nonAscii }; +} + +export function tokensForCharCounts(counts: TokenCharCounts): number { + return Math.ceil(counts.ascii / 4) + counts.nonAscii; +} + +export function estimateTokens(text: string): number { + return tokensForCharCounts(countTokenChars(text)); } export function estimateTokensForMessages(messages: readonly Message[]): number { diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index d59aeb1317d..33f61bb6db4 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1190,6 +1190,8 @@ describe('truncation pipeline', () => { }); it('recovers an expanded Glob listing beyond spill retention using complete saved pages', async () => { + await readConfig.set('read', { maxChars: 500_000 }); + await readConfig.reload(); const root = await mkdtemp(join(tmpdir(), 'r'.repeat(180))); try { const names = Array.from({ length: 60_000 }, (_, i) => `file-${String(i).padStart(6, '0')}.ts`); @@ -1532,7 +1534,7 @@ describe('truncation pipeline', () => { 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.note).toBeUndefined(); expect(result.output).not.toContain('output_path:'); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index b4a6947f2ce..d7ff229ce7c 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -11,7 +11,10 @@ import { TRANSCODE_MAX_BYTES, } from '#/agent/tools/os/read/read'; import { ReadTool } from '#/agent/tools/os/read/readTool'; +import { READ_SECTION } from '#/agent/tools/os/read/configSection'; +import type { IConfigService } from '#/app/config/config'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; +import { estimateTokens } from '#/llm-adapter/contract/tokens'; import { stubToolResultTruncationService } from '../../../../agent/toolResultTruncation/stubs'; import { stubConfigService } from '../../../../app/config/stubs'; import type { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; @@ -88,6 +91,7 @@ function createReadTool( profile: IAgentProfileService = stubProfileService({ image_in: true, video_in: true }), toolPolicy: IAgentToolPolicyService = { isToolActive: () => true } as unknown as IAgentToolPolicyService, toolRegistry: IAgentToolRegistryService = { resolve: () => ({}) } as unknown as IAgentToolRegistryService, + config: IConfigService = stubConfigService(), ): ReadTool { const runtime = Object.assign( new FakeRuntime( @@ -103,7 +107,7 @@ function createReadTool( inspect: () => runtime, acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }), }; - return new ReadTool(resolver, workspace, skillCatalog, truncation, stubConfigService(), profile, toolPolicy, toolRegistry); + return new ReadTool(resolver, workspace, skillCatalog, truncation, config, profile, toolPolicy, toolRegistry); } function createSpiedFs(content: string) { @@ -164,6 +168,28 @@ function toolWithContent(content: string, workspace = PERMISSIVE_WORKSPACE) { return createReadTool(createSpiedFs(content).fs, createTestEnv(), workspace); } +function readConfig(maxChars: number): IConfigService { + return stubConfigService({ [READ_SECTION]: { maxChars } }); +} + +function toolWithMaxChars(content: string, maxChars: number) { + return createReadTool( + createSpiedFs(content).fs, + createTestEnv(), + PERMISSIVE_WORKSPACE, + undefined, + undefined, + undefined, + undefined, + undefined, + readConfig(maxChars), + ); +} + +function nextReadArgs(result: ExecutableToolResult): ReadInput { + return ReadInputSchema.parse(JSON.parse(/Next Read: (\{[^\n]*\})/.exec(result.note ?? '')![1]!)); +} + function isPromiseLike(value: ToolExecution | Promise): value is Promise { return typeof (value as Promise).then === 'function'; } @@ -192,11 +218,12 @@ async function execute(tool: ReadTool, args: ReadInput): Promise { - 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 paper = Array.from({ length: 4621 }, (_, index) => + `section ${String(index + 1)} ${'x'.repeat(55)}`, + ).join('\n'); + + it('reads a 4621-line document in full when the configured maximum fits the requested budget', async () => { + const tool = toolWithMaxChars(paper, 500_000); const result = await execute(tool, ReadInputSchema.parse({ path: '/tmp/paper.md', @@ -206,10 +233,25 @@ describe('ReadTool', () => { expect(result.isError).not.toBe(true); expect(output.split('\n')).toHaveLength(4621); - expect(output.replaceAll(/^\d+\t/gm, '')).toBe(content); + expect(output.replaceAll(/^\d+\t/gm, '')).toBe(paper); expect(output.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(500_000); }); + it('caps a requested budget at 100000 characters by default and continues with Next Read', async () => { + const tool = toolWithContent(paper); + + 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.length + 1 + (result.note?.length ?? 0)).toBeLessThanOrEqual(100_000); + expect(result.note).toContain('Requested max_chars=500000 was capped at the configured maximum 100000.'); + expect(result.note).toContain('Next Read'); + }); + 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); @@ -404,12 +446,22 @@ describe('ReadTool', () => { const result = await execute(tool, { path: '/tmp/a.txt' }); - expect(result).toMatchObject({ - output: '1\talpha\n2\tbeta', - note: readNote( - '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.', + expect(result).toMatchObject({ output: '1\talpha\n2\tbeta' }); + }); + + it('omits the status block when the whole file is returned with nothing to report', async () => { + const tool = toolWithContent('alpha\nbeta\n'); + + const whole = await execute(tool, { path: '/tmp/a.txt' }); + const ranged = await execute(tool, { path: '/tmp/a.txt', n_lines: 1 }); + + expect(whole.note).toBeUndefined(); + expect(renderToolResultForModel(whole)).toEqual([{ type: 'text', text: '1\talpha\n2\tbeta' }]); + expect(ranged.note).toBe( + readNote( + '1 line read from file starting from line 1. Total lines in file: 2. Requested range complete. Effective max_chars: 100000.', ), - }); + ); }); it('stats the resolved target so symlinked files stay readable', async () => { @@ -428,11 +480,7 @@ describe('ReadTool', () => { const result = await execute(tool, { path: '/tmp/a.txt' }); 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. Requested range complete. Effective max_chars: 100000. End of file reached.', - ), - ); + expect(result.note).toBeUndefined(); }); it('makes mixed carriage returns visible instead of normalizing them', async () => { @@ -632,11 +680,7 @@ describe('ReadTool', () => { const result = await execute(tool, { path: '/tmp/external.txt' }); 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. Requested range complete. Effective max_chars: 100000. End of file reached.', - ), - ); + expect(result.note).toBeUndefined(); expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES); expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict' }); }); @@ -678,11 +722,7 @@ describe('ReadTool', () => { const result = await execute(tool, { path: '~/notes/today.txt' }); 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. Requested range complete. Effective max_chars: 100000. End of file reached.', - ), - ); + expect(result.note).toBeUndefined(); expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES); expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict' }); }); @@ -965,7 +1005,7 @@ describe('ReadTool', () => { expect(result.isError).not.toBe(true); expect(result.output).toBe(`1\t${content}`); - expect(result.note).not.toContain('Lossy'); + expect(result.note ?? '').not.toContain('Lossy'); }); it.each([2, -2])('preserves the lossy warning and budget through Read continuation from offset %i', async (lineOffset) => { @@ -1115,7 +1155,7 @@ describe('ReadTool', () => { 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.note).toBeUndefined(); expect(result.truncated).toBeUndefined(); }); @@ -1181,13 +1221,23 @@ describe('ReadTool', () => { expect(result.spillExempt).toBe(true); }); - it('reads a whole long event log record with an explicit character budget', async () => { + it('reads a whole long event log record with an explicit character budget under a raised maximum', 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, + undefined, + undefined, + undefined, + readConfig(500_000), + ); const result = await execute(tool, { path: '/home/user/.kimi-code/sessions/ws/session/agents/main/wire.jsonl', line_offset: 1, @@ -1201,9 +1251,9 @@ describe('ReadTool', () => { expect(result.spillExempt).toBe(true); }); - it('returns a whole large final event log record within the requested character budget', async () => { + it('returns a whole large final event log record within a requested budget the raised maximum allows', async () => { const huge = 'z'.repeat(105_000); - const tool = toolWithContent(`first\n${huge}`); + const tool = toolWithMaxChars(`first\n${huge}`, 500_000); const result = await execute(tool, { path: '/tmp/wire.jsonl', line_offset: -1, @@ -1225,12 +1275,48 @@ describe('ReadTool', () => { expect(result.spillExempt).toBe(true); }); - it('fits complete lines and status within the default character budget', async () => { + it('fragments a single long non-ASCII line to stay within the estimated token limit', async () => { + const line = '文'.repeat(30_000); + const result = await execute(toolWithContent(line), { path: '/tmp/long.md' }); + const output = toolContentString(result); + + expect(result.isError).not.toBe(true); + expect(estimateTokens(`${output}\n${result.note ?? ''}`)).toBeLessThanOrEqual(25_000); + expect(output.length).toBeLessThan(line.length); + expect(result.note).toContain('Line 1 fragment'); + expect(nextReadArgs(result)).toMatchObject({ line_offset: 1, max_chars: 100_000 }); + expect(nextReadArgs(result).column_offset).toBeGreaterThan(0); + }); + + it('returns the bounded forward recovery when a tail read of a long non-ASCII line exceeds the token limit', async () => { + const result = await execute(toolWithContent('文'.repeat(60_000)), { path: '/tmp/long.md', line_offset: -1 }); + + expect(result.isError).toBe(true); + expect(result.output).toBe('No complete line fits. Continue with the forward Next Read.'); + expect(nextReadArgs(result)).toMatchObject({ line_offset: 1, n_lines: 1 }); + }); + + it('shortens a page of non-ASCII text to the estimated token limit and keeps the budget for Next Read', 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(result.isError).not.toBe(true); + expect(estimateTokens(`${output}\n${result.note ?? ''}`)).toBeLessThanOrEqual(25_000); + expect(output.split('\n').length).toBeGreaterThan(1); + expect(output.split('\n').every((entry) => entry.replace(/^\d+\t/, '') === line)).toBe(true); + expect(result.truncated).toBe(true); + expect(result.note).toContain('"max_chars":100000'); + expect(result.note).toContain('find the section with Grep instead of answering from this page alone'); + }); + + it('fits complete lines and status within the default character budget', async () => { + const line = '文'.repeat(2_000); + const tool = toolWithMaxChars(Array.from({ length: 80 }, () => line).join('\n'), 500_000); + const result = await execute(tool, { path: '/tmp/characters.txt' }); + const output = toolContentString(result); + 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); @@ -1267,8 +1353,7 @@ describe('ReadTool', () => { expect(result.isError).toBeFalsy(); expect(output).toContain('1\tline 1'); 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(result.note).toBeUndefined(); expect(consumed).toBe(1_000 + 5); expect(readBytes).toHaveBeenCalledWith('/tmp/large.txt', MEDIA_SNIFF_BYTES); expect(readText).not.toHaveBeenCalled(); @@ -1280,7 +1365,7 @@ describe('ReadTool', () => { expect(result.isError).not.toBe(true); expect(result.output).toContain('1001\tline 1001'); - expect(result.note).toContain('Requested range complete.'); + expect(result.note).toBeUndefined(); expect(result.truncated).toBeUndefined(); }); @@ -1321,7 +1406,8 @@ describe('ReadTool', () => { it('interpolates the cap constants into the description', () => { const tool = toolWithContent(''); expect(tool.description).toContain('100000'); - expect(tool.description).toContain('500000'); + expect(tool.description).not.toContain('${'); + expect(toolWithMaxChars('', 250_000).description).toContain('250000'); }); it('reads files inside additional_dirs via absolute path', async () => { @@ -1418,7 +1504,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. Requested range complete. Effective max_chars: 100000.'); + expect(result.note).toBeUndefined(); }); it('tail mode on an empty file returns empty output without erroring', async () => { From 5c3b1eb7f48b7e0a4819c7092b411bef938bdb93 Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 30 Oct 2026 01:25:21 -0700 Subject: [PATCH 2/4] fix(agent-core-v2): reject named Read ranges that exceed the token limit When a call names a range, with n_lines or a negative line_offset, and those lines exceed the estimated token limit, return an error that says how many lines fit and suggests the exact arguments, instead of a page that silently covers only part of the range. Whole-file reads, reads from an offset to the end, single-line requests, and column continuations still page with Next Read. --- docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../src/agent/tools/os/read/readTool.ts | 35 ++++++++++++++++++- .../os/backends/node-local/tools/read.test.ts | 30 ++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 7caab03d426..eccd0d87544 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -17,7 +17,7 @@ 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), `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 and the most a call can request are both 100,000 characters, and a call also stops at about a quarter of the maximum in estimated tokens (25,000 by default), so text with many non-ASCII characters, such as Chinese, returns fewer characters per call; 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`** 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 and the most a call can request are both 100,000 characters, and a call also stops at about a quarter of the maximum in estimated tokens (25,000 by default), so text with many non-ASCII characters, such as Chinese, returns fewer characters per call; both values can be changed in the [`read` configuration](../configuration/config-files.md#read). When a call names a range, with `n_lines` or a negative `line_offset`, and those lines exceed the token limit, `Read` returns an error that suggests a range that fits instead of returning part of it. 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 status block follows the content unless the whole file was returned with nothing to report. 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. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 1ffea137a8e..3247a8081f0 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -17,7 +17,7 @@ | `Glob` | 自动放行 | 按 glob 模式查找文件 | | `ReadMediaFile` | 自动放行 | 读取图片或视频文件 | -**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限和调用可申请的最大值都是 100,000 字符,单次调用还会在估算 token 数达到最大值的四分之一(默认 25,000)时停止,因此中文等非 ASCII 字符较多的文本每次返回的字符更少;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。 +**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限和调用可申请的最大值都是 100,000 字符,单次调用还会在估算 token 数达到最大值的四分之一(默认 25,000)时停止,因此中文等非 ASCII 字符较多的文本每次返回的字符更少;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。如果调用指定了范围(带 `n_lines` 或负的 `line_offset`)而这些行超过 token 上限,`Read` 会返回错误并建议一个放得下的范围,而不是只返回其中一部分。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。 `Read` 优先返回完整行,结果不会再被通用工具输出限制缩短。除非一次就返回了整个文件且没有需要说明的情况,否则内容后面会附带状态信息。如果单独一行也无法在一页中容纳,工具会返回片段,并在状态中说明列范围及 `Next Read` 参数,无需提高额度即可继续读取。拼接同一行的片段时不要额外插入换行。一行只返回部分内容时,仍会计入剩余的 `n_lines` 范围,直到行尾返回为止。非法列偏移会明确报错,不会跳过内容。 diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 5c81ed9885b..3590a980eb6 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -12,7 +12,7 @@ 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 { countTokenChars, tokensForCharCounts } from '#/llm-adapter/contract/tokens'; +import { countTokenChars, tokensForCharCounts, type TokenCharCounts } from '#/llm-adapter/contract/tokens'; import { ToolAccesses, type ExecutableToolResult, @@ -100,6 +100,33 @@ function cutForTokens(text: string, end: number, overflow: number): number { return index; } +const RANGE_STATUS_TOKENS = 256; + +function rangeTooLarge(page: ReadPage, lineCounts: readonly TokenCharCounts[], tokenLimit: number): ExecutableToolResult { + const indexes = page.renderedLines.map((_, index) => index); + if (page.fromTail) indexes.reverse(); + let ascii = 0; + let nonAscii = 0; + let fit = 0; + for (const index of indexes) { + const counts = lineCounts[index]!; + ascii += counts.ascii + (fit > 0 ? 1 : 0); + nonAscii += counts.nonAscii; + if (tokensForCharCounts({ ascii, nonAscii }) > tokenLimit - RANGE_STATUS_TOKENS) break; + fit += 1; + } + const lines = Math.max(1, fit); + const suggestion = { + path: page.request.args.path, + line_offset: page.fromTail ? page.rangeEnd - lines + 1 : page.rangeStart, + n_lines: lines, + }; + return { + isError: true, + output: `Lines ${String(page.rangeStart)}-${String(page.rangeEnd)} are more than one Read can return (about ${String(tokenLimit)} estimated tokens). Read at most ${String(lines)} lines at a time, for example ${JSON.stringify(suggestion)}, or find the section with Grep.`, + }; +} + function updateLineEndingFlags(flags: LineEndingFlags, text: string): void { for (let i = 0; i < text.length; i += 1) { const code = text.codePointAt(i); @@ -539,6 +566,12 @@ export class ReadTool implements IReadTool { ascii: contentAscii + count - 1 + (noteCounts === undefined ? 0 : 1 + noteCounts.ascii), nonAscii: contentNonAscii + (noteCounts?.nonAscii ?? 0), }) - tokenLimit; + if ( + overTokens > 0 && count > 1 && first === 0 && end === page.renderedLines.length && + firstColumn === 0 && (args.n_lines !== undefined || page.fromTail) + ) { + return rangeTooLarge(page, lineCounts, tokenLimit); + } if (renderedChars <= maxChars && overTokens <= 0 && (complete || count > 0)) { return { output: fragmentEnd !== undefined diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index d7ff229ce7c..268f942b87d 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -1296,6 +1296,36 @@ describe('ReadTool', () => { expect(nextReadArgs(result)).toMatchObject({ line_offset: 1, n_lines: 1 }); }); + it('rejects a named range that exceeds the estimated token limit and suggests one that fits', async () => { + const tool = toolWithContent(Array.from({ length: 600 }, () => '文'.repeat(100)).join('\n')); + const result = await execute(tool, { path: '/tmp/doc.md', line_offset: 1, n_lines: 400 }); + + expect(result.isError).toBe(true); + expect(toolContentString(result)).toContain('Lines 1-400 are more than one Read can return'); + const suggestion = ReadInputSchema.parse(JSON.parse(/for example (\{[^}]*\})/.exec(toolContentString(result))![1]!)); + expect(suggestion).toMatchObject({ path: '/tmp/doc.md', line_offset: 1 }); + expect(suggestion.n_lines).toBeLessThan(400); + + const page = await execute(tool, suggestion); + expect(page.isError).not.toBe(true); + expect(page.truncated).toBeUndefined(); + expect(estimateTokens(`${toolContentString(page)}\n${page.note ?? ''}`)).toBeLessThanOrEqual(25_000); + }); + + it('rejects a tail range that exceeds the estimated token limit and suggests the newest lines that fit', async () => { + const tool = toolWithContent(Array.from({ length: 600 }, () => '文'.repeat(100)).join('\n')); + const result = await execute(tool, { path: '/tmp/doc.md', line_offset: -400 }); + + expect(result.isError).toBe(true); + expect(toolContentString(result)).toContain('Lines 201-600 are more than one Read can return'); + const suggestion = ReadInputSchema.parse(JSON.parse(/for example (\{[^}]*\})/.exec(toolContentString(result))![1]!)); + expect(suggestion.line_offset! + suggestion.n_lines! - 1).toBe(600); + + const page = await execute(tool, suggestion); + expect(page.isError).not.toBe(true); + expect(page.truncated).toBeUndefined(); + }); + it('shortens a page of non-ASCII text to the estimated token limit and keeps the budget for Next Read', async () => { const line = '文'.repeat(2_000); const tool = toolWithContent(Array.from({ length: 80 }, () => line).join('\n')); From 605335c1c068fdd0530ec2e2ce27d21f1c3e8c87 Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 30 Oct 2026 01:53:20 -0700 Subject: [PATCH 3/4] fix(agent-core-v2): keep character paging for named Read ranges The named-range check ran before character trimming, so an ASCII range longer than max_chars, whose status block pushed the page over both limits, returned a range error instead of a page with Next Read. Run the check only once the page fits max_chars, so it rejects a range only when the token limit would cut it shorter than the character limit does. --- docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../src/agent/tools/os/read/readTool.ts | 2 +- .../os/backends/node-local/tools/read.test.ts | 35 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index eccd0d87544..e3ad4fbc85d 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -17,7 +17,7 @@ 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), `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 and the most a call can request are both 100,000 characters, and a call also stops at about a quarter of the maximum in estimated tokens (25,000 by default), so text with many non-ASCII characters, such as Chinese, returns fewer characters per call; both values can be changed in the [`read` configuration](../configuration/config-files.md#read). When a call names a range, with `n_lines` or a negative `line_offset`, and those lines exceed the token limit, `Read` returns an error that suggests a range that fits instead of returning part of it. 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`** 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 and the most a call can request are both 100,000 characters, and a call also stops at about a quarter of the maximum in estimated tokens (25,000 by default), so text with many non-ASCII characters, such as Chinese, returns fewer characters per call; both values can be changed in the [`read` configuration](../configuration/config-files.md#read). When a call names a range, with `n_lines` or a negative `line_offset`, and the lines that fit within `max_chars` still exceed the token limit, `Read` returns an error that suggests a range that fits instead of returning part of it; a range cut short only by `max_chars` still returns a page with `Next Read` arguments. 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 status block follows the content unless the whole file was returned with nothing to report. 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. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 3247a8081f0..2238e719a1e 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -17,7 +17,7 @@ | `Glob` | 自动放行 | 按 glob 模式查找文件 | | `ReadMediaFile` | 自动放行 | 读取图片或视频文件 | -**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限和调用可申请的最大值都是 100,000 字符,单次调用还会在估算 token 数达到最大值的四分之一(默认 25,000)时停止,因此中文等非 ASCII 字符较多的文本每次返回的字符更少;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。如果调用指定了范围(带 `n_lines` 或负的 `line_offset`)而这些行超过 token 上限,`Read` 会返回错误并建议一个放得下的范围,而不是只返回其中一部分。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。 +**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)、`column_offset`(正向读取时,起始行内从 0 开始的位置)、`n_lines`(请求读取的源文件行数)和 `max_chars`(结果的字符上限,包含行号和状态信息)。省略 `n_lines` 时向文件末尾读取。默认上限和调用可申请的最大值都是 100,000 字符,单次调用还会在估算 token 数达到最大值的四分之一(默认 25,000)时停止,因此中文等非 ASCII 字符较多的文本每次返回的字符更少;两个值都可通过 [`read` 配置](../configuration/config-files.md#read) 修改。如果调用指定了范围(带 `n_lines` 或负的 `line_offset`),而 `max_chars` 之内放得下的行仍超过 token 上限,`Read` 会返回错误并建议一个放得下的范围,而不是只返回其中一部分;只被 `max_chars` 截短的范围仍会返回一页内容并附带 `Next Read` 参数。字符数和列偏移按显示文本的 JavaScript 字符串长度计算,列偏移不包含行号前缀:常见字母和汉字各计 1,许多 emoji 计 2。 `Read` 优先返回完整行,结果不会再被通用工具输出限制缩短。除非一次就返回了整个文件且没有需要说明的情况,否则内容后面会附带状态信息。如果单独一行也无法在一页中容纳,工具会返回片段,并在状态中说明列范围及 `Next Read` 参数,无需提高额度即可继续读取。拼接同一行的片段时不要额外插入换行。一行只返回部分内容时,仍会计入剩余的 `n_lines` 范围,直到行尾返回为止。非法列偏移会明确报错,不会跳过内容。 diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 3590a980eb6..b47b7464017 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -567,7 +567,7 @@ export class ReadTool implements IReadTool { nonAscii: contentNonAscii + (noteCounts?.nonAscii ?? 0), }) - tokenLimit; if ( - overTokens > 0 && count > 1 && first === 0 && end === page.renderedLines.length && + renderedChars <= maxChars && overTokens > 0 && count > 1 && firstColumn === 0 && (args.n_lines !== undefined || page.fromTail) ) { return rangeTooLarge(page, lineCounts, tokenLimit); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 268f942b87d..225b2f8d9b0 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -1326,6 +1326,41 @@ describe('ReadTool', () => { expect(page.truncated).toBeUndefined(); }); + it('rejects a non-ASCII named range when the lines that fit max_chars still exceed the token limit', async () => { + const tool = toolWithContent(Array.from({ length: 2000 }, () => '文'.repeat(100)).join('\n')); + const result = await execute(tool, { path: '/tmp/doc.md', line_offset: 1, n_lines: 2000 }); + + expect(result.isError).toBe(true); + expect(toolContentString(result)).toContain('Lines 1-2000 are more than one Read can return'); + const suggestion = ReadInputSchema.parse(JSON.parse(/for example (\{[^}]*\})/.exec(toolContentString(result))![1]!)); + const page = await execute(tool, suggestion); + expect(page.isError).not.toBe(true); + expect(page.truncated).toBeUndefined(); + }); + + it('keeps paging named ASCII ranges that exceed only the character limit', async () => { + const tool = toolWithContent( + Array.from({ length: 2000 }, (_, index) => `${String(index).padStart(6, '0')}${'a'.repeat(94)}`).join('\n'), + ); + const forward = await execute(tool, { path: '/tmp/code.ts', line_offset: 1, n_lines: 2000 }); + const forwardLines = toolContentString(forward).split('\n'); + const lastLine = Number(forwardLines.at(-1)!.split('\t')[0]); + + expect(forward.isError).not.toBe(true); + expect(forward.truncated).toBe(true); + expect(forwardLines[0]).toMatch(/^1\t/); + expect(nextReadArgs(forward)).toMatchObject({ line_offset: lastLine + 1, n_lines: 2000 - lastLine, max_chars: 100_000 }); + + const tail = await execute(tool, { path: '/tmp/code.ts', line_offset: -2000 }); + const tailLines = toolContentString(tail).split('\n'); + const firstLine = Number(tailLines[0]!.split('\t')[0]); + + expect(tail.isError).not.toBe(true); + expect(tail.truncated).toBe(true); + expect(tailLines.at(-1)).toMatch(/^2000\t/); + expect(nextReadArgs(tail)).toMatchObject({ line_offset: 1, n_lines: firstLine - 1, max_chars: 100_000 }); + }); + it('shortens a page of non-ASCII text to the estimated token limit and keeps the budget for Next Read', async () => { const line = '文'.repeat(2_000); const tool = toolWithContent(Array.from({ length: 80 }, () => line).join('\n')); From e67be4985360ee7861250c241cf0616642495fc9 Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 30 Oct 2026 02:07:39 -0700 Subject: [PATCH 4/4] test(kap-server): raise max_chars in the paginated attachment read test Every incomplete Read page now carries one more status sentence, so with a 500-character budget and a long attachment path the status took about 490 characters and the read could not finish within the test's 80 pages. --- packages/kap-server/test/sessions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 60bc5b8d065..9d8abbfcc0a 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -1767,7 +1767,7 @@ describe('server-v2 /api/v1/sessions', () => { const sourceAgents = session.accessor.get(IAgentLifecycleService); await sourceAgents.create({ agentId: MAIN_AGENT_ID }); let reader = sourceAgents.handleOf(MAIN_AGENT_ID)!.accessor.get(IReadTool); - let args: ReadInput | undefined = { path: reference, max_chars: 500 }; + let args: ReadInput | undefined = { path: reference, max_chars: 1000 }; const firstExecution = await reader.resolveExecution(args); if (firstExecution.isError === true) throw new Error(JSON.stringify(firstExecution.output)); const first = await firstExecution.execute({ turnId: 1, toolCallId: 'read-first', signal: new AbortController().signal });