feat(coding-agent): implemented shared single-path edit request handling

- Required top-level `path` in edit requests, removed per-entry `path` fields, and updated docs/tests to match.
- Updated patch/hashline/atom streaming preview generation to return a single request-level path diff preview.
- Changed edit execution to route patch/replace/atom/hashline through single-path handlers sharing `path`.
- Added `scripts/analyze-edit-formats` Go CLI with reports to audit edit-tool usage from session JSONL logs.
This commit is contained in:
can1357
2026-04-28 05:21:24 +02:00
parent e8c5c660b6
commit 5944fe34c0
25 changed files with 986 additions and 320 deletions
+48
View File
@@ -0,0 +1,48 @@
# analyze-edit-formats
Audits how agents have used the `edit` / `ast_edit` / `write` tools across
historical session JSONLs in `~/.omp/agent/sessions/`.
For each tool call we:
- detect the **argument-schema family** in use (the edit tool has shipped many
shapes over time: `oldText/newText`, `op+pos+end+lines`, `loc+content`,
`loc+splice/pre/post/sed`, etc.);
- record the locator shape and verb combination (for the current
`loc+splice/pre/post/sed` schema);
- pair the call with its `toolResult` and classify the outcome
(`success` / `truncated` / `aborted` / `fail:anchor-stale` /
`fail:no-match` / `fail:parse` / `fail:no-enclosing-block` / …).
Output is a markdown-ish report on stdout plus per-call CSV at
`/tmp/edit-analysis/edits.csv` (or your CWD if you set that up differently).
## Usage
```sh
# Scan every session jsonl on disk (slow — ~25k files).
go run ./scripts/analyze-edit-formats
# Scan only files whose path contains the given date prefix(es).
go run ./scripts/analyze-edit-formats 2026-04-28
go run ./scripts/analyze-edit-formats 2026-04-27 2026-04-28
```
The walk root is `~/.omp/agent/sessions/`. Sub-session files (subagent
trajectories nested under `<session-id>/<n>-<name>.jsonl`) are picked up
automatically.
## Why Go
The session corpus is large (>25k files, >100k edit calls). Go iterates the
JSONL stream with negligible memory overhead and finishes in ~90s. The same
analysis in Bun/TS works but is noticeably slower for ad-hoc runs.
## What it's good for
- Comparing reliability across edit-tool argument schemas before changing the
current one.
- Spotting which verb / locator shapes have outsized failure rates so the
prompt can warn against them.
- Sanity-checking that a new edit-tool design isn't regressing the
failure-mode mix versus the previous design.
+3
View File
@@ -0,0 +1,3 @@
module github.com/oh-my-pi/scripts/analyze-edit-formats
go 1.26.2
+651
View File
@@ -0,0 +1,651 @@
// Analyzes how agents use the `edit` tool across today's session jsonl files
// in ~/.omp/agent/sessions/.
//
// For every edit-family toolCall (edit, ast_edit, write) we:
// - record what shape of arguments was supplied (loc, splice/pre/post/sed,
// bracket form of locator, line vs file targeted, etc.)
// - pair it with its toolResult and classify the result as success or as a
// specific failure category (anchor stale, anchor unknown, no enclosing
// block, parse error, ssr no match, etc.).
//
// Output is a markdown-ish report on stdout plus a CSV of every edit attempt
// to ./edit-analysis.csv (override with $EDIT_ANALYSIS_CSV).
package main
import (
"bufio"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
type rawMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message"`
}
type message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolName string `json:"toolName"`
ToolCallID string `json:"toolCallId"`
}
type contentItem struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
ID string `json:"id"`
Arguments json.RawMessage `json:"arguments"`
}
type editEntry struct {
File string
CallID string
ToolName string
NumEdits int
Verbs []string // splice/pre/post/sed per sub-edit
LocShapes []string // bare/bracket-(/bracket-[/bracket-tail/bracket-head/$pre/$post/$sed
HasNewFile bool
HasGlob bool
HasOps bool
Format string // edit-tool argument schema family
ResultRaw string
Status string // "success" or failure category
}
// matchDate accepts files whose path contains any of the supplied date
// prefixes (e.g. "2026-04-28"). With no flags it accepts every .jsonl.
var dateFilters []string
func matchDate(p string) bool {
if len(dateFilters) == 0 {
return true
}
for _, d := range dateFilters {
if strings.Contains(p, d) {
return true
}
}
return false
}
func main() {
dateFilters = os.Args[1:]
root, err := os.UserHomeDir()
must(err)
base := filepath.Join(root, ".omp", "agent", "sessions")
var files []string
must(filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(p, ".jsonl") {
return nil
}
if !matchDate(p) {
return nil
}
files = append(files, p)
return nil
}))
sort.Strings(files)
fmt.Fprintf(os.Stderr, "loaded %d session files for today\n", len(files))
var entries []editEntry
for _, f := range files {
entries = append(entries, processFile(f)...)
}
report(entries)
writeCSV(entries)
}
func processFile(path string) []editEntry {
fh, err := os.Open(path)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
return nil
}
defer fh.Close()
calls := map[string]*editEntry{}
var order []string
sc := bufio.NewScanner(fh)
sc.Buffer(make([]byte, 0, 64*1024), 64*1024*1024)
for sc.Scan() {
var rm rawMessage
if err := json.Unmarshal(sc.Bytes(), &rm); err != nil {
continue
}
if rm.Type != "message" {
continue
}
var m message
if err := json.Unmarshal(rm.Message, &m); err != nil {
continue
}
var items []contentItem
if err := json.Unmarshal(m.Content, &items); err != nil {
continue
}
switch m.Role {
case "assistant":
for _, it := range items {
if it.Type != "toolCall" {
continue
}
if !isEditTool(it.Name) {
continue
}
e := classifyArgs(it.Name, it.Arguments)
e.File = path
e.CallID = it.ID
e.ToolName = it.Name
calls[it.ID] = &e
order = append(order, it.ID)
}
case "toolResult":
if !isEditTool(m.ToolName) {
continue
}
e, ok := calls[m.ToolCallID]
if !ok {
// orphan result, skip
continue
}
text := joinText(items)
e.ResultRaw = text
e.Status = classifyResult(m.ToolName, text)
}
}
out := make([]editEntry, 0, len(order))
for _, id := range order {
if e, ok := calls[id]; ok {
out = append(out, *e)
}
}
return out
}
func isEditTool(name string) bool {
switch strings.ToLower(name) {
case "edit", "ast_edit", "write":
return true
}
return false
}
func joinText(items []contentItem) string {
var b strings.Builder
for _, it := range items {
if it.Type == "text" {
b.WriteString(it.Text)
}
}
return b.String()
}
// ---- argument classification ----
type editOp struct {
Loc string `json:"loc"`
Splice json.RawMessage `json:"splice"`
Pre json.RawMessage `json:"pre"`
Post json.RawMessage `json:"post"`
Sed json.RawMessage `json:"sed"`
}
type editArgs struct {
Path string `json:"path"`
Edits []editOp `json:"edits"`
// ast_edit
Ops []json.RawMessage `json:"ops"`
// Write
Content *string `json:"content,omitempty"`
}
var anchorBare = regexp.MustCompile(`^[a-zA-Z]?\d+[a-z]{2}$`)
var anchorWithFile = regexp.MustCompile(`^[^:]+:\d+[a-z]{2}$`)
func classifyArgs(name string, raw json.RawMessage) editEntry {
e := editEntry{}
e.Format = detectFormat(name, raw)
switch strings.ToLower(name) {
case "edit":
var a editArgs
_ = json.Unmarshal(raw, &a)
e.NumEdits = len(a.Edits)
for _, op := range a.Edits {
e.LocShapes = append(e.LocShapes, locShape(op.Loc))
verbs := []string{}
if !isNullOrEmpty(op.Splice) {
verbs = append(verbs, "splice")
}
if !isNullOrEmpty(op.Pre) {
verbs = append(verbs, "pre")
}
if !isNullOrEmpty(op.Post) {
verbs = append(verbs, "post")
}
if !isNullOrEmpty(op.Sed) {
verbs = append(verbs, "sed")
}
if len(verbs) == 0 {
verbs = append(verbs, "none")
}
e.Verbs = append(e.Verbs, strings.Join(verbs, "+"))
}
case "ast_edit":
var a editArgs
_ = json.Unmarshal(raw, &a)
e.HasOps = len(a.Ops) > 0
e.NumEdits = len(a.Ops)
if strings.ContainsAny(a.Path, "*?,") {
e.HasGlob = true
}
case "write":
e.HasNewFile = true
e.NumEdits = 1
e.Verbs = []string{"write"}
}
return e
}
// detectFormat figures out which edit-tool argument schema is in use by
// looking at the top-level argument keys and (for `edit`) the keys of the
// first sub-edit. Older sessions used many incompatible schemas.
func detectFormat(name string, raw json.RawMessage) string {
switch strings.ToLower(name) {
case "write":
return "write"
case "ast_edit":
return "ast_edit"
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return "unknown"
}
has := func(k string) bool { _, ok := top[k]; return ok }
switch {
case has("oldText") && has("newText"):
return "oldText/newText"
case has("old_text") && has("new_text"):
return "old_text/new_text"
case has("diff") && has("op"):
return "diff+op"
case has("diff") && has("operation"):
return "diff+operation"
case has("diff"):
return "diff"
case has("replace") || has("insert"):
return "replace/insert"
}
if edits, ok := top["edits"]; ok {
var list []map[string]json.RawMessage
if err := json.Unmarshal(edits, &list); err == nil && len(list) > 0 {
first := list[0]
fh := func(k string) bool { _, ok := first[k]; return ok }
switch {
case fh("loc") && (fh("splice") || fh("pre") || fh("post") || fh("sed")):
return "loc+splice/pre/post/sed"
case fh("loc") && fh("content"):
return "loc+content"
case fh("set_line"):
return "set_line"
case fh("insert_after"):
return "insert_after"
case fh("op") && fh("pos") && fh("end") && fh("lines"):
return "op+pos+end+lines"
case fh("op") && fh("pos") && fh("lines"):
return "op+pos+lines"
case fh("op") && fh("sel") && fh("content"):
return "op+sel+content"
case fh("all") && (fh("new_text") || fh("old_text")):
return "per-edit:old_text/new_text"
}
keys := make([]string, 0, len(first))
for k := range first {
keys = append(keys, k)
}
sort.Strings(keys)
return "edits[" + strings.Join(keys, ",") + "]"
}
}
keys := make([]string, 0, len(top))
for k := range top {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ",")
}
func isNullOrEmpty(b json.RawMessage) bool {
s := strings.TrimSpace(string(b))
return s == "" || s == "null"
}
func locShape(loc string) string {
if loc == "" {
return "empty"
}
if loc == "$" {
return "$file"
}
// strip optional file: prefix
rest := loc
if i := strings.LastIndex(loc, ":"); i >= 0 && !strings.HasPrefix(loc, "$") {
rest = loc[i+1:]
}
switch {
case strings.HasPrefix(rest, "(") && strings.HasSuffix(rest, ")"):
return "bracket-(body)"
case strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]"):
return "bracket-[block]"
case strings.HasPrefix(rest, "(") || strings.HasPrefix(rest, "["):
return "bracket-tail"
case strings.HasSuffix(rest, ")") || strings.HasSuffix(rest, "]"):
return "bracket-head"
case anchorBare.MatchString(rest):
return "bare-anchor"
}
return "other"
}
// ---- result classification ----
var (
reAnchorStale = regexp.MustCompile(`(?i)(Edit rejected:.*line[s]? .* changed since the last read|line[s]? ha(s|ve) changed since last read)`)
reAnchorMissing = regexp.MustCompile(`(?i)anchor .* (not found|unknown|missing)|loc requires the full anchor`)
reNoEnclosing = regexp.MustCompile(`(?i)No enclosing .* block`)
reParseError = regexp.MustCompile(`(?i)parse|syntax error|unbalanced|unexpected token`)
reSSRNoMatch = regexp.MustCompile(`(?i)0 matches|no replacements|no match found|No replacements made|Failed to find expected lines`)
reFileNotRead = regexp.MustCompile(`(?i)must be read first|has not been read|not yet read`)
reFileChanged = regexp.MustCompile(`(?i)file has been (modified|changed) externally`)
rePermDenied = regexp.MustCompile(`(?i)permission denied|not allowed`)
reGenericRejected = regexp.MustCompile(`(?i)\b(rejected|failed|error|invalid)\b`)
reTruncated = regexp.MustCompile(`(?i)\[Output truncated`)
reAborted = regexp.MustCompile(`(?i)Tool execution was aborted|Request was aborted|cancelled|canceled by user`)
reSuccess = regexp.MustCompile(`(?i)^(Updated|Successfully (wrote|replaced|edited|deleted|inserted)|Replaced|Applied|Deleted|Created|Wrote|edit applied|Edited|Inserted|OK\b)`)
)
func classifyResult(tool, text string) string {
t := strings.TrimSpace(text)
if t == "" {
return "empty"
}
first := strings.SplitN(t, "\n", 2)[0]
switch {
case reTruncated.MatchString(first):
return "truncated"
case reAborted.MatchString(t):
return "aborted"
case reSuccess.MatchString(first):
return "success"
case reAnchorStale.MatchString(t):
return "fail:anchor-stale"
case reNoEnclosing.MatchString(t):
return "fail:no-enclosing-block"
case reAnchorMissing.MatchString(t):
return "fail:anchor-missing"
case reParseError.MatchString(t):
return "fail:parse"
case reSSRNoMatch.MatchString(t):
return "fail:no-match"
case reFileNotRead.MatchString(t):
return "fail:file-not-read"
case reFileChanged.MatchString(t):
return "fail:file-changed"
case rePermDenied.MatchString(t):
return "fail:perm"
case reGenericRejected.MatchString(first):
return "fail:other"
}
return "unknown"
}
// ---- reporting ----
func report(entries []editEntry) {
if len(entries) == 0 {
fmt.Println("no edit-family tool calls found in today's sessions")
return
}
byTool := map[string]int{}
byFormat := map[string]int{}
statusByFormat := map[string]map[string]int{}
statusByTool := map[string]map[string]int{}
verbCount := map[string]int{}
locCount := map[string]int{}
failsByVerb := map[string]map[string]int{}
failsByLoc := map[string]map[string]int{}
for _, e := range entries {
byTool[e.ToolName]++
if statusByTool[e.ToolName] == nil {
statusByTool[e.ToolName] = map[string]int{}
}
statusByTool[e.ToolName][e.Status]++
byFormat[e.Format]++
if statusByFormat[e.Format] == nil {
statusByFormat[e.Format] = map[string]int{}
}
statusByFormat[e.Format][e.Status]++
for _, v := range e.Verbs {
verbCount[v]++
if failsByVerb[v] == nil {
failsByVerb[v] = map[string]int{}
}
failsByVerb[v][e.Status]++
}
for _, l := range e.LocShapes {
locCount[l]++
if failsByLoc[l] == nil {
failsByLoc[l] = map[string]int{}
}
failsByLoc[l][e.Status]++
}
}
fmt.Println("# Edit-tool usage in today's sessions")
fmt.Printf("\nTotal tool calls: %d (across %d sessions)\n",
len(entries), countSessions(entries))
fmt.Println("\n## By tool")
printSorted(byTool)
fmt.Println("\n## Outcome by tool")
tools := keys(byTool)
sort.Strings(tools)
for _, t := range tools {
fmt.Printf("\n %s (%d calls):\n", t, byTool[t])
printSortedIndent(statusByTool[t], " ")
}
fmt.Println("\n## edit verb distribution (per sub-edit)")
printSorted(verbCount)
fmt.Println("\n## edit locator shape distribution")
printSorted(locCount)
fmt.Println("\n## Failure rate per verb shape")
for _, v := range sortedKeys(verbCount) {
total, failed := 0, 0
for status, n := range failsByVerb[v] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", v, failed, total, pct(failed, total))
}
fmt.Println("\n## Failure rate per locator shape")
for _, l := range sortedKeys(locCount) {
total, failed := 0, 0
for status, n := range failsByLoc[l] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", l, failed, total, pct(failed, total))
}
fmt.Println("\n## edit-tool argument-format usage")
printSorted(byFormat)
fmt.Println("\n## Failure rate per argument format")
for _, fname := range sortedKeys(byFormat) {
total, failed := 0, 0
for status, n := range statusByFormat[fname] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-32s %6d/%-6d failed (%.0f%%)\n", fname, failed, total, pct(failed, total))
}
fmt.Println("\n## Failure breakdown per top format")
cap := 0
for _, fname := range sortedKeys(byFormat) {
if cap >= 8 {
break
}
cap++
fmt.Printf("\n %s (%d total)\n", fname, byFormat[fname])
printSortedIndent(statusByFormat[fname], " ")
}
fmt.Println("\n## Sample failed edits")
shown := 0
for _, e := range entries {
if !strings.HasPrefix(e.Status, "fail") {
continue
}
fmt.Printf("\n— %s [%s] verbs=%v loc=%v\n result: %s\n",
e.ToolName, e.Status, e.Verbs, e.LocShapes,
truncate(strings.SplitN(e.ResultRaw, "\n\n", 2)[0], 220))
shown++
if shown >= 8 {
break
}
}
}
func countSessions(es []editEntry) int {
s := map[string]struct{}{}
for _, e := range es {
s[e.File] = struct{}{}
}
return len(s)
}
func printSorted(m map[string]int) {
for _, k := range sortedKeys(m) {
fmt.Printf(" %-25s %d\n", k, m[k])
}
}
func printSortedIndent(m map[string]int, indent string) {
for _, k := range sortedKeys(m) {
fmt.Printf("%s%-25s %d\n", indent, k, m[k])
}
}
func sortedKeys(m map[string]int) []string {
type kv struct {
k string
v int
}
pairs := make([]kv, 0, len(m))
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].v != pairs[j].v {
return pairs[i].v > pairs[j].v
}
return pairs[i].k < pairs[j].k
})
out := make([]string, len(pairs))
for i, p := range pairs {
out[i] = p.k
}
return out
}
func keys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func pct(a, b int) float64 {
if b == 0 {
return 0
}
return 100 * float64(a) / float64(b)
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " | ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func writeCSV(entries []editEntry) {
csvPath := os.Getenv("EDIT_ANALYSIS_CSV")
if csvPath == "" {
csvPath = "edit-analysis.csv"
}
f, err := os.Create(csvPath)
if err != nil {
fmt.Fprintln(os.Stderr, "csv:", err)
return
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
_ = w.Write([]string{"session", "tool", "status", "num_edits", "verbs", "loc_shapes", "result_first_line"})
for _, e := range entries {
first := strings.SplitN(e.ResultRaw, "\n", 2)[0]
_ = w.Write([]string{
filepath.Base(e.File),
e.ToolName,
e.Status,
fmt.Sprintf("%d", e.NumEdits),
strings.Join(e.Verbs, ","),
strings.Join(e.LocShapes, ","),
truncate(first, 200),
})
}
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}