Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitkeep

This file was deleted.

23 changes: 13 additions & 10 deletions TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ This document lists all tools supported by `@link-assistant/agent`. All tools ar

### read

Reads file contents from the filesystem.
Reads file contents from the filesystem. Long files are summarized with first
and last line ranges by default, and long lines can be inspected with explicit
column windows.

**Status:** ✅ Fully supported and tested
**Test:** [tests/read.tools.test.js](tests/read.tools.test.js)
Expand Down Expand Up @@ -45,7 +47,8 @@ Fast file pattern matching tool that works with any codebase size. Supports glob

### grep

Powerful search tool built on ripgrep. Supports full regex syntax and can filter by file type or glob pattern.
Powerful search tool built on ripgrep. Supports full regex syntax, can filter by
file type or glob pattern, and summarizes long matching lines around the match.

**Status:** ✅ Fully supported and tested
**Test:** [tests/grep.tools.test.js](tests/grep.tools.test.js)
Expand Down Expand Up @@ -118,14 +121,14 @@ agent supports a **native, enforceable read-only mode**.
Disables every filesystem-mutating and shell tool so the agent can only read,
search and plan:

| Tool | read-only |
| ----------- | --------- |
| `bash` | ❌ disabled |
| `edit` | ❌ disabled |
| `write` | ❌ disabled |
| `multiedit` | ❌ disabled |
| `patch` | ❌ disabled |
| everything else (`read`, `list`, `glob`, `grep`, `websearch`, `codesearch`, `webfetch`, `todo`, `batch`, `task`) | ✅ enabled |
| Tool | read-only |
| ---------------------------------------------------------------------------------------------------------------- | ----------- |
| `bash` | ❌ disabled |
| `edit` | ❌ disabled |
| `write` | ❌ disabled |
| `multiedit` | ❌ disabled |
| `patch` | ❌ disabled |
| everything else (`read`, `list`, `glob`, `grep`, `websearch`, `codesearch`, `webfetch`, `todo`, `batch`, `task`) | ✅ enabled |

