Files
AgentCompany/src/main/services/text-paging.ts
T

86 lines
3.8 KiB
TypeScript

import { optionalText } from "./agent-tool-args";
import type { JsonRecord } from "./provider-http";
const PAGE_CHAR_LIMIT = 24_000;
const SEARCH_MATCH_LIMIT = 200;
export interface PageRequest {
startLine: number;
startColumn: number;
lineCount: number | null;
search: string | null;
}
export const PAGE_TOOL_PROPERTIES = {
start_line: { type: "number", description: "First line to read (1-based). Use the start_line a truncated read tells you to continue." },
line_count: { type: "number", description: "How many lines to read from start_line." },
start_column: { type: "number", description: "Character in start_line to begin at (1-based), for continuing a cut line." },
search: { type: "string", description: "Return only the lines containing this text (case-insensitive), with line numbers." },
} as const;
export function readPageArgs(args: JsonRecord): PageRequest {
const search = optionalText(args.search, "search");
return {
startLine: positiveInteger(args.start_line, "start_line") ?? 1,
startColumn: positiveInteger(args.start_column, "start_column") ?? 1,
lineCount: positiveInteger(args.line_count, "line_count"),
search,
};
}
/** A window of the text that fits one tool result, with directions to the rest. */
export function pageText(name: string, content: string, request: PageRequest): string {
const lines = content.split("\n");
if (request.search) return searchLines(name, lines, request.search);
if (request.startLine > lines.length) throw new Error(`${name} has only ${lines.length} lines.`);
const last = request.lineCount === null ? lines.length : Math.min(lines.length, request.startLine + request.lineCount - 1);
const picked: string[] = [];
let size = 0;
let cut = false;
let lineNumber = request.startLine;
for (; lineNumber <= last; lineNumber += 1) {
const offset = lineNumber === request.startLine ? request.startColumn - 1 : 0;
const line = lines[lineNumber - 1].slice(offset);
const separator = picked.length > 0 ? 1 : 0;
if (size + separator + line.length > PAGE_CHAR_LIMIT) {
if (picked.length === 0) {
const nextColumn = offset + PAGE_CHAR_LIMIT + 1;
picked.push(`${line.slice(0, PAGE_CHAR_LIMIT)}\n[line ${lineNumber} continues: pass start_line=${lineNumber} and start_column=${nextColumn}]`);
cut = true;
lineNumber += 1;
}
break;
}
picked.push(line);
size += separator + line.length;
}
const end = lineNumber - 1;
if (request.startLine === 1 && request.startColumn === 1 && end === lines.length && !cut) return content;
const more = end < lines.length ? `\n[more: pass start_line=${end + 1} to continue]` : "";
return `[lines ${request.startLine}-${end} of ${lines.length} in ${name}]\n${picked.join("\n")}${more}`;
}
function searchLines(name: string, lines: string[], search: string): string {
const needle = search.toLowerCase();
const matches: string[] = [];
let total = 0;
let size = 0;
lines.forEach((line, index) => {
if (!line.toLowerCase().includes(needle)) return;
total += 1;
const entry = `${index + 1}: ${line.slice(0, 500)}`;
if (matches.length >= SEARCH_MATCH_LIMIT || size + entry.length + 1 > PAGE_CHAR_LIMIT) return;
matches.push(entry);
size += entry.length + 1;
});
if (total === 0) return `No lines in ${name} contain "${search}".`;
const cut = total > matches.length ? `\n[${total - matches.length} more matches not shown; narrow the search]` : "";
return `[${total} matching lines for "${search}" in ${name}]\n${matches.join("\n")}${cut}`;
}
function positiveInteger(value: unknown, field: string): number | null {
if (value === undefined || value === null) return null;
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error(`"${field}" must be a positive whole number.`);
return value;
}