dirge-agent 0.18.5

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! Hash-anchored line editing.
//!
//! `edit_lines` replaces a line *range* in a file, guarded by the
//! per-line content hashes the model saw via `read(line_hashes=true)`.
//! Instead of reproducing the old text (as `edit` requires), the
//! model passes `start_line`, `end_line`, the `expected_hashes` for
//! that range, and the `new_text`. The tool recomputes the hashes
//! from disk; if any line drifted since the read, the edit is
//! rejected with a per-line diff instead of silently clobbering
//! changed content.
//!
//! Why: on cheaper models this cuts retries and output tokens
//! sharply — the model never re-emits the old block, only line
//! numbers + tiny hashes + the replacement. The hash is a staleness
//! guard, not a locator (lines are addressed by number); see
//! [`crate::agent::tools::line_hash`].

#[cfg(feature = "lsp")]
use std::sync::Arc;

use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::agent_loop::tool_input_repair::with_contract_hint;
use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::line_hash::line_hash;
use crate::agent::tools::{AskSender, EditLinesArgs, PermCheck, ToolError, require_and_resolve};
#[cfg(feature = "lsp")]
use crate::lsp::manager::LspManager;

pub struct EditLinesTool {
    pub permission: Option<PermCheck>,
    pub ask_tx: Option<AskSender>,
    cache: Option<ToolCache>,
    #[cfg(feature = "lsp")]
    #[allow(dead_code)]
    lsp_manager: Option<Arc<LspManager>>,
}

impl EditLinesTool {
    #[allow(dead_code)]
    pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
        EditLinesTool {
            permission,
            ask_tx,
            cache: None,
            #[cfg(feature = "lsp")]
            lsp_manager: None,
        }
    }

    pub fn with_cache(
        permission: Option<PermCheck>,
        ask_tx: Option<AskSender>,
        cache: ToolCache,
        #[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
    ) -> Self {
        EditLinesTool {
            permission,
            ask_tx,
            cache: Some(cache),
            #[cfg(feature = "lsp")]
            lsp_manager,
        }
    }
}

/// Apply a hash-anchored line replacement to `content` (already
/// LF-normalized, no CR). Returns the new content on success, or a
/// model-facing error string on a hash mismatch / bad range.
///
/// Pure and synchronous so it can be unit-tested without touching the
/// filesystem or the permission layer.
pub(crate) fn apply_line_edit(
    content: &str,
    start_line: usize,
    end_line: usize,
    expected_hashes: &[String],
    new_text: &str,
) -> Result<String, String> {
    if start_line == 0 {
        return Err("start_line is 1-indexed and must be >= 1".to_string());
    }
    if end_line < start_line {
        return Err(format!(
            "end_line ({end_line}) must be >= start_line ({start_line})"
        ));
    }
    // `lines()` matches the read tool's numbering: splits on '\n',
    // strips a trailing '\r', and yields no trailing empty element
    // for a file ending in a newline.
    let lines: Vec<&str> = content.lines().collect();
    if end_line > lines.len() {
        return Err(format!(
            "end_line ({end_line}) is past the end of the file ({} lines). \
             Re-read with line_hashes to get current line numbers.",
            lines.len()
        ));
    }
    let span = end_line - start_line + 1;
    if expected_hashes.len() != span {
        return Err(format!(
            "expected_hashes has {} entries but the range {start_line}..={end_line} \
             covers {span} lines — pass exactly one hash per line in the range.",
            expected_hashes.len()
        ));
    }

    // Verify every line in the range still hashes to what the model
    // saw. Collect ALL mismatches so the model fixes them in one shot.
    let mut mismatches = Vec::new();
    for (offset, expected) in expected_hashes.iter().enumerate() {
        let line_no = start_line + offset;
        // Predecessor coupling (dirge-w9q9): the real preceding file line,
        // or None for line 1. Matches how the read tool derived the hash.
        let prev = (line_no >= 2).then(|| lines[line_no - 2]);
        let actual = line_hash(prev, lines[line_no - 1]);
        if &actual != expected {
            mismatches.push(format!(
                "  line {line_no}: expected hash `{expected}`, found `{actual}` — \
                 current content: {:?}",
                lines[line_no - 1]
            ));
        }
    }
    if !mismatches.is_empty() {
        return Err(format!(
            "edit_lines rejected: {} line(s) changed since you read them. \
             Re-read with line_hashes and retry.\n{}",
            mismatches.len(),
            mismatches.join("\n")
        ));
    }

    // Build the new content. `new_text` is the replacement block; an
    // empty block deletes the range. A single trailing newline is
    // dropped so the block contributes exactly its own lines (the
    // join re-inserts separators) — pass content, not formatting.
    let nt = new_text.replace("\r\n", "\n");
    let nt = nt.strip_suffix('\n').unwrap_or(&nt);
    let mut out: Vec<&str> = Vec::with_capacity(lines.len());
    out.extend_from_slice(&lines[..start_line - 1]);
    if !new_text.is_empty() {
        out.extend(nt.split('\n'));
    }
    out.extend_from_slice(&lines[end_line..]);

    let mut output = out.join("\n");
    // Preserve the file's trailing-newline state.
    if content.ends_with('\n') && !output.is_empty() {
        output.push('\n');
    }
    Ok(output)
}