```bash
echo "Summarize this project" | agent --read-only
Expand Down
137 changes: 137 additions & 0 deletions docs/case-studies/issue-230/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Case Study: Issue #230 - Read/search tools improvements

## Issue Reference

- GitHub issue: https://github.com/link-assistant/agent/issues/230
- Prepared PR: https://github.com/link-assistant/agent/pull/278
- Research date: 2026-07-03

## Collected Data

Raw artifacts are stored in `data/`:

- `issue-230.json`
- `issue-230-comments.json`
- `pr-278.json`
- `pr-278-conversation-comments.json`
- `pr-278-review-comments.json`
- `pr-278-reviews.json`
- `gh-code-search-max-line-length.txt`
- `related-merged-prs.json`

At collection time, issue #230 had no comments. PR #278 had no conversation
comments, review comments, or reviews. Code search showed the old truncation
points in `js/src/tool/read.ts` and `rust/src/tool/read.rs`.

Online research notes are in `research/online-references.md`.

## Summary

The previous read and search tools hid important information in long lines. The
read tool kept only the start of long lines, and the search tool could omit a
whole long matching line, which made the match itself invisible. The default
read behavior also only showed the beginning of a long file, so an agent had no
first-call signal about how the file ended.

The implemented solution adds shared column-window formatting in both
JavaScript and Rust. The read tool now summarizes long files with first and
last line ranges by default, supports explicit `columnOffset` and
`columnLimit`, and summarizes long single lines with omitted-column markers. The
search tool now keeps long matching lines readable by showing a window centered
on the match and marking omitted column ranges.

## Requirements

| ID | Requirement from issue #230 | Resolution |
| --- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1 | Support long lines in tools | Added column-window helpers in JS and Rust that preserve first/last or match-focused text with omitted-column markers. |
| R2 | Search should show the matching segment, not `[Omitted long matching line]` | JS grep now parses ripgrep JSON submatch offsets; Rust grep uses `regex::Regex::find`. Both format a match-focused window. |
| R3 | Read should allow columns, not only lines | Added `columnOffset` and `columnLimit` parameters in JS and Rust read schemas and implementations. |
| R4 | Default read should give a useful whole-file overview | When no explicit line range is supplied and the file exceeds the line limit, read returns the first and last ranges with `... [omitted lines X..Y] ...`. |
| R5 | One long-line file should show first and last columns | Default long-line read uses first columns, omitted-column range, and last columns. |
| R6 | Preserve previous behavior with tests before changing logic | Added regression tests for existing IDs/schema behavior plus failing long-file, long-line, explicit-column, and grep match-window cases before the fix. |
| R7 | Collect issue data and perform case-study analysis | Added this folder with raw GitHub artifacts, online/library research, requirements, alternatives, and verification notes. |
| R8 | Check existing components/libraries that can help | Evaluated ripgrep JSON output, `grep-printer` JSON wire format, existing JS ripgrep wrapper, and Rust `regex`/`walkdir` implementation. |

## Root Cause

The original read implementations used a fixed `MAX_LINE_LENGTH` prefix
truncation. This discarded the end of long single-line files and did not expose
any parameter for column-level follow-up reads.

The JS search implementation used plain ripgrep output, so it had no structured
match offset to select a useful snippet. External research also confirmed that
ripgrep output-control flags such as `-M` do not apply when `--json` is used,
so Agent must do its own windowing when it consumes structured output.

The Rust grep implementation searched lines directly and printed the full line
in content mode. It needed the same match-focused formatting to keep parity
with JS behavior and avoid huge single-line output.

## Existing Components Checked

- JS `Ripgrep.filepath()` already resolves the bundled ripgrep binary.
- ripgrep `--json` emits match messages with `lines`, `line_number`, and
`submatches` byte offsets, which is enough to find the matching segment.
- Rust `regex::Regex::find` returns the byte range for a match in a line.
- Existing read/grep tests already cover stable tool IDs, schemas, simple
reads, offsets, binary handling, glob matching, regex matching, and grep
output modes.

## Solution Options

### Option 1: Increase or remove the line-length limit

This would make the example match visible, but it would also dump very large
minified files into the model context. It does not solve targeted follow-up
reads by column.

### Option 2: Use ripgrep max-column flags

This is not reliable for the JS search path because ripgrep documents that
standard output shaping flags such as `-M` have no effect with `--json`. It also
would not help the read tool.

### Option 3: Add Agent-owned column windows

This is the implemented plan. It keeps output bounded, exposes omitted ranges
that can be requested later, and uses structured match offsets where available.
The same visible marker format is used in read and grep output in both ports.

## Implementation Notes

- `formatBalancedLineWindow` keeps the first and last columns of a long line.
- `formatColumnWindow` implements explicit read windows with omitted ranges on
both sides when needed.
- `formatFocusedLineWindow` centers the output window around a matching range.
- JS converts ripgrep byte offsets to JavaScript column indices before
formatting.
- Rust converts regex byte offsets to character indices before formatting.
- Default read summarization only applies when the caller does not provide
`offset` or `limit`; explicit line windows retain pagination behavior and the
"File has more lines" hint.

## Verification

Focused pre-fix tests were added first and failed against the old behavior.
After implementation, these focused checks pass:

```bash
cd js
bun test ./tests/tool_read.js ./tests/tool_grep.js
```

```bash
cd rust
cargo test --test tool_read --test tool_grep
```

The JS focused tests cover:

- default long-file first/last line summaries;
- default long single-line omitted-column ranges;
- explicit `columnOffset`/`columnLimit` reads;
- match-focused grep output for a long line.

The Rust focused tests cover the same read and grep behaviors plus the existing
read/grep parity cases.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
link-assistant/agent:rust/src/tool/read.rs: const MAX_LINE_LENGTH: usize = 2000;
link-assistant/agent:rust/src/tool/read.rs: let truncated = if line.len() > MAX_LINE_LENGTH {
link-assistant/agent:rust/src/tool/read.rs: format!("{}...", &line[..MAX_LINE_LENGTH])
link-assistant/agent:js/src/tool/read.ts: const MAX_LINE_LENGTH = 2000;
link-assistant/agent:js/src/tool/read.ts: return line.length > MAX_LINE_LENGTH
link-assistant/agent:js/src/tool/read.ts: ? line.substring(0, MAX_LINE_LENGTH) + '...'
1 change: 1 addition & 0 deletions docs/case-studies/issue-230/data/issue-230-comments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
1 change: 1 addition & 0 deletions docs/case-studies/issue-230/data/issue-230.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"assignees":[],"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"We need to support long lines in tools.\n\nExample of wrong behavior:\n```\n⏺ Search(pattern: \"ivu-modal-header\", path: \"/Users/konard/Code/iview\")\n  ⎿  Found 1 line\n iview/dist/iview.css:1:[Omitted long matching line]\n```\n\ninstead it should become:\n\n```\n⏺ Search(pattern: \"ivu-modal-header\", path: \"/Users/konard/Code/iview\")\n  ⎿  Found 1 line\n iview/dist/iview.css:1: [omitted columns X..Y of line 1] ... ivu-modal-header{cursor:move} ... [omitted columns N..M of line 1]\n```\n\nRead tool should allow to set not only lines, but also columns for reading long single lines.\n\nSearch tool should show part of the line that matched omitting parts of it printing omitted range.\n\nAlso by default read tool should read the whole file, if it does not fit the limit, so AI can just read omitted lines if needed.\n\n```\nfirst N lines\n... [omitted 1700-2300 lines] ...\nlast N lines\n```\n\nAnd now also read tool should support not only lines as parameter for reading, but also columns.\n\nAnd if file is one long line it can be shown like this:\n\n```\nfirst N columns ... [omitted 1700-2300 columns of line 1] ... last N columns\n```\n\nSo AI will have the idea of the contents from the first tool call, and by subsequent calls can get anything required.\n\nThese improvements will allows us to beat competition.\n\nBefore adding this features, we need to ensure previous logic is 100% covered with tests, so we don't break anything that was working before.\n\nWe need to collect data related about the issue to this repository, make sure we compile that data to `./docs/case-studies/issue-{id}` folder, and use it to do deep case study analysis (also make sure to search online for additional facts and data), list of each and all requirements from the issue, and propose possible solutions and solution plans for each requirement (we should also check known existing components/libraries, that solve similar problem or can help in solutions).\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.","comments":[],"createdAt":"2026-04-03T13:03:59Z","labels":[{"id":"LA_kwDOQYTy3M8AAAACQHojCw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"milestone":null,"number":230,"state":"OPEN","title":"Read/search tools improvements","updatedAt":"2026-07-03T22:26:24Z","url":"https://github.com/link-assistant/agent/issues/230"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
1 change: 1 addition & 0 deletions docs/case-studies/issue-230/data/pr-278-reviews.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
1 change: 1 addition & 0 deletions docs/case-studies/issue-230/data/pr-278.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"baseRefName":"main","body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #230.\n\n### 📋 Issue Reference\nFixes #230\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","comments":[],"commits":[{"authoredDate":"2026-07-03T22:30:53Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjE0MzE5MDQ=","login":"konard","name":"konard"}],"committedDate":"2026-07-03T22:30:53Z","messageBody":"Adding .gitkeep for PR creation (default mode).\nThis file will be removed when the task is complete.\n\nIssue: https://github.com/link-assistant/agent/issues/230","messageHeadline":"Initial commit with task details","oid":"0033a9e8cf7064bd40522d6a888b68ee3bf424f6"}],"createdAt":"2026-07-03T22:30:59Z","files":[{"path":".gitkeep","additions":2,"deletions":1,"changeType":"MODIFIED"}],"headRefName":"issue-230-fb58121277cf","isDraft":true,"number":278,"state":"OPEN","title":"[WIP] Read/search tools improvements","updatedAt":"2026-07-03T22:31:00Z","url":"https://github.com/link-assistant/agent/pull/278"}
1 change: 1 addition & 0 deletions docs/case-studies/issue-230/data/related-merged-prs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"headRefName":"issue-36-33c80c585287","mergedAt":"2025-12-16T11:16:27Z","number":37,"title":"fix: Use system message override exclusively for low-limit models","url":"https://github.com/link-assistant/agent/pull/37"}]
37 changes: 37 additions & 0 deletions docs/case-studies/issue-230/research/online-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Online References

Research date: 2026-07-03

## ripgrep JSON Output

- Source: https://manpages.debian.org/testing/ripgrep/rg.1.en.html
- Finding: `--json` emits JSON Lines messages. Match messages include the
matched text and offsets. The man page also states that standard output
shaping flags, including `-M`/`--max-columns`, have no effect when JSON output
is enabled.
- Impact: Agent cannot rely on ripgrep to pre-truncate JSON match lines. The
JS grep tool should parse structured matches and apply its own column window.

## grep-printer Wire Format

- Source: https://docs.rs/grep-printer/latest/grep_printer/struct.JSON.html
- Finding: The JSON wire format describes `match` messages with `path`,
`lines`, `line_number`, and `submatches`. Submatch `start` and `end` fields
are byte offsets into the `lines` data and use a half-open interval.
- Impact: JS grep must convert byte offsets to display columns before choosing
a readable snippet around the match.

## ripgrep User Guide

- Source: https://ripgrep.dev/docs/guide/
- Finding: ripgrep is the existing fast recursive search component and supports
explicit files/directories plus glob filtering.
- Impact: The fix should keep using the existing bundled ripgrep path for JS
search and only change output parsing/windowing, not file discovery.

## Related ripgrep Issue

- Source: https://github.com/BurntSushi/ripgrep/issues/1451
- Finding: A prior ripgrep issue reported that `-M` did not limit line length
under `--json`; this matches the current man page behavior.
- Impact: Confirms Option 2 in the case study is not a complete solution.
5 changes: 5 additions & 0 deletions js/.changeset/long-line-tool-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@link-assistant/agent': minor
---

Improve read and grep output for long files and long single-line matches.
53 changes: 40 additions & 13 deletions js/src/tool/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { Ripgrep } from '../file/ripgrep';

import DESCRIPTION from './grep.txt';
import { Instance } from '../project/instance';
import {
byteOffsetToColumnIndex,
formatFocusedLineWindow,
} from './text-window';

const MAX_MATCHING_LINE_LENGTH = 2000;

export const GrepTool = Tool.define('grep', {
description: DESCRIPTION,
Expand Down Expand Up @@ -32,12 +38,7 @@ export const GrepTool = Tool.define('grep', {
const searchPath = params.path || Instance.directory;

const rgPath = await Ripgrep.filepath();
const args = [
'-nH',
'--field-match-separator=|',
'--regexp',
params.pattern,
];
const args = ['--json', '--regexp', params.pattern];
if (params.include) {
args.push('--glob', params.include);
}
Expand All @@ -64,17 +65,29 @@ export const GrepTool = Tool.define('grep', {
throw new Error(`ripgrep failed: ${errorOutput}`);
}

const lines = output.trim().split('\n');
const matches = [];

for (const line of lines) {
for (const line of output.trim().split('\n')) {
if (!line) continue;

const [filePath, lineNumStr, ...lineTextParts] = line.split('|');
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue;
const event = JSON.parse(line);
if (event.type !== 'match') continue;

const lineNum = parseInt(lineNumStr, 10);
const lineText = lineTextParts.join('|');
const filePath = event.data?.path?.text;
const lineNum = event.data?.line_number;
const rawLineText = event.data?.lines?.text;
const submatch = event.data?.submatches?.[0];
if (!filePath || !lineNum || typeof rawLineText !== 'string') continue;

const lineText = stripTrailingLineEnding(rawLineText);
const matchStart = byteOffsetToColumnIndex(
lineText,
submatch?.start ?? 0
);
const matchEnd = byteOffsetToColumnIndex(
lineText,
submatch?.end ?? submatch?.start ?? 0
);

const file = Bun.file(filePath);
const stats = await file.stat().catch(() => null);
Expand All @@ -85,6 +98,8 @@ export const GrepTool = Tool.define('grep', {
modTime: stats.mtime.getTime(),
lineNum,
lineText,
matchStart,
matchEnd,
});
}

Expand Down Expand Up @@ -113,7 +128,15 @@ export const GrepTool = Tool.define('grep', {
currentFile = match.path;
outputLines.push(`${match.path}:`);
}
outputLines.push(` Line ${match.lineNum}: ${match.lineText}`);
outputLines.push(
` Line ${match.lineNum}: ${formatFocusedLineWindow({
line: match.lineText,
lineNumber: match.lineNum,
focusStart: match.matchStart,
focusEnd: match.matchEnd,
limit: MAX_MATCHING_LINE_LENGTH,
})}`
);
}

if (truncated) {
Expand All @@ -133,3 +156,7 @@ export const GrepTool = Tool.define('grep', {
};
},
});

function stripTrailingLineEnding(line: string) {
return line.replace(/\r?\n$/, '');
}
Loading
Loading