Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:52:47.902Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

edit_file_tool.rs

682 lines · 23.5 KB · rust
1use std::{
2    any::Any,
3    future::Future,
4    path::Path,
5    sync::Arc,
6    task::{Context, Poll},
7};
8
9use action_log::ActionLog;
10use agent::{
11    AgentTool, ContextServerRegistry, EditFileTool, EditFileToolInput, EditFileToolOutput,
12    Templates, Thread, ToolCallEventStream, ToolInput,
13};
14use agent_settings::{AgentSettings, ToolRules};
15use benchmarks::bench_utils::{
16    RUST_FUNCTION_BODY_LINES, RUST_FUNCTION_LINES, RUST_MODULE_HEADER_LINES, random_rust_file,
17    rust_file_line_count, rust_identifier as identifier,
18};
19use criterion::{
20    BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main,
21};
22use editor::{Editor, EditorStyle};
23use futures::{StreamExt as _, pin_mut, task::noop_waker};
24use gpui::{
25    AnyWindowHandle, AppContext as _, BackgroundExecutor, Entity, Focusable as _, TestAppContext,
26    UpdateGlobal as _,
27};
28use language::{FakeLspAdapter, rust_lang};
29use language_model::fake_provider::FakeLanguageModel;
30use project::{FakeFs, Project};
31use prompt_store::ProjectContext;
32use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
33use serde_json::{Value, json};
34use settings::{Settings as _, SettingsStore};
35use ui::IntoElement as _;
36
37const SEED: u64 = 0x5EED_5EED;
38const OLD_TEXT_CHUNK_SIZE: usize = 512;
39const NEW_TEXT_CHUNK_SIZE: usize = 512;
40
41const FILE_PROJECT_PATH: &str = "root/src/workspace_snapshot.rs";
42const FILE_ABS_PATH: &str = "/root/src/workspace_snapshot.rs";
43
44#[derive(Clone)]
45struct EditOp {
46    old_text: String,
47    new_text: String,
48}
49
50#[derive(Clone)]
51struct EditFixture {
52    name: &'static str,
53    old_file_text: String,
54    expected_file_text: String,
55    edits: Vec<EditOp>,
56}
57
58struct BenchmarkHarness {
59    cx: Option<TestAppContext>,
60    edit_tool: Option<Arc<EditFileTool>>,
61    thread: Option<Entity<Thread>>,
62    partial_payloads: Vec<Value>,
63    final_payload: Value,
64    expected_file_text: String,
65    editor: Option<Entity<Editor>>,
66    window: Option<AnyWindowHandle>,
67    // Keeps the LSP buffer-registration handle and the fake language server alive
68    // for the lifetime of the benchmark so `didChange`/diagnostics keep flowing
69    // while edits are applied.
70    keep_alive: Vec<Box<dyn Any>>,
71}
72
73impl Drop for BenchmarkHarness {
74    fn drop(&mut self) {
75        // Release our handles to the entities first.
76        self.edit_tool.take();
77        self.thread.take();
78        self.editor.take();
79        self.keep_alive.clear();
80
81        if let Some(mut cx) = self.cx.take() {
82            // Close the editor window so the editor entity and the buffer handles
83            // it holds are released, then pump the executor so cancelled editor /
84            // action-log background tasks drop their captured handles before the
85            // leak detector runs on `TestAppContext` drop.
86            if let Some(window) = self.window.take() {
87                cx.update_window(window, |_, window, _| window.remove_window())
88                    .ok();
89            }
90            cx.update(|_| {});
91            cx.executor().run_until_parked();
92            cx.quit();
93        }
94    }
95}
96
97fn edit_file_tool_streaming(c: &mut Criterion) {
98    let fixtures = fixtures();
99    let mut group = c.benchmark_group("edit_file_tool_streaming");
100    group.sample_size(10);
101
102    for fixture in fixtures {
103        let new_bytes: usize = fixture.edits.iter().map(|edit| edit.new_text.len()).sum();
104        group.throughput(Throughput::Bytes(new_bytes as u64));
105        group.bench_with_input(
106            BenchmarkId::new(fixture.name, fixture.old_file_text.len()),
107            &fixture,
108            |bench, fixture| {
109                bench.iter_batched(
110                    || setup_harness(fixture.clone()),
111                    |mut harness| {
112                        let output = run_streamed_edit(&mut harness);
113                        let EditFileToolOutput::Success { new_text, .. } = &output else {
114                            panic!("expected edit_file tool to succeed");
115                        };
116                        assert_eq!(new_text, &harness.expected_file_text);
117                        // Return the harness as part of the output so its teardown (which has
118                        // to pump the executor to release `Entity<Buffer>` handles captured by
119                        // background tasks) runs in criterion's drop phase after the timer has
120                        // stopped, rather than inside the timed region.
121                        (black_box(output), harness)
122                    },
123                    BatchSize::SmallInput,
124                );
125            },
126        );
127    }
128
129    group.finish();
130}
131
132fn setup_harness(fixture: EditFixture) -> BenchmarkHarness {
133    let mut cx = init_context();
134    let executor = cx.executor();
135    let parts = block_on_executor(
136        &executor,
137        setup_editor_and_tool(&mut cx, fixture.old_file_text.clone()),
138    );
139    // Let the LSP handshake, initial parse, and first layout settle before timing.
140    cx.executor().run_until_parked();
141
142    let partial_payloads = streamed_partial_payloads(&fixture.edits);
143    let final_payload = json!({
144        "path": FILE_PROJECT_PATH,
145        "edits": fixture
146            .edits
147            .iter()
148            .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text }))
149            .collect::<Vec<_>>(),
150    });
151
152    BenchmarkHarness {
153        cx: Some(cx),
154        edit_tool: Some(parts.edit_tool),
155        thread: Some(parts.thread),
156        partial_payloads,
157        final_payload,
158        expected_file_text: fixture.expected_file_text,
159        editor: Some(parts.editor),
160        window: Some(parts.window),
161        keep_alive: parts.keep_alive,
162    }
163}
164
165struct HarnessParts {
166    edit_tool: Arc<EditFileTool>,
167    thread: Entity<Thread>,
168    editor: Entity<Editor>,
169    window: AnyWindowHandle,
170    keep_alive: Vec<Box<dyn Any>>,
171}
172
173/// Builds a project + edit tool, opens the target buffer in an editor view inside
174/// a window, and attaches a fake Rust language server. This mirrors the real app:
175/// the edited file is open in a pane with a language server, so each buffer edit
176/// drives the editor's observer cascade (matching brackets, code actions, outline,
177/// bracket colorization), a tree-sitter reparse, and an LSP `didChange` +
178/// diagnostics round-trip — the costs that dominate a real agent edit.
179async fn setup_editor_and_tool(cx: &mut TestAppContext, file_text: String) -> HarnessParts {
180    let fs = FakeFs::new(cx.executor());
181    fs.insert_tree(
182        "/root",
183        json!({
184            "src": {
185                "workspace_snapshot.rs": file_text,
186            },
187        }),
188    )
189    .await;
190
191    let project = Project::test(fs, [Path::new("/root")], cx).await;
192    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
193    language_registry.add(rust_lang());
194    let mut fake_servers = language_registry.register_fake_lsp(
195        "Rust",
196        FakeLspAdapter {
197            capabilities: lsp::ServerCapabilities {
198                text_document_sync: Some(lsp::TextDocumentSyncCapability::Kind(
199                    lsp::TextDocumentSyncKind::INCREMENTAL,
200                )),
201                ..Default::default()
202            },
203            ..Default::default()
204        },
205    );
206
207    let context_server_registry =
208        cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
209    let model = Arc::new(FakeLanguageModel::default());
210    let thread = cx.new(|cx| {
211        Thread::new(
212            project.clone(),
213            cx.new(|_cx| ProjectContext::default()),
214            context_server_registry,
215            Templates::new(),
216            Some(model),
217            cx,
218        )
219    });
220    let action_log: Entity<ActionLog> =
221        thread.read_with(cx, |thread, _cx| thread.action_log().clone());
222    let edit_tool = Arc::new(EditFileTool::new(
223        project.clone(),
224        thread.downgrade(),
225        action_log,
226        language_registry,
227    ));
228
229    // Open the same buffer the tool will edit and register it with the language
230    // servers so edits produce `didChange` notifications.
231    let buffer = project
232        .update(cx, |project, cx| {
233            project.open_local_buffer(FILE_ABS_PATH, cx)
234        })
235        .await
236        .expect("failed to open buffer");
237    let lsp_handle = project.update(cx, |project, cx| {
238        project.register_buffer_with_language_servers(&buffer, cx)
239    });
240
241    let fake_server = fake_servers
242        .next()
243        .await
244        .expect("fake language server should start");
245    // Publish diagnostics on every edit, mirroring a real server reacting to
246    // `didChange`, so the editor's diagnostics path runs per edit.
247    let server = fake_server.clone();
248    fake_server.handle_notification::<lsp::notification::DidChangeTextDocument, _>(
249        move |params, _cx| {
250            server.notify::<lsp::notification::PublishDiagnostics>(lsp::PublishDiagnosticsParams {
251                uri: params.text_document.uri.clone(),
252                version: Some(params.text_document.version),
253                diagnostics: vec![lsp::Diagnostic {
254                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)),
255                    severity: Some(lsp::DiagnosticSeverity::WARNING),
256                    message: "bench diagnostic".to_string(),
257                    ..Default::default()
258                }],
259            });
260        },
261    );
262
263    // Attach an editor view in a window and lay it out once so the viewport-gated
264    // observers (bracket colorization, selection highlights) have a visible range.
265    let window = cx.add_window(|window, cx| {
266        let mut editor = Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
267        editor.set_style(EditorStyle::default(), window, cx);
268        window.focus(&editor.focus_handle(cx), cx);
269        editor
270    });
271    let editor = window.root(cx).expect("window should have an editor root");
272    let window: AnyWindowHandle = window.into();
273    // Lay out and paint a real frame so the editor establishes a viewport (this
274    // is what makes the viewport-gated observers like bracket colorization run).
275    {
276        let mut visual_cx = gpui::VisualTestContext::from_window(window, &*cx);
277        visual_cx.draw(
278            gpui::point(gpui::px(0.0), gpui::px(0.0)),
279            gpui::size(gpui::px(1024.0), gpui::px(768.0)),
280            |_, _| editor.clone().into_any_element(),
281        );
282    }
283
284    let keep_alive: Vec<Box<dyn Any>> = vec![
285        Box::new(lsp_handle),
286        Box::new(fake_server),
287        Box::new(fake_servers),
288        Box::new(buffer),
289    ];
290
291    HarnessParts {
292        edit_tool,
293        thread,
294        editor,
295        window,
296        keep_alive,
297    }
298}
299
300fn init_context() -> TestAppContext {
301    let cx = TestAppContext::single();
302    cx.update(|cx| {
303        let settings_store = SettingsStore::test(cx);
304        cx.set_global(settings_store);
305        assets::Assets.load_test_fonts(cx);
306        theme_settings::init(theme::LoadThemes::JustBase, cx);
307        editor::init(cx);
308        SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
309            store.update_user_settings(cx, |settings| {
310                settings
311                    .project
312                    .all_languages
313                    .defaults
314                    .ensure_final_newline_on_save = Some(false);
315                settings.project.all_languages.defaults.colorize_brackets = Some(true);
316            });
317        });
318
319        let mut agent_settings = AgentSettings::get_global(cx).clone();
320        agent_settings.tool_permissions.tools.insert(
321            EditFileTool::NAME.into(),
322            ToolRules {
323                default: Some(settings::ToolPermissionMode::Allow),
324                always_allow: vec![],
325                always_deny: vec![],
326                always_confirm: vec![],
327                invalid_patterns: vec![],
328            },
329        );
330        AgentSettings::override_global(agent_settings, cx);
331    });
332    cx
333}
334
335fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput {
336    let (mut sender, input): (_, ToolInput<EditFileToolInput>) = ToolInput::test();
337    for payload in &harness.partial_payloads {
338        sender.send_partial(payload.clone());
339    }
340    sender.send_full(harness.final_payload.clone());
341
342    let (event_stream, _event_rx) = ToolCallEventStream::test();
343    let cx = harness
344        .cx
345        .as_ref()
346        .expect("benchmark harness should have a cx");
347    let task = cx.update(|cx| {
348        harness
349            .edit_tool
350            .as_ref()
351            .expect("benchmark harness should have an edit tool")
352            .clone()
353            .run(input, event_stream, cx)
354    });
355
356    let executor = harness
357        .cx
358        .as_ref()
359        .expect("benchmark harness should have a cx")
360        .executor();
361    block_on_executor(&executor, task).unwrap()
362}
363
364fn block_on_executor<R>(executor: &BackgroundExecutor, future: impl Future<Output = R>) -> R {
365    pin_mut!(future);
366    let waker = noop_waker();
367    let mut task_context = Context::from_waker(&waker);
368
369    for _ in 0..10_000 {
370        if let Poll::Ready(output) = future.as_mut().poll(&mut task_context) {
371            return output;
372        }
373        executor.run_until_parked();
374    }
375
376    panic!("future did not complete while running edit_file_tool benchmark");
377}
378
379/// Builds the streamed partial payloads for a (possibly multi-edit) session,
380/// mirroring how the agent reveals one edit at a time: earlier edits stay
381/// complete in the array while the current edit streams its `old_text` then its
382/// `new_text` in chunks.
383fn streamed_partial_payloads(edits: &[EditOp]) -> Vec<Value> {
384    let path = FILE_PROJECT_PATH;
385    let mut payloads = vec![json!({ "path": path }), json!({ "path": path })];
386
387    for index in 0..edits.len() {
388        let completed: Vec<Value> = edits[..index]
389            .iter()
390            .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text }))
391            .collect();
392        let edit = &edits[index];
393
394        for old_end in chunk_ends(&edit.old_text, OLD_TEXT_CHUNK_SIZE) {
395            let mut arr = completed.clone();
396            arr.push(json!({ "old_text": &edit.old_text[..old_end] }));
397            payloads.push(json!({ "path": path, "edits": arr }));
398        }
399
400        let mut arr = completed.clone();
401        arr.push(json!({ "old_text": edit.old_text, "new_text": "" }));
402        payloads.push(json!({ "path": path, "edits": arr }));
403
404        for new_end in chunk_ends(&edit.new_text, NEW_TEXT_CHUNK_SIZE) {
405            let mut arr = completed.clone();
406            arr.push(json!({ "old_text": edit.old_text, "new_text": &edit.new_text[..new_end] }));
407            payloads.push(json!({ "path": path, "edits": arr }));
408        }
409    }
410
411    payloads
412}
413
414fn chunk_ends(text: &str, chunk_size: usize) -> impl Iterator<Item = usize> + '_ {
415    let mut end = 0;
416    std::iter::from_fn(move || {
417        if end == text.len() {
418            return None;
419        }
420
421        end = (end + chunk_size).min(text.len());
422        while !text.is_char_boundary(end) {
423            end -= 1;
424        }
425        Some(end)
426    })
427}
428
429fn fixtures() -> Vec<EditFixture> {
430    vec![
431        make_fixture(
432            "tiny_function_rewrite",
433            2,
434            EditPattern::LocalizedRewrite {
435                start_line: 12,
436                line_count: 6,
437            },
438            SEED,
439        ),
440        make_fixture(
441            "small_function_rewrite",
442            5,
443            EditPattern::LocalizedRewrite {
444                start_line: 22,
445                line_count: 12,
446            },
447            SEED + 1,
448        ),
449        make_fixture(
450            "medium_many_small_changes",
451            8,
452            EditPattern::ManySmallChanges { every_nth_line: 7 },
453            SEED + 2,
454        ),
455        make_fixture(
456            "medium_insertions",
457            8,
458            EditPattern::InsertHelperBlocks { every_nth_line: 9 },
459            SEED + 3,
460        ),
461        make_large_multi_edit_fixture("large_multi_edit", 80, 16, SEED + 4),
462    ]
463}
464
465enum EditPattern {
466    LocalizedRewrite {
467        start_line: usize,
468        line_count: usize,
469    },
470    ManySmallChanges {
471        every_nth_line: usize,
472    },
473    InsertHelperBlocks {
474        every_nth_line: usize,
475    },
476}
477
478fn make_fixture(
479    name: &'static str,
480    function_count: usize,
481    pattern: EditPattern,
482    seed: u64,
483) -> EditFixture {
484    let mut rng = StdRng::seed_from_u64(seed);
485    let old_lines = random_rust_file(&mut rng, rust_file_line_count(function_count));
486    let edit_range = edit_range(&old_lines, &pattern);
487    let old_text = old_lines[edit_range.clone()].join("\n");
488    let mut new_lines = old_lines.clone();
489
490    match pattern {
491        EditPattern::LocalizedRewrite { .. } => {
492            rewrite_local_block(&mut new_lines[edit_range.clone()], &mut rng)
493        }
494        EditPattern::ManySmallChanges { every_nth_line } => {
495            rewrite_many_small_lines(&mut new_lines[edit_range.clone()], every_nth_line, &mut rng)
496        }
497        EditPattern::InsertHelperBlocks { every_nth_line } => {
498            insert_helper_blocks(&mut new_lines, edit_range.clone(), every_nth_line, &mut rng)
499        }
500    }
501
502    let new_text_end = edit_range.end + new_lines.len().saturating_sub(old_lines.len());
503    let old_file_text = old_lines.join("\n");
504    let expected_file_text = new_lines.join("\n");
505    let new_text = new_lines[edit_range.start..new_text_end].join("\n");
506
507    EditFixture {
508        name,
509        old_file_text,
510        expected_file_text,
511        edits: vec![EditOp { old_text, new_text }],
512    }
513}
514
515fn make_large_multi_edit_fixture(
516    name: &'static str,
517    function_count: usize,
518    edit_count: usize,
519    seed: u64,
520) -> EditFixture {
521    let mut rng = StdRng::seed_from_u64(seed);
522    let old_lines = random_rust_file(&mut rng, rust_file_line_count(function_count));
523    let old_file_text = old_lines.join("\n");
524
525    let step = (function_count / edit_count).max(1);
526    let mut picks: Vec<usize> = (0..edit_count)
527        .map(|k| (k * step).min(function_count - 1))
528        .collect();
529    picks.dedup();
530
531    let replacements: Vec<(usize, Vec<String>)> = picks
532        .iter()
533        .map(|&function_index| {
534            (
535                function_index,
536                large_function_lines(&mut rng, function_index),
537            )
538        })
539        .collect();
540
541    let edits = replacements
542        .iter()
543        .map(|(function_index, new_function)| {
544            let start = RUST_MODULE_HEADER_LINES + function_index * RUST_FUNCTION_LINES;
545            let end = start + RUST_FUNCTION_BODY_LINES;
546            EditOp {
547                old_text: old_lines[start..end].join("\n"),
548                new_text: new_function.join("\n"),
549            }
550        })
551        .collect();
552
553    let mut new_lines = old_lines;
554    for (function_index, new_function) in replacements.iter().rev() {
555        let start = RUST_MODULE_HEADER_LINES + function_index * RUST_FUNCTION_LINES;
556        let end = start + RUST_FUNCTION_BODY_LINES;
557        new_lines.splice(start..end, new_function.iter().cloned());
558    }
559    let expected_file_text = new_lines.join("\n");
560
561    EditFixture {
562        name,
563        old_file_text,
564        expected_file_text,
565        edits,
566    }
567}
568
569fn large_function_lines(rng: &mut StdRng, index: usize) -> Vec<String> {
570    let function_name = identifier(rng, index + 40_000);
571    let argument_name = identifier(rng, index + 41_000);
572
573    let mut lines = vec![
574        format!(
575            "    pub fn {function_name}(&mut self, {argument_name}: usize) -> Result<usize> {{"
576        ),
577        format!("        let mut accumulator = {argument_name};"),
578    ];
579
580    let body_lines = rng.random_range(30..42);
581    for body_index in 0..body_lines {
582        let local_name = identifier(rng, index + 50_000 + body_index);
583        let multiplier = rng.random_range(2..19);
584        let offset = rng.random_range(1..256);
585        match body_index % 4 {
586            0 => lines.push(format!(
587                "        let {local_name} = accumulator.saturating_mul({multiplier}).saturating_add({offset});"
588            )),
589            1 => lines.push(format!(
590                "        accumulator = {local_name}.saturating_sub(self.version % {offset}.max(1));"
591            )),
592            2 => lines.push(format!(
593                "        if {local_name} % {multiplier} == 0 {{ accumulator = accumulator.saturating_add({local_name}); }}"
594            )),
595            _ => lines.push(format!(
596                "        self.buffers.insert(\"{local_name}\".to_string(), accumulator);"
597            )),
598        }
599    }
600
601    lines.push("        self.version = self.version.saturating_add(accumulator);".to_string());
602    lines.push("        Ok(accumulator)".to_string());
603    lines.push("    }".to_string());
604    lines
605}
606
607fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range<usize> {
608    let mut range = match pattern {
609        EditPattern::LocalizedRewrite {
610            start_line,
611            line_count,
612        } => *start_line..(*start_line + *line_count).min(lines.len()),
613        EditPattern::ManySmallChanges { .. } | EditPattern::InsertHelperBlocks { .. } => {
614            10..lines.len().saturating_sub(5)
615        }
616    };
617
618    while range.end > range.start && lines[range.end - 1].is_empty() {
619        range.end -= 1;
620    }
621
622    range
623}
624
625fn rewrite_local_block(lines: &mut [String], rng: &mut StdRng) {
626    for (line_index, line) in lines.iter_mut().enumerate() {
627        let suffix = identifier(rng, line_index + 10_000);
628        if line.contains("saturating_add") {
629            *line = format!(
630                "        let {suffix} = self.version.checked_add({line_index}).context(\"version overflow\")?;"
631            );
632        } else if line.contains("saturating_sub") {
633            *line = format!(
634                "            {suffix}.saturating_sub({});",
635                rng.random_range(8..256)
636            );
637        } else if line.trim().is_empty() {
638            *line =
639                format!("        tracing::trace!(target: \"agent_bench\", value = {line_index});");
640        } else {
641            *line = format!("{line} // updated {suffix}");
642        }
643    }
644}
645
646fn rewrite_many_small_lines(lines: &mut [String], every_nth_line: usize, rng: &mut StdRng) {
647    for (line_index, line) in lines.iter_mut().enumerate() {
648        if line_index.is_multiple_of(every_nth_line) || line.trim().is_empty() {
649            continue;
650        }
651
652        let suffix = identifier(rng, line_index + 20_000);
653        *line = format!("{line} // audited {suffix}");
654    }
655}
656
657fn insert_helper_blocks(
658    lines: &mut Vec<String>,
659    range: std::ops::Range<usize>,
660    every_nth_line: usize,
661    rng: &mut StdRng,
662) {
663    let mut line_index = range.start;
664    while line_index < range.end.min(lines.len()) {
665        if line_index.is_multiple_of(every_nth_line) && !lines[line_index].trim().is_empty() {
666            let suffix = identifier(rng, line_index + 30_000);
667            lines.splice(
668                line_index..line_index,
669                [
670                    format!("        let {suffix}_before = self.version;"),
671                    format!("        tracing::debug!(version = {suffix}_before);"),
672                ],
673            );
674            line_index += 2;
675        }
676        line_index += 1;
677    }
678}
679
680criterion_group!(benches, edit_file_tool_streaming);
681criterion_main!(benches);
682
Served at tenant.openagents/omega Member data and write actions are omitted.