impl Tool for EditLinesTool {
    const NAME: &'static str = "edit_lines";

    type Error = ToolError;
    type Args = EditLinesArgs;
    type Output = String;

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition {
            name: "edit_lines".to_string(),
            description: with_contract_hint(
                "edit_lines",
                "Replace a range of lines by line number, guarded by per-line content hashes. \
                 First read the file with line_hashes=true to get `N hhh: ...` lines, then call \
                 edit_lines with start_line/end_line (1-indexed, inclusive), expected_hashes (one \
                 per line in the range, in order), and new_text (the replacement block; empty \
                 deletes the range). Cheaper than `edit` for large blocks — you don't retype the \
                 old text. The edit is rejected if any line changed since you read it.",
            ),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string", "description": "The absolute path to the file to edit (must be absolute, not relative)", "dirge-hints": {"semantic": "absolute_path"} },
                    "start_line": { "type": "integer", "description": "First line to replace (1-indexed, inclusive)" },
                    "end_line": { "type": "integer", "description": "Last line to replace (1-indexed, inclusive)" },
                    "expected_hashes": { "type": "array", "items": {"type": "string"}, "description": "The 3-char content hash for each line in [start_line, end_line], in order, exactly as shown by read(line_hashes=true)" },
                    "new_text": { "type": "string", "description": "Replacement text for the range. Empty string deletes the lines." }
                },
                "required": ["path", "start_line", "end_line", "expected_hashes", "new_text"]
            }),
        }
    }

    async fn call(&self, args: EditLinesArgs) -> Result<String, ToolError> {
        let resolved_path = require_and_resolve(
            &self.permission,
            &self.ask_tx,
            "edit",
            &args.path,
            "the edit path",
        )
        .await?;

        // Read-before-edit gate: the hashes only mean something if
        // the model read this file's current contents this session.
        if let Some(ref cache) = self.cache
            && !cache.has_been_read(std::path::Path::new(&resolved_path))
        {
            return Err(ToolError::Msg(format!(
                "edit_lines was blocked because \"{}\" has not been read in this session yet. \
                 Call read(line_hashes=true) on this path first.",
                args.path
            )));
        }

        const MAX_EDIT_BYTES: u64 = 100 * 1024 * 1024;
        if let Ok(meta) = tokio::fs::metadata(&resolved_path).await
            && meta.len() > MAX_EDIT_BYTES
        {
            return Err(ToolError::Msg(format!(
                "file too large for edit_lines: {} bytes (cap {} bytes)",
                meta.len(),
                MAX_EDIT_BYTES,
            )));
        }

        let bytes = tokio::fs::read(&resolved_path).await?;
        // Shared decode seam: refuses non-UTF-8 (dirge-yga0) and strips a
        // leading BOM before hashing (dirge-2hqv) so line 1 hashes to the same
        // value read showed — read strips the BOM too, so without this any
        // edit_lines touching line 1 of a BOM file was falsely rejected.
        let src = crate::agent::tools::text_io::decode_for_edit(&bytes).map_err(ToolError::Msg)?;
        let content = &src.content;

        let new_content = apply_line_edit(
            content,
            args.start_line,
            args.end_line,
            &args.expected_hashes,
            &args.new_text,
        )
        .map_err(ToolError::Msg)?;

        // Restore the original BOM + line endings via the shared seam, instead
        // of the old `has_crlf = any CRLF` flip that rewrote mixed-ending
        // files wholesale to CRLF (dirge-k32l).
        let reencoded = src.reencode(&new_content);
        let mixed_ending_note = reencoded.note;
        let candidate = reencoded.text;

        // Tree-sitter pre-write validation: refuse syntactically
        // broken results so the model sees the error this turn.
        // dirge-p5fu: a purely unclosed-delimiter imbalance is
        // mechanically closed (parity with the JSON truncation repair)
        // and reported, rather than bounced back to the model.
        let (output, syntax_note) =
            crate::agent::tools::syntax_gate(std::path::Path::new(&resolved_path), &candidate)
                .map_err(ToolError::Msg)?;

        // Snapshot pre-edit content for /rewind before mutating, reusing
        // the bytes already read above instead of re-reading from disk.
        crate::agent::tools::snapshots::capture_bytes(std::path::Path::new(&resolved_path), &bytes);
        crate::fs_atomic::atomic_write(std::path::Path::new(&resolved_path), output.as_bytes())
            .await?;
        crate::agent::tools::modified::mark_modified(std::path::Path::new(&resolved_path));
        if let Some(ref cache) = self.cache {
            cache.clear();
            cache.mark_read(std::path::Path::new(&resolved_path));
        }

        let new_span = if args.new_text.is_empty() {
            0
        } else {
            args.new_text
                .replace("\r\n", "\n")
                .trim_end_matches('\n')
                .split('\n')
                .count()
        };
        let mut msg = format!(
            "Replaced lines {}-{} ({} line(s) → {} line(s)).",
            args.start_line,
            args.end_line,
            args.end_line - args.start_line + 1,
            new_span,
        );
        crate::agent::tools::append_repair_note(&mut msg, syntax_note);
        if let Some(note) = mixed_ending_note {
            msg.push('\n');
            msg.push_str(&note);
        }
        Ok(msg)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::tools::line_hash::line_hash;

    fn hashes_for(content: &str, start: usize, end: usize) -> Vec<String> {
        let lines: Vec<&str> = content.lines().collect();
        (start..=end)
            .map(|line_no| {
                let prev = (line_no >= 2).then(|| lines[line_no - 2]);
                line_hash(prev, lines[line_no - 1])
            })
            .collect()
    }

    struct TmpFile {
        path: String,
    }
    impl TmpFile {
        fn new(name: &str, bytes: &[u8]) -> Self {
            let path = format!("/tmp/dirge-editlines-{}", name);
            let _ = std::fs::remove_file(&path);
            std::fs::write(&path, bytes).unwrap();
            Self { path }
        }
    }
    impl Drop for TmpFile {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(&self.path);
        }
    }

    // dirge-2hqv: read strips the BOM before hashing, so an edit_lines whose
    // range includes line 1 must strip it too — otherwise line 1's hash never
    // matches and the edit is falsely rejected. The BOM must survive the write.
    #[tokio::test]
    async fn edits_line_one_of_a_bom_file_and_preserves_the_bom() {
        let tf = TmpFile::new(
            "bom-line1.txt",
            "\u{FEFF}alpha\r\nbeta\r\ngamma\r\n".as_bytes(),
        );
        // Hash matches what read would show: BOM stripped, LF-normalized.
        let h = hashes_for("alpha\nbeta\ngamma\n", 1, 1);
        let tool = EditLinesTool::new(None, None);
        let out = tool
            .call(EditLinesArgs {
                path: tf.path.clone(),
                start_line: 1,
                end_line: 1,
                expected_hashes: h,
                new_text: "ALPHA".to_string(),
            })
            .await
            .expect("edit_lines should accept line 1 of a BOM file");
        assert!(out.contains("Replaced lines 1-1"), "got: {out}");
        // BOM + CRLF preserved, content changed.
        let disk = std::fs::read(&tf.path).unwrap();
        assert_eq!(disk, "\u{FEFF}ALPHA\r\nbeta\r\ngamma\r\n".as_bytes());
    }

    // dirge-k32l: a mixed-ending file must not be wholesale flipped to CRLF.
    #[tokio::test]
    async fn mixed_ending_file_is_not_flipped_wholesale() {
        // 3 LF-only lines, 1 CRLF → LF dominates.
        let tf = TmpFile::new("mixed.txt", b"a\nb\nc\r\nd\n");
        let h = hashes_for("a\nb\nc\nd\n", 2, 2);
        let tool = EditLinesTool::new(None, None);
        let out = tool
            .call(EditLinesArgs {
                path: tf.path.clone(),
                start_line: 2,
                end_line: 2,
                expected_hashes: h,
                new_text: "B".to_string(),
            })
            .await
            .unwrap();
        let disk = std::fs::read(&tf.path).unwrap();
        // The lone CRLF was normalized to LF (dominant), NOT every line flipped
        // to CRLF as the old `has_crlf = any CRLF` code did.
        assert_eq!(disk, b"a\nB\nc\nd\n");
        assert!(out.contains("mixed line endings"), "got: {out}");
    }

    // dirge-yga0: a non-UTF-8 file is refused, not lossily rewritten with
    // replacement chars in bytes the edit never touched.
    #[tokio::test]
    async fn non_utf8_file_is_refused() {
        let tf = TmpFile::new("binary.bin", b"ok\n\xffbytes\n");
        let tool = EditLinesTool::new(None, None);
        let err = tool
            .call(EditLinesArgs {
                path: tf.path.clone(),
                start_line: 1,
                end_line: 1,
                expected_hashes: vec![line_hash(None, "ok")],
                new_text: "OK".to_string(),
            })
            .await
            .expect_err("non-UTF-8 file must be refused");
        let msg = format!("{err:?}");
        assert!(msg.contains("not valid UTF-8"), "got: {msg}");
        // Bytes untouched.
        assert_eq!(std::fs::read(&tf.path).unwrap(), b"ok\n\xffbytes\n");
    }

    #[test]
    fn replaces_single_line_on_matching_hash() {
        let c = "a\nb\nc\n";
        let h = hashes_for(c, 2, 2);
        let out = apply_line_edit(c, 2, 2, &h, "B").unwrap();
        assert_eq!(out, "a\nB\nc\n");
    }

    #[test]
    fn replaces_multi_line_range_with_more_lines() {
        let c = "one\ntwo\nthree\n";
        let h = hashes_for(c, 1, 2);
        let out = apply_line_edit(c, 1, 2, &h, "X\nY\nZ").unwrap();
        assert_eq!(out, "X\nY\nZ\nthree\n");
    }

    #[test]
    fn empty_new_text_deletes_the_range() {
        let c = "keep1\ndrop\nkeep2\n";
        let h = hashes_for(c, 2, 2);
        let out = apply_line_edit(c, 2, 2, &h, "").unwrap();
        assert_eq!(out, "keep1\nkeep2\n");
    }

    #[test]
    fn rejects_on_hash_mismatch_without_mutating() {
        let c = "a\nb\nc\n";
        // Pretend the model read a different content for line 2.
        let stale = vec![line_hash(None, "OLD_b")];
        let err = apply_line_edit(c, 2, 2, &stale, "B").unwrap_err();
        assert!(err.contains("line 2"), "got: {err}");
        assert!(err.contains("changed since you read"), "got: {err}");
    }

    #[test]
    fn reports_every_drifted_line() {
        let c = "a\nb\nc\n";
        // Both line 1 and line 3 hashes are wrong; line 2 correct (its real
        // predecessor-coupled hash: line "b" preceded by "a").
        let mixed = vec![
            line_hash(None, "WRONG"),
            line_hash(Some("a"), "b"),
            line_hash(None, "ALSO_WRONG"),
        ];
        let err = apply_line_edit(c, 1, 3, &mixed, "x").unwrap_err();
        assert!(err.contains("line 1"), "got: {err}");
        assert!(err.contains("line 3"), "got: {err}");
        assert!(!err.contains("line 2"), "line 2 matched; got: {err}");
    }

    #[test]
    fn predecessor_drift_is_detected() {
        // dirge-w9q9: the model read "a\nb\nc\n" and kept line 2's hash,
        // which is coupled to its predecessor "a". Line 1 then drifted
        // "a" -> "A" externally. Editing line 2 with the stale hash is now
        // rejected — line 2's hash depends on its predecessor, so the stale
        // context is caught. A predecessor-independent single-line hash would
        // have matched and clobbered.
        let read = "a\nb\nc\n";
        let echoed = hashes_for(read, 2, 2);
        let drifted = "A\nb\nc\n";
        let err = apply_line_edit(drifted, 2, 2, &echoed, "B").unwrap_err();
        assert!(err.contains("line 2"), "got: {err}");
        assert!(err.contains("changed since you read"), "got: {err}");
        // Bytes untouched on rejection.
        assert_eq!(drifted, "A\nb\nc\n");
    }

    #[test]
    fn rejects_wrong_hash_count() {
        let c = "a\nb\nc\n";
        let err = apply_line_edit(c, 1, 3, &[line_hash(None, "a")], "x").unwrap_err();
        assert!(err.contains("one hash per line"), "got: {err}");
    }

    #[test]
    fn rejects_out_of_range() {
        let c = "a\nb\n";
        let err = apply_line_edit(c, 1, 9, &vec!["x".into(); 9], "z").unwrap_err();
        assert!(err.contains("past the end"), "got: {err}");
    }

    #[test]
    fn preserves_missing_trailing_newline() {
        let c = "a\nb"; // no trailing newline
        let h = hashes_for(c, 2, 2);
        let out = apply_line_edit(c, 2, 2, &h, "B").unwrap();
        assert_eq!(out, "a\nB");
    }

    /// End-to-end through `call()`: a real file round-trips, and a
    /// CRLF file keeps its CRLF endings after the edit.
    #[tokio::test]
    async fn call_round_trips_and_preserves_crlf() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("dirge_edit_lines_{}.txt", std::process::id()));
        std::fs::write(&path, "a\r\nb\r\nc\r\n").unwrap();

        // Hashes are computed on LF-normalized content (as the read
        // tool shows them).
        let normalized = "a\nb\nc\n";
        let h = hashes_for(normalized, 2, 2);

        let tool = EditLinesTool::new(None, None); // no cache → gate skipped
        let out = tool
            .call(EditLinesArgs {
                path: path.to_string_lossy().into_owned(),
                start_line: 2,
                end_line: 2,
                expected_hashes: h,
                new_text: "B".to_string(),
            })
            .await
            .expect("edit_lines call succeeds");
        assert!(out.contains("Replaced lines 2-2"), "summary: {out}");

        let after = std::fs::read_to_string(&path).unwrap();
        let _ = std::fs::remove_file(&path);
        assert_eq!(after, "a\r\nB\r\nc\r\n", "CRLF must be preserved");
    }
}