Skip to content
Open
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
38 changes: 38 additions & 0 deletions examples/hashline_anchors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Hashline anchor comment localization (experimental)

Renders the review diff with per-line anchors (`LINE#HASH:`) and lets the
model localize `code_comment` calls by copying an anchor instead of quoting
`existing_code`. The anchor's hash is verified against the new file content
(two-factor: line number = primary key, hash = checksum, existing_code =
text hint), eliminating the first-match ambiguity of pure text matching.

Adapted from the hashline protocol (github.com/RimuruW/pi-hashline-edit).

## Usage

```bash
OCR_HASHLINE_ANCHORS=1 opencodereview review \
--from <base> --to <head> \
--tools examples/hashline_anchors/tools.json
```

- `OCR_HASHLINE_ANCHORS=1` — annotate the diff shown to the model with anchors.
- `--tools examples/hashline_anchors/tools.json` — code_comment schema with the
`anchor` parameter and matching description.

Comments resolved via a verified anchor report `loc_method: "anchor"` in JSON
output; a hash mismatch falls back to the existing text-matching pipeline
(`hunk` / `file` / `relocation`), so behavior is never worse than baseline.

## Measured effect (offline replay over real commits, production resolver)

| Localization | opencode repo (95k added lines) | this repo (23k added lines) |
|---|---|---|
| existing_code, 1 line | 63.2% correct | 74.4% correct |
| existing_code, 3 lines | 81.8% correct | 93.0% correct |
| hashline anchor | 100% correct | 100% correct |

