mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 19:00:44 +00:00
Implement tool result truncation with actionable notices (#134)
- read: actionable notices with offset for continuation - First line > 30KB: return empty + bash command suggestion - Hit limit: '[Showing lines X-Y of Z. Use offset=N to continue]' - bash: tail truncation with temp file - Notice includes line range + temp file path - Edge case: last line > 30KB shows partial - grep: pre-truncate match lines to 500 chars - '[... truncated]' suffix on long lines - Notice for match limit and line truncation - find/ls: result/entry limit notices - '[N results limit reached. Use limit=M for more]' - All notices now in text content (LLM sees them) - TUI simplified (notices render as part of output) - Never return partial lines (except bash edge case)
This commit is contained in:
parent
de77cd1419
commit
b813a8b92b
9 changed files with 465 additions and 164 deletions
|
|
@ -118,7 +118,8 @@ describe("Context overflow error handling", () => {
|
||||||
|
|
||||||
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => {
|
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => {
|
||||||
it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => {
|
it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => {
|
||||||
const model = getModel("openai", "gpt-4o-mini");
|
const model = { ...getModel("openai", "gpt-4o-mini") };
|
||||||
|
model.api = "openai-completions" as any;
|
||||||
const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!);
|
const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!);
|
||||||
logResult(result);
|
logResult(result);
|
||||||
|
|
||||||
|
|
|
||||||
235
packages/coding-agent/docs/truncation.md
Normal file
235
packages/coding-agent/docs/truncation.md
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Tool Output Truncation
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **Line limit**: 2000 lines
|
||||||
|
- **Byte limit**: 30KB
|
||||||
|
- **Grep line limit**: 500 chars per match line
|
||||||
|
|
||||||
|
Whichever limit is hit first wins. **Never return partial lines** (except bash edge case).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## read
|
||||||
|
|
||||||
|
Head truncation (first N lines). Has offset/limit params for continuation.
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
**First line > 30KB:**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[Line 1 is 50KB, exceeds 30KB limit. Use bash to read: head -c 30000 path/to/file]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0, ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit line limit (2000 lines, < 30KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 1-2000 content]
|
||||||
|
|
||||||
|
[Showing lines 1-2000 of 5000. Use offset=2001 to continue]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 5000 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit byte limit (< 2000 lines, 30KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 1-500 content]
|
||||||
|
|
||||||
|
[Showing lines 1-500 of 5000 (30KB limit). Use offset=501 to continue]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 500, totalLines: 5000 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**With offset, hit line limit (e.g., offset=1000):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 1000-2999 content]
|
||||||
|
|
||||||
|
[Showing lines 1000-2999 of 5000. Use offset=3000 to continue]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "lines", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**With offset, hit byte limit (e.g., offset=1000, 30KB after 500 lines):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 1000-1499 content]
|
||||||
|
|
||||||
|
[Showing lines 1000-1499 of 5000 (30KB limit). Use offset=1500 to continue]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", outputLines: 500, ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**With offset, first line at offset > 30KB (e.g., offset=1000, line 1000 is 50KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[Line 1000 is 50KB, exceeds 30KB limit. Use bash: sed -n '1000p' file | head -c 30000]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bash
|
||||||
|
|
||||||
|
Tail truncation (last N lines). Writes full output to temp file if truncated.
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
**Hit line limit (2000 lines):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 48001-50000 content]
|
||||||
|
|
||||||
|
[Showing lines 48001-50000 of 50000. Full output: /tmp/pi-bash-xxx.log]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 50000 }, fullOutputPath: "/tmp/..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit byte limit (< 2000 lines, 30KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[lines 49501-50000 content]
|
||||||
|
|
||||||
|
[Showing lines 49501-50000 of 50000 (30KB limit). Full output: /tmp/pi-bash-xxx.log]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", ... }, fullOutputPath: "/tmp/..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Last line alone > 30KB (edge case, partial OK here):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[last 30KB of final line]
|
||||||
|
|
||||||
|
[Showing last 30KB of line 50000 (line is 100KB). Full output: /tmp/pi-bash-xxx.log]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", lastLinePartial: true }, fullOutputPath: "/tmp/..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## grep
|
||||||
|
|
||||||
|
Head truncation. Primary limit: 100 matches. Each match line truncated to 500 chars.
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
**Hit match limit (100 matches):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
file.ts:10: matching content here...
|
||||||
|
file.ts:25: another match...
|
||||||
|
...
|
||||||
|
|
||||||
|
[100 matches limit reached. Use limit=200 for more, or refine pattern]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ matchLimitReached: 100 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit byte limit (< 100 matches, 30KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[matches that fit in 30KB]
|
||||||
|
|
||||||
|
[30KB limit reached (50 of 100+ matches shown)]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Match lines truncated (any line > 500 chars):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
file.ts:10: very long matching content that exceeds 500 chars gets cut off here... [truncated]
|
||||||
|
file.ts:25: normal match
|
||||||
|
|
||||||
|
[Some lines truncated to 500 chars. Use read tool to see full lines]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ linesTruncated: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## find
|
||||||
|
|
||||||
|
Head truncation. Primary limit: 1000 results. File paths only (never > 30KB each).
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
**Hit result limit (1000 results):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
src/file1.ts
|
||||||
|
src/file2.ts
|
||||||
|
[998 more paths]
|
||||||
|
|
||||||
|
[1000 results limit reached. Use limit=2000 for more, or refine pattern]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ resultLimitReached: 1000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit byte limit (unlikely, < 1000 results, 30KB):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[paths that fit]
|
||||||
|
|
||||||
|
[30KB limit reached]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ls
|
||||||
|
|
||||||
|
Head truncation. Primary limit: 500 entries. Entry names only (never > 30KB each).
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
**Hit entry limit (500 entries):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
src/
|
||||||
|
[497 more entries]
|
||||||
|
|
||||||
|
[500 entries limit reached. Use limit=1000 for more]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ entryLimitReached: 500 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hit byte limit (unlikely):**
|
||||||
|
```
|
||||||
|
LLM sees:
|
||||||
|
[entries that fit]
|
||||||
|
|
||||||
|
[30KB limit reached]
|
||||||
|
|
||||||
|
Details:
|
||||||
|
{ truncation: { truncatedBy: "bytes", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TUI Display
|
||||||
|
|
||||||
|
`tool-execution.ts` reads `details.truncation` and related fields to display truncation notices in warning color. The LLM text content and TUI display show the same information.
|
||||||
|
|
@ -6,7 +6,7 @@ import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { spawn, spawnSync } from "child_process";
|
import { spawn, spawnSync } from "child_process";
|
||||||
import { SettingsManager } from "../settings-manager.js";
|
import { SettingsManager } from "../settings-manager.js";
|
||||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||||
|
|
||||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||||
|
|
||||||
|
|
@ -256,11 +256,26 @@ export const bashTool: AgentTool<typeof bashSchema> = {
|
||||||
|
|
||||||
// Build details with truncation info
|
// Build details with truncation info
|
||||||
let details: BashToolDetails | undefined;
|
let details: BashToolDetails | undefined;
|
||||||
|
|
||||||
if (truncation.truncated) {
|
if (truncation.truncated) {
|
||||||
details = {
|
details = {
|
||||||
truncation,
|
truncation,
|
||||||
fullOutputPath: tempFilePath,
|
fullOutputPath: tempFilePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build actionable notice
|
||||||
|
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||||
|
const endLine = truncation.totalLines;
|
||||||
|
|
||||||
|
if (truncation.lastLinePartial) {
|
||||||
|
// Edge case: last line alone > 30KB
|
||||||
|
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
|
||||||
|
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
||||||
|
} else if (truncation.truncatedBy === "lines") {
|
||||||
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
||||||
|
} else {
|
||||||
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code !== 0 && code !== null) {
|
if (code !== 0 && code !== null) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { globSync } from "glob";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { ensureTool } from "../tools-manager.js";
|
import { ensureTool } from "../tools-manager.js";
|
||||||
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand ~ to home directory
|
* Expand ~ to home directory
|
||||||
|
|
@ -160,25 +160,39 @@ export const findTool: AgentTool<typeof findSchema> = {
|
||||||
relativized.push(relativePath);
|
relativized.push(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawOutput = relativized.join("\n");
|
|
||||||
let details: FindToolDetails | undefined;
|
|
||||||
|
|
||||||
// Check if we hit the result limit
|
// Check if we hit the result limit
|
||||||
const hitResultLimit = relativized.length >= effectiveLimit;
|
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||||
|
|
||||||
// Apply byte truncation
|
// Apply byte truncation (no line limit since we already have result limit)
|
||||||
|
const rawOutput = relativized.join("\n");
|
||||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||||
const resultOutput = truncation.content;
|
|
||||||
|
|
||||||
// Include truncation info in details (result limit or byte limit)
|
let resultOutput = truncation.content;
|
||||||
if (hitResultLimit || truncation.truncated) {
|
const details: FindToolDetails = {};
|
||||||
details = {
|
|
||||||
truncation: truncation.truncated ? truncation : undefined,
|
// Build notices
|
||||||
resultLimitReached: hitResultLimit ? effectiveLimit : undefined,
|
const notices: string[] = [];
|
||||||
};
|
|
||||||
|
if (resultLimitReached) {
|
||||||
|
notices.push(
|
||||||
|
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||||
|
);
|
||||||
|
details.resultLimitReached = effectiveLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ content: [{ type: "text", text: resultOutput }], details });
|
if (truncation.truncated) {
|
||||||
|
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||||
|
details.truncation = truncation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notices.length > 0) {
|
||||||
|
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
content: [{ type: "text", text: resultOutput }],
|
||||||
|
details: Object.keys(details).length > 0 ? details : undefined,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
signal?.removeEventListener("abort", onAbort);
|
signal?.removeEventListener("abort", onAbort);
|
||||||
reject(e);
|
reject(e);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ import { readFileSync, type Stats, statSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { ensureTool } from "../tools-manager.js";
|
import { ensureTool } from "../tools-manager.js";
|
||||||
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
|
import {
|
||||||
|
DEFAULT_MAX_BYTES,
|
||||||
|
formatSize,
|
||||||
|
GREP_MAX_LINE_LENGTH,
|
||||||
|
type TruncationResult,
|
||||||
|
truncateHead,
|
||||||
|
truncateLine,
|
||||||
|
} from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand ~ to home directory
|
* Expand ~ to home directory
|
||||||
|
|
@ -40,12 +47,13 @@ const DEFAULT_LIMIT = 100;
|
||||||
interface GrepToolDetails {
|
interface GrepToolDetails {
|
||||||
truncation?: TruncationResult;
|
truncation?: TruncationResult;
|
||||||
matchLimitReached?: number;
|
matchLimitReached?: number;
|
||||||
|
linesTruncated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const grepTool: AgentTool<typeof grepSchema> = {
|
export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
name: "grep",
|
name: "grep",
|
||||||
label: "grep",
|
label: "grep",
|
||||||
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
|
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
|
||||||
parameters: grepSchema,
|
parameters: grepSchema,
|
||||||
execute: async (
|
execute: async (
|
||||||
_toolCallId: string,
|
_toolCallId: string,
|
||||||
|
|
@ -148,7 +156,8 @@ export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
const rl = createInterface({ input: child.stdout });
|
const rl = createInterface({ input: child.stdout });
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
let truncated = false;
|
let matchLimitReached = false;
|
||||||
|
let linesTruncated = false;
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
let killedDueToLimit = false;
|
let killedDueToLimit = false;
|
||||||
const outputLines: string[] = [];
|
const outputLines: string[] = [];
|
||||||
|
|
@ -176,7 +185,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
stderr += chunk.toString();
|
stderr += chunk.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatBlock = (filePath: string, lineNumber: number) => {
|
const formatBlock = (filePath: string, lineNumber: number): string[] => {
|
||||||
const relativePath = formatPath(filePath);
|
const relativePath = formatPath(filePath);
|
||||||
const lines = getFileLines(filePath);
|
const lines = getFileLines(filePath);
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
|
|
@ -192,10 +201,16 @@ export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
const sanitized = lineText.replace(/\r/g, "");
|
const sanitized = lineText.replace(/\r/g, "");
|
||||||
const isMatchLine = current === lineNumber;
|
const isMatchLine = current === lineNumber;
|
||||||
|
|
||||||
|
// Truncate long lines
|
||||||
|
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||||
|
if (wasTruncated) {
|
||||||
|
linesTruncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMatchLine) {
|
if (isMatchLine) {
|
||||||
block.push(`${relativePath}:${current}: ${sanitized}`);
|
block.push(`${relativePath}:${current}: ${truncatedText}`);
|
||||||
} else {
|
} else {
|
||||||
block.push(`${relativePath}-${current}- ${sanitized}`);
|
block.push(`${relativePath}-${current}- ${truncatedText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,7 +239,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchCount >= effectiveLimit) {
|
if (matchCount >= effectiveLimit) {
|
||||||
truncated = true;
|
matchLimitReached = true;
|
||||||
stopChild(true);
|
stopChild(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,22 +271,45 @@ export const grepTool: AgentTool<typeof grepSchema> = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply byte truncation
|
// Apply byte truncation (no line limit since we already have match limit)
|
||||||
const rawOutput = outputLines.join("\n");
|
const rawOutput = outputLines.join("\n");
|
||||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||||
|
|
||||||
const output = truncation.content;
|
let output = truncation.content;
|
||||||
let details: GrepToolDetails | undefined;
|
const details: GrepToolDetails = {};
|
||||||
|
|
||||||
// Include truncation info in details (match limit or byte limit)
|
// Build notices
|
||||||
if (truncated || truncation.truncated) {
|
const notices: string[] = [];
|
||||||
details = {
|
|
||||||
truncation: truncation.truncated ? truncation : undefined,
|
if (matchLimitReached) {
|
||||||
matchLimitReached: truncated ? effectiveLimit : undefined,
|
notices.push(
|
||||||
};
|
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||||
|
);
|
||||||
|
details.matchLimitReached = effectiveLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
settle(() => resolve({ content: [{ type: "text", text: output }], details }));
|
if (truncation.truncated) {
|
||||||
|
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||||
|
details.truncation = truncation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linesTruncated) {
|
||||||
|
notices.push(
|
||||||
|
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
|
||||||
|
);
|
||||||
|
details.linesTruncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notices.length > 0) {
|
||||||
|
output += `\n\n[${notices.join(". ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(() =>
|
||||||
|
resolve({
|
||||||
|
content: [{ type: "text", text: output }],
|
||||||
|
details: Object.keys(details).length > 0 ? details : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
settle(() => reject(err as Error));
|
settle(() => reject(err as Error));
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Type } from "@sinclair/typebox";
|
||||||
import { existsSync, readdirSync, statSync } from "fs";
|
import { existsSync, readdirSync, statSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import nodePath from "path";
|
import nodePath from "path";
|
||||||
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand ~ to home directory
|
* Expand ~ to home directory
|
||||||
|
|
@ -76,11 +76,11 @@ export const lsTool: AgentTool<typeof lsSchema> = {
|
||||||
|
|
||||||
// Format entries with directory indicators
|
// Format entries with directory indicators
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
let truncated = false;
|
let entryLimitReached = false;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (results.length >= effectiveLimit) {
|
if (results.length >= effectiveLimit) {
|
||||||
truncated = true;
|
entryLimitReached = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,22 +107,34 @@ export const lsTool: AgentTool<typeof lsSchema> = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply byte truncation (no line limit since we already have entry limit)
|
||||||
const rawOutput = results.join("\n");
|
const rawOutput = results.join("\n");
|
||||||
let details: LsToolDetails | undefined;
|
|
||||||
|
|
||||||
// Apply byte truncation
|
|
||||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||||
const output = truncation.content;
|
|
||||||
|
|
||||||
// Include truncation info in details (entry limit or byte limit)
|
let output = truncation.content;
|
||||||
if (truncated || truncation.truncated) {
|
const details: LsToolDetails = {};
|
||||||
details = {
|
|
||||||
truncation: truncation.truncated ? truncation : undefined,
|
// Build notices
|
||||||
entryLimitReached: truncated ? effectiveLimit : undefined,
|
const notices: string[] = [];
|
||||||
};
|
|
||||||
|
if (entryLimitReached) {
|
||||||
|
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
|
||||||
|
details.entryLimitReached = effectiveLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ content: [{ type: "text", text: output }], details });
|
if (truncation.truncated) {
|
||||||
|
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||||
|
details.truncation = truncation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notices.length > 0) {
|
||||||
|
output += `\n\n[${notices.join(". ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
content: [{ type: "text", text: output }],
|
||||||
|
details: Object.keys(details).length > 0 ? details : undefined,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
signal?.removeEventListener("abort", onAbort);
|
signal?.removeEventListener("abort", onAbort);
|
||||||
reject(e);
|
reject(e);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox";
|
||||||
import { constants } from "fs";
|
import { constants } from "fs";
|
||||||
import { access, readFile } from "fs/promises";
|
import { access, readFile } from "fs/promises";
|
||||||
import { extname, resolve as resolvePath } from "path";
|
import { extname, resolve as resolvePath } from "path";
|
||||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateHead } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand ~ to home directory
|
* Expand ~ to home directory
|
||||||
|
|
@ -108,43 +108,66 @@ export const readTool: AgentTool<typeof readSchema> = {
|
||||||
} else {
|
} else {
|
||||||
// Read as text
|
// Read as text
|
||||||
const textContent = await readFile(absolutePath, "utf-8");
|
const textContent = await readFile(absolutePath, "utf-8");
|
||||||
const lines = textContent.split("\n");
|
const allLines = textContent.split("\n");
|
||||||
|
const totalFileLines = allLines.length;
|
||||||
|
|
||||||
// Apply offset if specified (1-indexed to 0-indexed)
|
// Apply offset if specified (1-indexed to 0-indexed)
|
||||||
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||||
|
const startLineDisplay = startLine + 1; // For display (1-indexed)
|
||||||
|
|
||||||
// Check if offset is out of bounds
|
// Check if offset is out of bounds
|
||||||
if (startLine >= lines.length) {
|
if (startLine >= allLines.length) {
|
||||||
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
|
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
|
||||||
let selectedContent: string;
|
let selectedContent: string;
|
||||||
|
let userLimitedLines: number | undefined;
|
||||||
if (limit !== undefined) {
|
if (limit !== undefined) {
|
||||||
const endLine = Math.min(startLine + limit, lines.length);
|
const endLine = Math.min(startLine + limit, allLines.length);
|
||||||
selectedContent = lines.slice(startLine, endLine).join("\n");
|
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
||||||
|
userLimitedLines = endLine - startLine;
|
||||||
} else {
|
} else {
|
||||||
selectedContent = lines.slice(startLine).join("\n");
|
selectedContent = allLines.slice(startLine).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply truncation (respects both line and byte limits)
|
// Apply truncation (respects both line and byte limits)
|
||||||
const truncation = truncateHead(selectedContent);
|
const truncation = truncateHead(selectedContent);
|
||||||
|
|
||||||
let outputText = truncation.content;
|
let outputText: string;
|
||||||
|
|
||||||
// Add continuation hint if there's more content after our selection
|
if (truncation.firstLineExceedsLimit) {
|
||||||
// (only relevant when user specified limit and there's more in the file)
|
// First line at offset exceeds 30KB - tell model to use bash
|
||||||
if (limit !== undefined && startLine + limit < lines.length && !truncation.truncated) {
|
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
||||||
const remaining = lines.length - (startLine + limit);
|
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
||||||
outputText += `\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue]`;
|
details = { truncation };
|
||||||
|
} else if (truncation.truncated) {
|
||||||
|
// Truncation occurred - build actionable notice
|
||||||
|
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
||||||
|
const nextOffset = endLineDisplay + 1;
|
||||||
|
|
||||||
|
outputText = truncation.content;
|
||||||
|
|
||||||
|
if (truncation.truncatedBy === "lines") {
|
||||||
|
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
||||||
|
} else {
|
||||||
|
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;
|
||||||
|
}
|
||||||
|
details = { truncation };
|
||||||
|
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
||||||
|
// User specified limit, there's more content, but no truncation
|
||||||
|
const endLineDisplay = startLineDisplay + userLimitedLines - 1;
|
||||||
|
const remaining = allLines.length - (startLine + userLimitedLines);
|
||||||
|
const nextOffset = startLine + userLimitedLines + 1;
|
||||||
|
|
||||||
|
outputText = truncation.content;
|
||||||
|
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
|
||||||
|
} else {
|
||||||
|
// No truncation, no user limit exceeded
|
||||||
|
outputText = truncation.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
content = [{ type: "text", text: outputText }];
|
content = [{ type: "text", text: outputText }];
|
||||||
|
|
||||||
// Include truncation info in details if truncation occurred
|
|
||||||
if (truncation.truncated) {
|
|
||||||
details = { truncation };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if aborted after reading
|
// Check if aborted after reading
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
* Truncation is based on two independent limits - whichever is hit first wins:
|
* Truncation is based on two independent limits - whichever is hit first wins:
|
||||||
* - Line limit (default: 2000 lines)
|
* - Line limit (default: 2000 lines)
|
||||||
* - Byte limit (default: 30KB)
|
* - Byte limit (default: 30KB)
|
||||||
|
*
|
||||||
|
* Never returns partial lines (except bash tail truncation edge case).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const DEFAULT_MAX_LINES = 2000;
|
export const DEFAULT_MAX_LINES = 2000;
|
||||||
export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB
|
export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB
|
||||||
|
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
|
||||||
|
|
||||||
export interface TruncationResult {
|
export interface TruncationResult {
|
||||||
/** The truncated content */
|
/** The truncated content */
|
||||||
|
|
@ -20,12 +23,14 @@ export interface TruncationResult {
|
||||||
totalLines: number;
|
totalLines: number;
|
||||||
/** Total number of bytes in the original content */
|
/** Total number of bytes in the original content */
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
/** Number of lines in the truncated output */
|
/** Number of complete lines in the truncated output */
|
||||||
outputLines: number;
|
outputLines: number;
|
||||||
/** Number of bytes in the truncated output */
|
/** Number of bytes in the truncated output */
|
||||||
outputBytes: number;
|
outputBytes: number;
|
||||||
/** Human-readable truncation notice (empty if not truncated) */
|
/** Whether the last line was partially truncated (only for tail truncation edge case) */
|
||||||
notice: string;
|
lastLinePartial: boolean;
|
||||||
|
/** Whether the first line exceeded the byte limit (for head truncation) */
|
||||||
|
firstLineExceedsLimit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TruncationOptions {
|
export interface TruncationOptions {
|
||||||
|
|
@ -38,7 +43,7 @@ export interface TruncationOptions {
|
||||||
/**
|
/**
|
||||||
* Format bytes as human-readable size.
|
* Format bytes as human-readable size.
|
||||||
*/
|
*/
|
||||||
function formatSize(bytes: number): string {
|
export function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
return `${bytes}B`;
|
return `${bytes}B`;
|
||||||
} else if (bytes < 1024 * 1024) {
|
} else if (bytes < 1024 * 1024) {
|
||||||
|
|
@ -48,31 +53,12 @@ function formatSize(bytes: number): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a truncation notice.
|
|
||||||
*/
|
|
||||||
function makeNotice(
|
|
||||||
direction: "head" | "tail",
|
|
||||||
truncatedBy: "lines" | "bytes",
|
|
||||||
totalLines: number,
|
|
||||||
totalBytes: number,
|
|
||||||
outputLines: number,
|
|
||||||
outputBytes: number,
|
|
||||||
): string {
|
|
||||||
const totalSize = formatSize(totalBytes);
|
|
||||||
const outputSize = formatSize(outputBytes);
|
|
||||||
const directionText = direction === "head" ? "first" : "last";
|
|
||||||
|
|
||||||
if (truncatedBy === "lines") {
|
|
||||||
return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`;
|
|
||||||
} else {
|
|
||||||
return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate content from the head (keep first N lines/bytes).
|
* Truncate content from the head (keep first N lines/bytes).
|
||||||
* Suitable for file reads where you want to see the beginning.
|
* Suitable for file reads where you want to see the beginning.
|
||||||
|
*
|
||||||
|
* Never returns partial lines. If first line exceeds byte limit,
|
||||||
|
* returns empty content with firstLineExceedsLimit=true.
|
||||||
*/
|
*/
|
||||||
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
|
|
@ -92,11 +78,28 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||||
totalBytes,
|
totalBytes,
|
||||||
outputLines: totalLines,
|
outputLines: totalLines,
|
||||||
outputBytes: totalBytes,
|
outputBytes: totalBytes,
|
||||||
notice: "",
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which limit we'll hit first
|
// Check if first line alone exceeds byte limit
|
||||||
|
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
|
||||||
|
if (firstLineBytes > maxBytes) {
|
||||||
|
return {
|
||||||
|
content: "",
|
||||||
|
truncated: true,
|
||||||
|
truncatedBy: "bytes",
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: 0,
|
||||||
|
outputBytes: 0,
|
||||||
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect complete lines that fit
|
||||||
const outputLinesArr: string[] = [];
|
const outputLinesArr: string[] = [];
|
||||||
let outputBytesCount = 0;
|
let outputBytesCount = 0;
|
||||||
let truncatedBy: "lines" | "bytes" = "lines";
|
let truncatedBy: "lines" | "bytes" = "lines";
|
||||||
|
|
@ -107,12 +110,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||||
|
|
||||||
if (outputBytesCount + lineBytes > maxBytes) {
|
if (outputBytesCount + lineBytes > maxBytes) {
|
||||||
truncatedBy = "bytes";
|
truncatedBy = "bytes";
|
||||||
// If this is the first line and it alone exceeds maxBytes, include partial
|
|
||||||
if (i === 0) {
|
|
||||||
const truncatedLine = truncateStringToBytes(line, maxBytes);
|
|
||||||
outputLinesArr.push(truncatedLine);
|
|
||||||
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,13 +133,16 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||||
totalBytes,
|
totalBytes,
|
||||||
outputLines: outputLinesArr.length,
|
outputLines: outputLinesArr.length,
|
||||||
outputBytes: finalOutputBytes,
|
outputBytes: finalOutputBytes,
|
||||||
notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes),
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate content from the tail (keep last N lines/bytes).
|
* Truncate content from the tail (keep last N lines/bytes).
|
||||||
* Suitable for bash output where you want to see the end (errors, final results).
|
* Suitable for bash output where you want to see the end (errors, final results).
|
||||||
|
*
|
||||||
|
* May return partial first line if the last line of original content exceeds byte limit.
|
||||||
*/
|
*/
|
||||||
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
|
|
@ -162,7 +162,8 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||||
totalBytes,
|
totalBytes,
|
||||||
outputLines: totalLines,
|
outputLines: totalLines,
|
||||||
outputBytes: totalBytes,
|
outputBytes: totalBytes,
|
||||||
notice: "",
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,6 +171,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||||
const outputLinesArr: string[] = [];
|
const outputLinesArr: string[] = [];
|
||||||
let outputBytesCount = 0;
|
let outputBytesCount = 0;
|
||||||
let truncatedBy: "lines" | "bytes" = "lines";
|
let truncatedBy: "lines" | "bytes" = "lines";
|
||||||
|
let lastLinePartial = false;
|
||||||
|
|
||||||
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
@ -177,12 +179,13 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||||
|
|
||||||
if (outputBytesCount + lineBytes > maxBytes) {
|
if (outputBytesCount + lineBytes > maxBytes) {
|
||||||
truncatedBy = "bytes";
|
truncatedBy = "bytes";
|
||||||
// If this is the first line we're adding and it alone exceeds maxBytes, include partial
|
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
|
||||||
|
// take the end of the line (partial)
|
||||||
if (outputLinesArr.length === 0) {
|
if (outputLinesArr.length === 0) {
|
||||||
// Take the end of the line
|
|
||||||
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
||||||
outputLinesArr.unshift(truncatedLine);
|
outputLinesArr.unshift(truncatedLine);
|
||||||
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
||||||
|
lastLinePartial = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -207,29 +210,11 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||||
totalBytes,
|
totalBytes,
|
||||||
outputLines: outputLinesArr.length,
|
outputLines: outputLinesArr.length,
|
||||||
outputBytes: finalOutputBytes,
|
outputBytes: finalOutputBytes,
|
||||||
notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes),
|
lastLinePartial,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate a string to fit within a byte limit (from the start).
|
|
||||||
* Handles multi-byte UTF-8 characters correctly.
|
|
||||||
*/
|
|
||||||
function truncateStringToBytes(str: string, maxBytes: number): string {
|
|
||||||
const buf = Buffer.from(str, "utf-8");
|
|
||||||
if (buf.length <= maxBytes) {
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a valid UTF-8 boundary
|
|
||||||
let end = maxBytes;
|
|
||||||
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
|
||||||
end--;
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.slice(0, end).toString("utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate a string to fit within a byte limit (from the end).
|
* Truncate a string to fit within a byte limit (from the end).
|
||||||
* Handles multi-byte UTF-8 characters correctly.
|
* Handles multi-byte UTF-8 characters correctly.
|
||||||
|
|
@ -250,3 +235,17 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
||||||
|
|
||||||
return buf.slice(start).toString("utf-8");
|
return buf.slice(start).toString("utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a single line to max characters, adding [truncated] suffix.
|
||||||
|
* Used for grep match lines.
|
||||||
|
*/
|
||||||
|
export function truncateLine(
|
||||||
|
line: string,
|
||||||
|
maxChars: number = GREP_MAX_LINE_LENGTH,
|
||||||
|
): { text: string; wasTruncated: boolean } {
|
||||||
|
if (line.length <= maxChars) {
|
||||||
|
return { text: line, wasTruncated: false };
|
||||||
|
}
|
||||||
|
return { text: line.slice(0, maxChars) + "... [truncated]", wasTruncated: true };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show truncation notice at the bottom in warning color if present in details
|
// Truncation notice is now in the text content itself, TUI just shows it
|
||||||
const truncation = this.result.details?.truncation;
|
|
||||||
const fullOutputPath = this.result.details?.fullOutputPath;
|
|
||||||
if (truncation?.truncated) {
|
|
||||||
if (fullOutputPath) {
|
|
||||||
text += "\n" + theme.fg("warning", `[Full output: ${fullOutputPath}]`);
|
|
||||||
}
|
|
||||||
text += "\n" + theme.fg("warning", truncation.notice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "read") {
|
} else if (this.toolName === "read") {
|
||||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||||
|
|
@ -166,11 +158,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show truncation notice at the bottom in warning color if present in details
|
// Truncation notice is now in the text content itself
|
||||||
const truncation = this.result.details?.truncation;
|
|
||||||
if (truncation?.truncated) {
|
|
||||||
text += "\n" + theme.fg("warning", truncation.notice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "write") {
|
} else if (this.toolName === "write") {
|
||||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||||
|
|
@ -249,15 +237,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show truncation notice from details
|
// Truncation notice is now in the text content itself
|
||||||
const entryLimit = this.result.details?.entryLimitReached;
|
|
||||||
const truncation = this.result.details?.truncation;
|
|
||||||
if (entryLimit) {
|
|
||||||
text += "\n" + theme.fg("warning", `[Truncated: ${entryLimit} entries limit reached]`);
|
|
||||||
}
|
|
||||||
if (truncation?.truncated) {
|
|
||||||
text += "\n" + theme.fg("warning", truncation.notice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "find") {
|
} else if (this.toolName === "find") {
|
||||||
const pattern = this.args?.pattern || "";
|
const pattern = this.args?.pattern || "";
|
||||||
|
|
@ -287,15 +267,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show truncation notice from details
|
// Truncation notice is now in the text content itself
|
||||||
const resultLimit = this.result.details?.resultLimitReached;
|
|
||||||
const truncation = this.result.details?.truncation;
|
|
||||||
if (resultLimit) {
|
|
||||||
text += "\n" + theme.fg("warning", `[Truncated: ${resultLimit} results limit reached]`);
|
|
||||||
}
|
|
||||||
if (truncation?.truncated) {
|
|
||||||
text += "\n" + theme.fg("warning", truncation.notice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "grep") {
|
} else if (this.toolName === "grep") {
|
||||||
const pattern = this.args?.pattern || "";
|
const pattern = this.args?.pattern || "";
|
||||||
|
|
@ -329,15 +301,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show truncation notice from details
|
// Truncation notice is now in the text content itself
|
||||||
const matchLimit = this.result.details?.matchLimitReached;
|
|
||||||
const truncation = this.result.details?.truncation;
|
|
||||||
if (matchLimit) {
|
|
||||||
text += "\n" + theme.fg("warning", `[Truncated: ${matchLimit} matches limit reached]`);
|
|
||||||
}
|
|
||||||
if (truncation?.truncated) {
|
|
||||||
text += "\n" + theme.fg("warning", truncation.notice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Generic tool
|
// Generic tool
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue