diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 760953d05..82e4d86bd 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -980,33 +980,12 @@ export class AgentSession { /** * Switch edit mode between standard (text-match) and hashline (LINE#ID anchors). - * Swaps the active read/edit tools and rebuilds the system prompt. + * Both modes now use the same Read/Edit tools; the mode setting is persisted for + * system-prompt context only. Read({format:"tagged"}) and Edit({match:"anchor"}) + * are the caller-controlled arguments. */ setEditMode(mode: "standard" | "hashline"): void { this.settingsManager.setEditMode(mode); - - // Get current active tool registry keys - const currentKeys = new Set(); - for (const [key, tool] of this._toolRegistry.entries()) { - if (this.agent.state.tools.includes(tool)) { - currentKeys.add(key); - } - } - - // Swap read tools - if (mode === "hashline") { - currentKeys.delete("Read"); - currentKeys.add("hashline_read"); - currentKeys.delete("Edit"); - currentKeys.add("hashline_edit"); - } else { - currentKeys.delete("hashline_read"); - currentKeys.add("Read"); - currentKeys.delete("hashline_edit"); - currentKeys.add("Edit"); - } - - this.setActiveToolsByName([...currentKeys]); } /** Current edit mode */ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 1e0b8f2cd..f61ff0499 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -82,9 +82,6 @@ import { createEditTool, createFindTool, createGrepTool, - createHashlineCodingTools, - createHashlineEditTool, - createHashlineReadTool, createLsTool, createReadOnlyTools, createReadTool, @@ -92,9 +89,6 @@ import { editTool, findTool, grepTool, - hashlineCodingTools, - hashlineEditTool, - hashlineReadTool, lsTool, readOnlyTools, readTool, @@ -180,9 +174,6 @@ export { createEditTool, createFindTool, createGrepTool, - createHashlineCodingTools, - createHashlineEditTool, - createHashlineReadTool, createLsTool, createReadOnlyTools, createReadTool, @@ -190,10 +181,6 @@ export { editTool, findTool, grepTool, - // Hashline edit mode - hashlineCodingTools, - hashlineEditTool, - hashlineReadTool, lsTool, readOnlyTools, // Pre-built tools (use process.cwd()) @@ -358,20 +345,16 @@ export async function createAgentSession( thinkingLevel = "off"; } - const editMode = settingsManager.getEditMode(); - const defaultActiveToolNames: ToolName[] = - editMode === "hashline" - ? [ - "hashline_read", - "Grep", - "Glob", - "LS", - "Bash", - "hashline_edit", - "Write", - "lsp", - ] - : ["Read", "Grep", "Glob", "LS", "Bash", "Edit", "Write", "lsp"]; + const defaultActiveToolNames: ToolName[] = [ + "Read", + "Grep", + "Glob", + "LS", + "Bash", + "Edit", + "Write", + "lsp", + ]; const initialActiveToolNames: ToolName[] = options.tools ? options.tools .map((t) => t.name) diff --git a/packages/coding-agent/src/core/tools/hashline-edit.ts b/packages/coding-agent/src/core/tools/hashline-edit.ts deleted file mode 100644 index c617a0c94..000000000 --- a/packages/coding-agent/src/core/tools/hashline-edit.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Hashline edit tool — applies file edits using line-hash anchors. - * - * The model references lines by `LINE#ID` tags from read output. - * Each tag uniquely identifies a line, so edits remain stable even when lines shift. - */ - -import { constants } from "node:fs"; -import { - access as fsAccess, - readFile as fsReadFile, - unlink as fsUnlink, - writeFile as fsWriteFile, -} from "node:fs/promises"; -import { type Static, Type } from "@sinclair/typebox"; -import type { AgentTool } from "@singularity-forge/agent-core"; -import { - detectLineEnding, - generateDiffString, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "./edit-diff.js"; -import { - type Anchor, - applyHashlineEdits, - type HashlineEdit, - parseHashlineText, - parseTag, -} from "./hashline.js"; -import { resolveToCwd } from "./path-utils.js"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Schema -// ═══════════════════════════════════════════════════════════════════════════ - -const hashlineEditItemSchema = Type.Object( - { - op: Type.Union([ - Type.Literal("replace"), - Type.Literal("append"), - Type.Literal("prepend"), - ]), - pos: Type.Optional( - Type.String({ description: 'Anchor tag (e.g. "5#QQ")' }), - ), - end: Type.Optional( - Type.String({ description: "End anchor for range replace" }), - ), - lines: Type.Union([ - Type.Array(Type.String(), { description: "Replacement content lines" }), - Type.String(), - Type.Null(), - ]), - }, - { additionalProperties: false }, -); - -const hashlineEditSchema = Type.Object( - { - path: Type.String({ description: "Path to the file to edit" }), - edits: Type.Array(hashlineEditItemSchema, { - description: - "Edits to apply (referenced by LINE#ID tags from read output)", - }), - delete: Type.Optional( - Type.Boolean({ description: "If true, delete the file" }), - ), - move: Type.Optional( - Type.String({ description: "If set, move/rename the file to this path" }), - ), - }, - { additionalProperties: false }, -); - -export type HashlineEditInput = Static; -export type HashlineEditItem = Static; - -export interface HashlineEditToolDetails { - /** Unified diff of the changes made */ - diff: string; - /** Line number of the first change in the new file */ - firstChangedLine?: number; -} - -/** - * Pluggable operations for the hashline edit tool. - */ -export interface HashlineEditOperations { - readFile: (absolutePath: string) => Promise; - writeFile: (absolutePath: string, content: string) => Promise; - access: (absolutePath: string) => Promise; - unlink: (absolutePath: string) => Promise; -} - -const defaultHashlineEditOperations: HashlineEditOperations = { - readFile: (path) => fsReadFile(path), - writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), - access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), - unlink: (path) => fsUnlink(path), -}; - -export interface HashlineEditToolOptions { - operations?: HashlineEditOperations; -} - -/** Parse a tag, returning undefined instead of throwing on garbage. */ -function tryParseTag(raw: string): Anchor | undefined { - try { - return parseTag(raw); - } catch { - return undefined; - } -} - -/** - * Map flat tool-schema edits into typed HashlineEdit objects. - */ -function resolveEditAnchors(edits: HashlineEditItem[]): HashlineEdit[] { - const result: HashlineEdit[] = []; - for (const edit of edits) { - const lines = parseHashlineText(edit.lines); - const tag = edit.pos ? tryParseTag(edit.pos) : undefined; - const end = edit.end ? tryParseTag(edit.end) : undefined; - - const op = - edit.op === "append" || edit.op === "prepend" ? edit.op : "replace"; - switch (op) { - case "replace": { - if (tag && end) { - result.push({ op: "replace", pos: tag, end, lines }); - } else if (tag || end) { - result.push({ op: "replace", pos: tag || end!, lines }); - } else { - throw new Error("Replace requires at least one anchor (pos or end)."); - } - break; - } - case "append": { - result.push({ op: "append", pos: tag ?? end, lines }); - break; - } - case "prepend": { - result.push({ op: "prepend", pos: end ?? tag, lines }); - break; - } - } - } - return result; -} - -const HASHLINE_EDIT_DESCRIPTION = `Edit a file by referencing LINE#ID tags from read output. Each tag uniquely identifies a line via content hash, so edits remain stable even when lines shift. - -Read the file first to get fresh tags. Submit one edit call per file with all operations batched. - -Operations: -- replace: Replace line(s) at pos (and optionally through end) with lines content -- append: Insert lines after pos (omit pos for end of file) -- prepend: Insert lines before pos (omit pos for beginning of file) - -Set lines to null or [] to delete lines. Set delete:true to delete the file.`; - -export function createHashlineEditTool( - cwd: string, - options?: HashlineEditToolOptions, -): AgentTool { - const ops = options?.operations ?? defaultHashlineEditOperations; - - return { - name: "hashline_edit", - label: "hashline_edit", - description: HASHLINE_EDIT_DESCRIPTION, - parameters: hashlineEditSchema, - execute: async ( - _toolCallId: string, - params: HashlineEditInput, - signal?: AbortSignal, - ) => { - const { path, edits, delete: deleteFile, move } = params; - const absolutePath = resolveToCwd(path, cwd); - - return new Promise<{ - content: Array<{ type: "text"; text: string }>; - details: HashlineEditToolDetails | undefined; - }>((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } - - let aborted = false; - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - (async () => { - try { - // Handle delete - if (deleteFile) { - let fileExists = true; - try { - await ops.access(absolutePath); - } catch { - fileExists = false; - } - if (fileExists) { - await ops.unlink(absolutePath); - } - if (signal) signal.removeEventListener("abort", onAbort); - resolve({ - content: [ - { - type: "text", - text: fileExists - ? `Deleted ${path}` - : `File not found, nothing to delete: ${path}`, - }, - ], - details: { diff: "" }, - }); - return; - } - - // Handle file creation (no existing file, anchorless appends/prepends) - let fileExists = true; - try { - await ops.access(absolutePath); - } catch { - fileExists = false; - } - - if (!fileExists) { - const lines: string[] = []; - for (const edit of edits) { - if ( - (edit.op === "append" || edit.op === "prepend") && - !edit.pos && - !edit.end - ) { - if (edit.op === "prepend") { - lines.unshift(...parseHashlineText(edit.lines)); - } else { - lines.push(...parseHashlineText(edit.lines)); - } - } else { - throw new Error(`File not found: ${path}`); - } - } - await ops.writeFile(absolutePath, lines.join("\n")); - if (signal) signal.removeEventListener("abort", onAbort); - resolve({ - content: [{ type: "text", text: `Created ${path}` }], - details: { diff: "" }, - }); - return; - } - - if (aborted) return; - - // Read file - const rawContent = (await ops.readFile(absolutePath)).toString( - "utf-8", - ); - const { bom, text } = stripBom(rawContent); - const originalEnding = detectLineEnding(text); - const originalNormalized = normalizeToLF(text); - - if (aborted) return; - - // Resolve and apply edits - const anchorEdits = resolveEditAnchors(edits); - const result = applyHashlineEdits(originalNormalized, anchorEdits); - - if (originalNormalized === result.lines && !move) { - let diagnostic = `No changes made to ${path}. The edits produced identical content.`; - if (result.noopEdits && result.noopEdits.length > 0) { - const details = result.noopEdits - .map( - (e) => - `Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.current}`, - ) - .join("\n"); - diagnostic += `\n${details}`; - diagnostic += - "\nYour content must differ from what the file already contains. Re-read the file to see the current state."; - } - throw new Error(diagnostic); - } - - if (aborted) return; - - // Write result - const finalContent = - bom + restoreLineEndings(result.lines, originalEnding); - const writePath = move ? resolveToCwd(move, cwd) : absolutePath; - - // Prevent silent overwrite when moving to an existing file - if (move && writePath !== absolutePath) { - try { - await ops.access(writePath); - // If access succeeds, the file exists — refuse the move - throw new Error( - `Destination file already exists: ${writePath}. Use a different path or delete the existing file first.`, - ); - } catch (err: any) { - // Re-throw our own error; swallow only "file not found" - if (err.message?.startsWith("Destination file already exists:")) - throw err; - // File doesn't exist — safe to proceed - } - } - - await ops.writeFile(writePath, finalContent); - - // If moved, delete original - if (move && writePath !== absolutePath) { - await ops.unlink(absolutePath); - } - - if (aborted) return; - - if (signal) signal.removeEventListener("abort", onAbort); - - const diffResult = generateDiffString( - originalNormalized, - result.lines, - ); - const resultText = move - ? `Moved ${path} to ${move}` - : `Updated ${path}`; - const warningsBlock = result.warnings?.length - ? `\nWarnings:\n${result.warnings.join("\n")}` - : ""; - - resolve({ - content: [ - { - type: "text", - text: `${resultText}${warningsBlock}`, - }, - ], - details: { - diff: diffResult.diff, - firstChangedLine: - result.firstChangedLine ?? diffResult.firstChangedLine, - }, - }); - } catch (error: any) { - if (signal) signal.removeEventListener("abort", onAbort); - if (!aborted) { - reject(error); - } - } - })(); - }); - }, - }; -} - -/** Default hashline edit tool using process.cwd() */ -export const hashlineEditTool = createHashlineEditTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/hashline-read.ts b/packages/coding-agent/src/core/tools/hashline-read.ts deleted file mode 100644 index 6a7c6ffab..000000000 --- a/packages/coding-agent/src/core/tools/hashline-read.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Hashline read tool — reads files with LINE#ID prefix on each line. - * - * Produces output like: - * 1#QQ:function hello() { - * 2#KX: return 42; - * 3#NW:} - * - * These tags are used by the hashline_edit tool to address lines precisely. - */ - -import { constants } from "node:fs"; -import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises"; -import { type Static, Type } from "@sinclair/typebox"; -import type { AgentTool } from "@singularity-forge/agent-core"; -import type { ImageContent, TextContent } from "@singularity-forge/ai"; -import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; -import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; -import { formatHashLines } from "./hashline.js"; -import { resolveReadPath } from "./path-utils.js"; -import { - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - formatSize, - type TruncationResult, - truncateHead, -} from "./truncate.js"; - -const readSchema = Type.Object({ - path: Type.String({ - description: "Path to the file to read (relative or absolute)", - }), - offset: Type.Optional( - Type.Number({ - description: "Line number to start reading from (1-indexed)", - }), - ), - limit: Type.Optional( - Type.Number({ description: "Maximum number of lines to read" }), - ), -}); - -export type HashlineReadToolInput = Static; - -export interface HashlineReadToolDetails { - truncation?: TruncationResult; -} - -/** - * Pluggable operations for the hashline read tool. - */ -export interface HashlineReadOperations { - readFile: (absolutePath: string) => Promise; - access: (absolutePath: string) => Promise; - detectImageMimeType?: ( - absolutePath: string, - ) => Promise; -} - -const defaultReadOperations: HashlineReadOperations = { - readFile: (path) => fsReadFile(path), - access: (path) => fsAccess(path, constants.R_OK), - detectImageMimeType: detectSupportedImageMimeTypeFromFile, -}; - -export interface HashlineReadToolOptions { - autoResizeImages?: boolean; - operations?: HashlineReadOperations; -} - -export function createHashlineReadTool( - cwd: string, - options?: HashlineReadToolOptions, -): AgentTool { - const autoResizeImages = options?.autoResizeImages ?? true; - const ops = options?.operations ?? defaultReadOperations; - - return { - name: "Read", - label: "Read", - description: `Read a file with LINE#ID hash anchors on each line. These anchors are used by hashline_edit for precise edits. Output format: LINENUM#HASH:CONTENT. Supports text files and images. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB. Use offset/limit for large files.`, - parameters: readSchema, - execute: async ( - _toolCallId: string, - { - path, - offset, - limit, - }: { path: string; offset?: number; limit?: number }, - signal?: AbortSignal, - ) => { - const absolutePath = resolveReadPath(path, cwd); - - return new Promise<{ - content: (TextContent | ImageContent)[]; - details: HashlineReadToolDetails | undefined; - }>((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } - - let aborted = false; - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - (async () => { - try { - await ops.access(absolutePath); - - if (aborted) return; - - const mimeType = ops.detectImageMimeType - ? await ops.detectImageMimeType(absolutePath) - : undefined; - - let content: (TextContent | ImageContent)[]; - let details: HashlineReadToolDetails | undefined; - - if (mimeType) { - // Image handling (identical to standard read tool) - const buffer = await ops.readFile(absolutePath); - const base64 = buffer.toString("base64"); - - if (autoResizeImages) { - const resized = await resizeImage({ - type: "image", - data: base64, - mimeType, - }); - const dimensionNote = formatDimensionNote(resized); - let textNote = `Read image file [${resized.mimeType}]`; - if (dimensionNote) { - textNote += `\n${dimensionNote}`; - } - content = [ - { type: "text", text: textNote }, - { - type: "image", - data: resized.data, - mimeType: resized.mimeType, - }, - ]; - } else { - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; - } - } else { - // Text file — format with hashline prefixes - const buffer = await ops.readFile(absolutePath); - const textContent = buffer.toString("utf-8"); - const allLines = textContent.split("\n"); - const totalFileLines = allLines.length; - - let startLine = offset ? Math.max(0, offset - 1) : 0; - - // Clamp offset to file bounds instead of throwing (#3007) - let offsetClamped = false; - if (startLine >= allLines.length) { - startLine = Math.max(0, allLines.length - 1); - offsetClamped = true; - } - const startLineDisplay = startLine + 1; - - let selectedContent: string; - let userLimitedLines: number | undefined; - if (limit !== undefined) { - const endLine = Math.min(startLine + limit, allLines.length); - selectedContent = allLines.slice(startLine, endLine).join("\n"); - userLimitedLines = endLine - startLine; - } else { - selectedContent = allLines.slice(startLine).join("\n"); - } - - // Apply truncation - const truncation = truncateHead(selectedContent); - - let outputText: string; - - if (truncation.firstLineExceedsLimit) { - const firstLineSize = formatSize( - Buffer.byteLength(allLines[startLine], "utf-8"), - ); - outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; - details = { truncation }; - } else if (truncation.truncated) { - const endLineDisplay = - startLineDisplay + truncation.outputLines - 1; - const nextOffset = endLineDisplay + 1; - - // Format with hashline prefixes - outputText = formatHashLines( - truncation.content, - startLineDisplay, - ); - - 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 - ) { - const remaining = - allLines.length - (startLine + userLimitedLines); - const nextOffset = startLine + userLimitedLines + 1; - - outputText = formatHashLines( - truncation.content, - startLineDisplay, - ); - outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; - } else { - outputText = formatHashLines( - truncation.content, - startLineDisplay, - ); - } - - // Prepend clamp notice so the agent knows offset was adjusted - if (offsetClamped) { - outputText = `[Offset ${offset} beyond end of file (${totalFileLines} lines). Clamped to line ${startLineDisplay}.]\n\n${outputText}`; - } - - content = [{ type: "text", text: outputText }]; - } - - if (aborted) return; - - if (signal) signal.removeEventListener("abort", onAbort); - resolve({ content, details }); - } catch (error: any) { - if (signal) signal.removeEventListener("abort", onAbort); - if (!aborted) { - reject(error); - } - } - })(); - }); - }, - }; -} - -/** Default hashline read tool using process.cwd() */ -export const hashlineReadTool = createHashlineReadTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 93ba9cec9..cd65f571b 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -65,23 +65,6 @@ export { stripNewLinePrefixes, validateLineRef, } from "./hashline.js"; -export { - createHashlineEditTool, - type HashlineEditInput, - type HashlineEditItem, - type HashlineEditOperations, - type HashlineEditToolDetails, - type HashlineEditToolOptions, - hashlineEditTool, -} from "./hashline-edit.js"; -export { - createHashlineReadTool, - type HashlineReadOperations, - type HashlineReadToolDetails, - type HashlineReadToolInput, - type HashlineReadToolOptions, - hashlineReadTool, -} from "./hashline-read.js"; export { createLsTool, type LsOperations, @@ -130,8 +113,6 @@ import { type BashToolOptions, bashTool, createBashTool } from "./bash.js"; import { createEditTool, editTool } from "./edit.js"; import { createFindTool, findTool } from "./find.js"; import { createGrepTool, grepTool } from "./grep.js"; -import { createHashlineEditTool, hashlineEditTool } from "./hashline-edit.js"; -import { createHashlineReadTool, hashlineReadTool } from "./hashline-read.js"; import { createInsertAroundSymbolTool } from "./insert-around-symbol.js"; import { createLsTool, lsTool } from "./ls.js"; import { createReadTool, type ReadToolOptions, readTool } from "./read.js"; @@ -166,8 +147,6 @@ export const allTools = { Glob: findTool, LS: lsTool, lsp: lspTool, - hashline_edit: hashlineEditTool, - hashline_read: hashlineReadTool, // Serena-style AST tools: tree-sitter-anchored, language-aware editing. // More durable than substring Edit / line-anchor hashline for code in // languages with a tree-sitter grammar. @@ -176,18 +155,6 @@ export const allTools = { AstGrep: createAstGrepTool(process.cwd()), }; -// Hashline-mode coding tools — read with hash anchors, edit with hash references -export const hashlineCodingTools: Tool[] = [ - hashlineReadTool, - grepTool, - findTool, - lsTool, - bashTool, - hashlineEditTool, - writeTool, - lspTool, -]; - export type ToolName = keyof typeof allTools; export interface ToolsOptions { @@ -244,30 +211,8 @@ export function createAllTools( Glob: createFindTool(cwd), LS: createLsTool(cwd), lsp: createLspTool(cwd), - hashline_edit: createHashlineEditTool(cwd), - hashline_read: createHashlineReadTool(cwd, options?.read), ReplaceSymbol: createReplaceSymbolTool(cwd), InsertAroundSymbol: createInsertAroundSymbolTool(cwd), AstGrep: createAstGrepTool(cwd), }; } - -/** - * Create hashline-mode coding tools configured for a specific working directory. - * Uses hashline read (LINE#ID prefixed output) and hashline edit (hash-anchor based edits). - */ -export function createHashlineCodingTools( - cwd: string, - options?: ToolsOptions, -): Tool[] { - return [ - createHashlineReadTool(cwd, options?.read), - createGrepTool(cwd), - createFindTool(cwd), - createLsTool(cwd), - createBashTool(cwd, options?.bash), - createHashlineEditTool(cwd), - createWriteTool(cwd), - createLspTool(cwd), - ]; -} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index bdd791c8c..e8ba6f671 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -6,6 +6,7 @@ import type { AgentTool } from "@singularity-forge/agent-core"; import type { ImageContent, TextContent } from "@singularity-forge/ai"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; +import { formatHashLines } from "./hashline.js"; import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, @@ -27,6 +28,13 @@ const readSchema = Type.Object({ limit: Type.Optional( Type.Number({ description: "Maximum number of lines to read" }), ), + format: Type.Optional( + Type.Union([Type.Literal("plain"), Type.Literal("tagged")], { + default: "plain", + description: + 'Output format. "plain" (default): raw file text. "tagged": prefix each line with LINE#HASH for use with Edit({match:"anchor"}).', + }), + ), }); export type ReadToolInput = Static; @@ -166,7 +174,7 @@ export function createReadTool( return { name: "Read", label: "Read", - description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete. Use format="tagged" to get LINE#HASH prefixes on each line (for anchor-mode editing with Edit({match:"anchor"})).`, parameters: readSchema, execute: async ( _toolCallId: string, @@ -174,7 +182,8 @@ export function createReadTool( path, offset, limit, - }: { path: string; offset?: number; limit?: number }, + format, + }: { path: string; offset?: number; limit?: number; format?: "plain" | "tagged" }, signal?: AbortSignal, ) => { const absolutePath = resolveReadPath(path, cwd); @@ -298,7 +307,11 @@ export function createReadTool( effectiveStartLine + truncation.outputLines - 1; const nextOffset = endLineDisplay + 1; - outputText = truncation.content; + const bodyText = + format === "tagged" + ? formatHashLines(truncation.content, effectiveStartLine) + : truncation.content; + outputText = bodyText; if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines ${effectiveStartLine}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; @@ -315,10 +328,17 @@ export function createReadTool( const remaining = totalFileLines - (effectiveStartLine + lines.length - 1); - outputText = truncation.content; + const bodyText = + format === "tagged" + ? formatHashLines(truncation.content, effectiveStartLine) + : truncation.content; + outputText = bodyText; outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; } else { - outputText = truncation.content; + outputText = + format === "tagged" + ? formatHashLines(truncation.content, effectiveStartLine) + : truncation.content; } if (offsetClamped) { @@ -340,7 +360,11 @@ export function createReadTool( const endLineDisplay = truncation.outputLines; const nextOffset = endLineDisplay + 1; - outputText = truncation.content; + const bodyText = + format === "tagged" + ? formatHashLines(truncation.content, 1) + : truncation.content; + outputText = bodyText; if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; @@ -349,7 +373,10 @@ export function createReadTool( } _details = { truncation }; } else { - outputText = truncation.content; + outputText = + format === "tagged" + ? formatHashLines(truncation.content, 1) + : truncation.content; } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index b720f2f44..3af49e694 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -324,9 +324,6 @@ export { checkBashInterception, codingTools, compileInterceptor, - createHashlineCodingTools, - createHashlineEditTool, - createHashlineReadTool, DEFAULT_BASH_INTERCEPTOR_RULES, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -348,16 +345,6 @@ export { getAllToolCompatibility, getToolCompatibility, grepTool, - type HashlineEditInput, - type HashlineEditToolDetails, - type HashlineEditToolOptions, - type HashlineReadToolDetails, - type HashlineReadToolInput, - type HashlineReadToolOptions, - hashlineCodingTools, - // Hashline edit mode tools - hashlineEditTool, - hashlineReadTool, type LsOperations, type LsToolDetails, type LsToolInput, diff --git a/src/headless-ui.ts b/src/headless-ui.ts index a8ef75e31..e7dba99e5 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -202,8 +202,6 @@ export function summarizeToolArgs( case "Edit": case "edit": return filePath(); - case "hashline_edit": - return filePath(); case "Bash": case "bash": { const cmd = String(input.command ?? ""); diff --git a/src/tests/headless-progress.test.ts b/src/tests/headless-progress.test.ts index 6bdbb58f7..b9de9dfbd 100644 --- a/src/tests/headless-progress.test.ts +++ b/src/tests/headless-progress.test.ts @@ -513,13 +513,6 @@ describe("summarizeToolArgs", () => { ); }); - it("extracts path for hashline_edit", () => { - assert.equal( - summarizeToolArgs("hashline_edit", { path: "src/main.ts" }), - "src/main.ts", - ); - }); - it("extracts agent and task for subagent", () => { assert.equal( summarizeToolArgs("subagent", {