Anchor false-accept rate (wrong line number still passing hash verification):
~0.5%. Diff token overhead of annotation: +26% on the diff itself; in
end-to-end runs total input tokens dropped ~25% as the model needed fewer
file_read round-trips to confirm positions.
214 changes: 214 additions & 0 deletions examples/hashline_anchors/tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
[
{
"name": "task_done",
"plan_task": false,
"main_task": true,
"definition": {
"name": "task_done",
"description": "Call this tool to terminate task execution when you have completed the user's task, such as when no obvious code issues are found during code review.",
"parameters": {
"type": "object",
"properties": {
"state": {
"type": "string",
"enum": [
"DONE",
"FAILED"
],
"description": "Defaults to DONE. Return FAILED if the task cannot be completed using available tools."
}
},
"required": [
"state"
]
}
}
},
{
"name": "code_comment",
"plan_task": false,
"main_task": true,
"definition": {
"name": "code_comment",
"description": "When you discover that a code change could introduce a code issue, use this tool to report it. The tool pinpoints your feedback to the precise code line (or block) in the current file.\n\n**Core Mechanism (anchor-based):**\nThe diff you were given renders every new-file line with a leading anchor of the form `LINE#HASH:` (e.g. `42#KT:+ some code`). To locate a comment, copy the anchor(s) of the line(s) it applies to into the 'anchor' parameter: a single anchor `42#KT` for one line, or a range `42#KT-45#MQ` for a block. Copy anchors EXACTLY as shown \u2014 the hash is verified. Only anchor lines that are part of the change (added lines). Additionally provide 'existing_code' with the first line of the anchored code as a cross-check.",
"parameters": {
"type": "object",
"properties": {
"comments": {
"type": "array",
"description": "A list of comments. Each item should contain 'content' and 'existing_code'.",
"items": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Comment content, typically a brief description of code issues and corresponding suggestions."
},
"anchor": {
"type": "string",
"description": "Line anchor(s) copied verbatim from the diff, without the trailing colon or code. Single line: '42#KT'. Block: '42#KT-45#MQ' (start and end anchors of the block)."
},
"existing_code": {
"type": "string",
"description": "The first line of code at the anchor position, copied exactly from the diff (without the anchor prefix and without the +/- marker). Used as a cross-check for the anchor."
},
"suggestion_code": {
"type": "string",
"description": "Corresponding suggested code snippet, maintaining consistent code style."
},
"category": {
"type": "string",
"enum": [
"bug",
"security",
"performance",
"maintainability",
"test",
"style",
"documentation",
"other"
],
"description": "The category the issue belongs to."
},
"severity": {
"type": "string",
"enum": [
"critical",
"high",
"medium",
"low"
],
"description": "The severity of the issue."
}
},
"required": [
"content",
"anchor",
"existing_code",
"category",
"severity"
]
}
}
},
"required": [
"comments"
]
}
}
},
{
"name": "file_read",
"plan_task": false,
"main_task": true,
"definition": {
"name": "file_read",
"description": "Use this tool to read file content when you need to get context for git diff. You can specify start_line and end_line to view specific parts of the file.\n\n**Line Range Strategy:**\n- Git diff hunk header provides guidance on how to get more relevant context.\n- Git diff hunk header \"@@-x,y +m,n@@\" indicates that the old file has y lines starting from line x, and the new file has n lines starting from line m.\n- For example, when you need to read 50 lines above and below the current changed code block in the new file, set start_line = m - 50, end_line = m + n + 50.\n\n**Example output:**\nFile\uff1apath/to/example.go (Total lines: 50)\nIS_TRUNCATED: false\nLINE_RANGE: 10-12\n// The following is the original content of the file\nfunc main() {\n fmt.Println(\"Hello, World!\")\n}\n\n**Limitations:**\n- If the specified range exceeds 500 lines, only 500 lines will be returned with a truncation notice.\n- This tool can only read file content from the modified version (after changes) in git diff.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The relative path of the file to open."
},
"start_line": {
"type": "integer",
"description": "The start line number to view. Defaults to 1."
},
"end_line": {
"type": "integer",
"description": "The end line number to view. Defaults to end line of file."
}
},
"required": [
"file_path"
]
}
}
},
{
"name": "code_search",
"plan_task": true,
"main_task": true,
"definition": {
"name": "code_search",
"description": "Use this tool to search for specific text within files. Supports searching in specific files, directories, or across the entire codebase with flexible file pattern filtering. Can use either exact string matching or regular expressions.\n\n**Example output:**\nSearch results for 'toolRequest' (case-insensitive):\nFile: path/to/example.java\n433| String name = toolRequest.get().getName();\n438| logToolRequest(newPath, tool, toolRequest.get());\n\n**Regular expression examples (requires use_perl_regexp: true):**\n- Find classes that extend BaseModel: 'class.*extends.*BaseModel'\n- Find function: 'functionName(.*)'\n- Find the function call sites: '\\.functionName(.*)'\n- Match any of multiple strings: 'error|exception|fail'\n\n**File patterns examples:**\n- Single file: ['src/main.go']\n- Multiple files: ['src/main.go', 'lib/utils.js']\n- All Go files: ['*.go']\n- Exclude test files: [':(exclude)*_test.go']\n- Only in src directory: ['src/']\n- Multiple patterns: ['*.go', ':(exclude)vendor/']\n\n**Limitations:**\n- If more than 100 matches are found, only the first 100 results will be returned.\n- Empty search terms will return no results.\n- This tool searches in the current version of files.",
"parameters": {
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "The text string or regular expression pattern to search for."
},
"file_patterns": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of patterns to include/exclude files in the search. Supports Git pathspec syntax for including and excluding files. If omitted, searches the entire codebase."
},
"case_sensitive": {
"type": "boolean",
"description": "Whether the search should be case-sensitive. Defaults to false (case-insensitive)."
},
"use_perl_regexp": {
"type": "boolean",
"description": "If true, treats search_text as a Perl-compatible regular expression pattern instead of literal text. Defaults to false."
}
},
"required": [
"search_text"
]
}
}
},
{
"name": "file_read_diff",
"plan_task": true,
"main_task": true,
"definition": {
"name": "file_read_diff",
"description": "The tool is used to view the changes made to other files in the list of modifications. Call this tool when you discover suspected code issues but need to check changes in other files to confirm whether the problem actually exists. This tool will respond in git diff format.\n\nOutput example:\n==== FILE: path/to/file1.txt ====\n--- a/path/to/file1.txt\n+++ b/path/to/file1.txt\n@@ -10,1 +10,1 @@\n- old content\n+ new content\n\n==== FILE: path/to/file2.txt ====\n@@ -5,1 +5,2 @@\n - old content\n + new content1\n + new content2",
"parameters": {
"type": "object",
"properties": {
"path_array": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of file paths to view diff content."
}
},
"required": [
"path_array"
]
}
}
},
{
"name": "file_find",
"plan_task": true,
"main_task": true,
"definition": {
"name": "file_find",
"description": "Search for matching files in the current project based on filename keywords. Use this tool when you cannot find the files you need to view in the current change file list.\n\nThis tool searches for filenames containing specified keywords in the project directory and returns a list of matching file paths. Search is case-insensitive by default, adjustable via case_sensitive parameter.\n\nNote: This tool only supports returning the first 100 matching file paths; excess will be truncated.\n\nExample:\nInput:\nquery_name: UserService\nOutput:\nsrc/main/java/UserService.java\nsrc/test/java/UserServiceTest.java",
"parameters": {
"type": "object",
"properties": {
"query_name": {
"type": "string",
"description": "Filename keyword to search for, supports partial matching."
},
"case_sensitive": {
"type": "boolean",
"description": "Whether to perform case-sensitive search. Defaults to false."
}
},
"required": [
"query_name"
]
}
}
}
]
13 changes: 11 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/alibaba/open-code-review/internal/config/toolsconfig"
"github.com/alibaba/open-code-review/internal/diff"
"github.com/alibaba/open-code-review/internal/gitcmd"
"github.com/alibaba/open-code-review/internal/hashline"
"github.com/alibaba/open-code-review/internal/llm"
"github.com/alibaba/open-code-review/internal/llmloop"
"github.com/alibaba/open-code-review/internal/model"
Expand Down Expand Up @@ -469,7 +470,11 @@ func (a *Agent) injectDiffMap() {
for i := range a.diffs {
d := &a.diffs[i]
if d.NewPath != "/dev/null" {
m[d.NewPath] = d.Diff
if hashlineAnchorsEnabled() {
m[d.NewPath] = hashline.AnnotateDiff(d)
} else {
m[d.NewPath] = d.Diff
}
}
}
dm := tool.NewDiffMap(m)
Expand Down Expand Up @@ -1136,13 +1141,17 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, *subtas

rawMsgs := a.args.Template.MainTask.Messages
messages := make([]llm.Message, 0, len(rawMsgs))
diffForPrompt := d.Diff
if hashlineAnchorsEnabled() {
diffForPrompt = hashline.AnnotateDiff(&d)
}
for _, m := range rawMsgs {
content := m.Content
content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
content = strings.ReplaceAll(content, "{{current_file_path}}", newPath)
content = strings.ReplaceAll(content, "{{system_rule}}", rule)
content = strings.ReplaceAll(content, "{{change_files}}", changeFilesExcludingCurrent)
content = strings.ReplaceAll(content, "{{diff}}", d.Diff)
content = strings.ReplaceAll(content, "{{diff}}", diffForPrompt)
content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
// Always substitute the {{plan_guidance}} token so the literal placeholder
// never leaks into the rendered prompt. When the plan phase produced no
Expand Down
15 changes: 15 additions & 0 deletions internal/agent/hashline_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package agent

import "os"

// hashlineAnchorsEnabled reports whether hashline anchor mode is on.
// Set OCR_HASHLINE_ANCHORS=1 to render the main-task diff with per-line
// "LINE#HASH:" anchors and let the model localize comments via the
// code_comment "anchor" field instead of (or in addition to) existing_code.
func hashlineAnchorsEnabled() bool {
switch os.Getenv("OCR_HASHLINE_ANCHORS") {
case "1", "true", "on", "yes":
return true
}
return false
}
1 change: 1 addition & 0 deletions internal/diff/relocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func ReLocateComment(
original := cm.ExistingCode
cm.ExistingCode = code
if ResolveComment(cm, d) {
cm.LocMethod = "relocation"
return true, resp, messages
}
cm.ExistingCode = original
Expand Down
Loading