Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:50:34.738Z 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

terminal_tool.rs

528 lines · 18.2 KB · rust
1use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput};
2use Role::*;
3use anyhow::{Context as _, Result};
4use client::{Client, RefreshLlmTokenListener, UserStore};
5use futures::{FutureExt as _, StreamExt};
6use gpui::{AppContext as _, AsyncApp, TestAppContext};
7use http_client::StatusCode;
8use language_model::{
9    LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
10    LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role,
11    SelectedModel,
12};
13use prompt_store::{ProjectContext, WorktreeContext};
14use rand::prelude::*;
15use reqwest_client::ReqwestClient;
16use settings::SettingsStore;
17use std::{
18    fmt::{self, Display},
19    path::Path,
20    str::FromStr,
21    sync::Arc,
22    time::Duration,
23};
24
25#[derive(Clone)]
26struct EvalInput {
27    conversation: Vec<LanguageModelRequestMessage>,
28    assertion: CommandAssertion,
29}
30
31impl EvalInput {
32    fn new(conversation: Vec<LanguageModelRequestMessage>, assertion: CommandAssertion) -> Self {
33        Self {
34            conversation,
35            assertion,
36        }
37    }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq, Hash)]
41struct EvalAssertionOutcome {
42    score: usize,
43    message: Option<String>,
44}
45
46type AssertionFn = Arc<dyn Fn(&TerminalToolInput) -> EvalAssertionOutcome + Send + Sync + 'static>;
47
48#[derive(Clone)]
49struct CommandAssertion {
50    description: &'static str,
51    check: AssertionFn,
52}
53
54impl CommandAssertion {
55    fn new(
56        description: &'static str,
57        check: impl Fn(&TerminalToolInput) -> EvalAssertionOutcome + Send + Sync + 'static,
58    ) -> Self {
59        Self {
60            description,
61            check: Arc::new(check),
62        }
63    }
64
65    /// Passes when the command is a git command and every git subcommand that
66    /// could block on a pty (pager or editor) is guarded with the appropriate
67    /// environment variable or flag.
68    ///
69    /// This is intentionally permissive about *which* git subcommand the model
70    /// chooses — for an indirect prompt like "combine my last 3 commits", the
71    /// model is free to first investigate with `git log` or jump straight to
72    /// `git rebase -i`. Either is fine, as long as whatever it picks won't
73    /// hang on a pager or editor.
74    fn git_pty_safe(description: &'static str) -> Self {
75        Self::new(description, |input| {
76            let cmd = input.command.as_str();
77            let words: Vec<&str> = cmd.split_whitespace().collect();
78
79            if !words.contains(&"git") {
80                return EvalAssertionOutcome {
81                    score: 0,
82                    message: Some(format!("Expected a `git` command, got: {cmd}")),
83                };
84            }
85
86            // Subcommands that pipe their output through a pager by default,
87            // and so will hang on `less` unless one of these escape hatches is
88            // present somewhere in the command:
89            const PAGER_SUBCMDS: &[&str] = &["log", "diff", "show", "blame"];
90            const PAGER_GUARDS: &[&str] = &["--no-pager", "GIT_PAGER=cat", "PAGER=cat"];
91
92            // Subcommands that may invoke an interactive editor and so will
93            // hang unless one of these escape hatches is present:
94            const EDITOR_SUBCMDS: &[&str] = &["rebase", "commit", "merge", "tag"];
95            const EDITOR_GUARDS: &[&str] =
96                &["GIT_EDITOR=true", "GIT_EDITOR=:", "EDITOR=true", "EDITOR=:"];
97
98            let has_pager_guard = PAGER_GUARDS.iter().any(|guard| cmd.contains(guard));
99            let has_editor_guard = EDITOR_GUARDS.iter().any(|guard| cmd.contains(guard));
100
101            for subcmd in PAGER_SUBCMDS {
102                if words.contains(subcmd) && !has_pager_guard {
103                    return EvalAssertionOutcome {
104                        score: 0,
105                        message: Some(format!(
106                            "`git {subcmd}` is missing a pager guard \
107                             (one of {PAGER_GUARDS:?}). Command: {cmd}"
108                        )),
109                    };
110                }
111            }
112
113            for subcmd in EDITOR_SUBCMDS {
114                if words.contains(subcmd) && !has_editor_guard {
115                    return EvalAssertionOutcome {
116                        score: 0,
117                        message: Some(format!(
118                            "`git {subcmd}` is missing an editor guard \
119                             (one of {EDITOR_GUARDS:?}). Command: {cmd}"
120                        )),
121                    };
122                }
123            }
124
125            EvalAssertionOutcome {
126                score: 100,
127                message: None,
128            }
129        })
130    }
131}
132
133struct EvalOutput {
134    tool_input: TerminalToolInput,
135    assertion: EvalAssertionOutcome,
136    assertion_description: &'static str,
137}
138
139impl Display for EvalOutput {
140    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
141        writeln!(f, "Score: {}", self.assertion.score)?;
142        writeln!(f, "Assertion: {}", self.assertion_description)?;
143        if let Some(message) = self.assertion.message.as_ref() {
144            writeln!(f, "Message: {}", message)?;
145        }
146        writeln!(f, "Tool input: {:#?}", self.tool_input)?;
147        Ok(())
148    }
149}
150
151struct TerminalToolTest {
152    model: Arc<dyn LanguageModel>,
153    model_thinking_effort: Option<String>,
154}
155
156impl TerminalToolTest {
157    async fn new(cx: &mut TestAppContext) -> Self {
158        cx.executor().allow_parking();
159
160        cx.update(|cx| {
161            let settings_store = SettingsStore::test(cx);
162            cx.set_global(settings_store);
163
164            gpui_tokio::init(cx);
165            let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap());
166            cx.set_http_client(http_client);
167            let client = Client::production(cx);
168            let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
169            language_model::init(cx);
170            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
171            language_models::init(user_store, client, cx);
172        });
173
174        let agent_model = SelectedModel::from_str(
175            &std::env::var("ZED_AGENT_MODEL")
176                .unwrap_or("anthropic/claude-sonnet-4-6-latest".into()),
177        )
178        .unwrap();
179
180        let authenticate_provider_tasks = cx.update(|cx| {
181            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
182                registry
183                    .providers()
184                    .iter()
185                    .map(|p| p.authenticate(cx))
186                    .collect::<Vec<_>>()
187            })
188        });
189
190        let model = cx
191            .update(|cx| {
192                cx.spawn(async move |cx| {
193                    futures::future::join_all(authenticate_provider_tasks).await;
194                    load_model(&agent_model, cx).await.unwrap()
195                })
196            })
197            .await;
198
199        let model_thinking_effort = model
200            .default_effort_level()
201            .map(|effort_level| effort_level.value.to_string());
202
203        Self {
204            model,
205            model_thinking_effort,
206        }
207    }
208
209    async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result<EvalOutput> {
210        eval.conversation
211            .last_mut()
212            .context("Conversation must not be empty")?
213            .cache = true;
214
215        let tools = crate::built_in_tools().collect::<Vec<_>>();
216
217        let system_prompt = {
218            let worktrees = vec![WorktreeContext {
219                root_name: "root".to_string(),
220                abs_path: Path::new("/path/to/root").into(),
221                rules_file: None,
222            }];
223            let project_context = ProjectContext::new(worktrees);
224            let tool_names = tools
225                .iter()
226                .map(|tool| tool.name.clone().into())
227                .collect::<Vec<_>>();
228            let template = crate::SystemPromptTemplate {
229                project: &project_context,
230                available_tools: tool_names,
231                model_name: None,
232                date: chrono::Local::now().format("%Y-%m-%d").to_string(),
233                user_agents_md: None,
234                sandboxing: false,
235                is_linux: cfg!(target_os = "linux"),
236                is_windows: cfg!(target_os = "windows"),
237            };
238            template.render(&Templates::new())?
239        };
240
241        let has_system_prompt = eval
242            .conversation
243            .first()
244            .is_some_and(|msg| msg.role == Role::System);
245        let messages = if has_system_prompt {
246            eval.conversation
247        } else {
248            [LanguageModelRequestMessage {
249                role: Role::System,
250                content: vec![MessageContent::Text(system_prompt)],
251                cache: true,
252                reasoning_details: None,
253            }]
254            .into_iter()
255            .chain(eval.conversation)
256            .collect::<Vec<_>>()
257        };
258
259        let request = LanguageModelRequest {
260            messages,
261            tools,
262            thinking_allowed: true,
263            thinking_effort: self.model_thinking_effort.clone(),
264            ..Default::default()
265        };
266
267        let tool_input =
268            retry_on_rate_limit(async || extract_tool_use(&self.model, request.clone(), cx).await)
269                .await?;
270
271        let assertion = (eval.assertion.check)(&tool_input);
272        Ok(EvalOutput {
273            tool_input,
274            assertion,
275            assertion_description: eval.assertion.description,
276        })
277    }
278}
279
280async fn load_model(
281    selected_model: &SelectedModel,
282    cx: &mut AsyncApp,
283) -> Result<Arc<dyn LanguageModel>> {
284    cx.update(|cx| {
285        let registry = LanguageModelRegistry::read_global(cx);
286        let provider = registry
287            .provider(&selected_model.provider)
288            .expect("Provider not found");
289        provider.authenticate(cx)
290    })
291    .await?;
292    Ok(cx.update(|cx| {
293        let models = LanguageModelRegistry::read_global(cx);
294        models
295            .available_models(cx)
296            .find(|model| {
297                model.provider_id() == selected_model.provider && model.id() == selected_model.model
298            })
299            .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0))
300    }))
301}
302
303/// Stream the model completion and extract the first complete tool use whose
304/// name matches `TerminalTool::NAME`, parsed as `TerminalToolInput`.
305async fn extract_tool_use(
306    model: &Arc<dyn LanguageModel>,
307    request: LanguageModelRequest,
308    cx: &mut TestAppContext,
309) -> Result<TerminalToolInput> {
310    let model = model.clone();
311    let events = cx
312        .update(|cx| {
313            let async_cx = cx.to_async();
314            cx.foreground_executor()
315                .spawn(async move { model.stream_completion(request, &async_cx).await })
316        })
317        .await
318        .map_err(|err| anyhow::anyhow!("completion error: {}", err))?;
319
320    let mut streamed_text = String::new();
321    let mut stop_reason = None;
322    let mut parse_errors = Vec::new();
323
324    let mut events = events.fuse();
325    while let Some(event) = events.next().await {
326        match event {
327            Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
328                if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME =>
329            {
330                let input: TerminalToolInput = tool_use
331                    .input
332                    .parse()
333                    .context("Failed to parse tool input as TerminalToolInput")?;
334                return Ok(input);
335            }
336            Ok(LanguageModelCompletionEvent::Text(text)) => {
337                if streamed_text.len() < 2_000 {
338                    streamed_text.push_str(&text);
339                }
340            }
341            Ok(LanguageModelCompletionEvent::Stop(reason)) => {
342                stop_reason = Some(reason);
343            }
344            Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
345                tool_name,
346                raw_input,
347                json_parse_error,
348                ..
349            }) if tool_name.as_ref() == TerminalTool::NAME => {
350                parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}"));
351            }
352            Err(err) => {
353                return Err(anyhow::anyhow!("completion error: {}", err));
354            }
355            _ => {}
356        }
357    }
358
359    let streamed_text = streamed_text.trim();
360    let streamed_text_suffix = if streamed_text.is_empty() {
361        String::new()
362    } else {
363        format!("\nStreamed text:\n{streamed_text}")
364    };
365    let stop_reason_suffix = stop_reason
366        .map(|reason| format!("\nStop reason: {reason:?}"))
367        .unwrap_or_default();
368    let parse_errors_suffix = if parse_errors.is_empty() {
369        String::new()
370    } else {
371        format!("\nTool parse errors:\n{}", parse_errors.join("\n"))
372    };
373
374    anyhow::bail!(
375        "Stream ended without a terminal tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}"
376    )
377}
378
379async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
380    const MAX_RETRIES: usize = 20;
381    let mut attempt = 0;
382
383    loop {
384        attempt += 1;
385        let response = request().await;
386
387        if attempt >= MAX_RETRIES {
388            return response;
389        }
390
391        let retry_delay = match &response {
392            Ok(_) => None,
393            Err(err) => match err.downcast_ref::<LanguageModelCompletionError>() {
394                Some(err) => match &err {
395                    LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
396                    | LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
397                        Some(retry_after.unwrap_or(Duration::from_secs(5)))
398                    }
399                    LanguageModelCompletionError::UpstreamProviderError {
400                        status,
401                        retry_after,
402                        ..
403                    } => {
404                        let should_retry = matches!(
405                            *status,
406                            StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
407                        ) || status.as_u16() == 529;
408
409                        if should_retry {
410                            Some(retry_after.unwrap_or(Duration::from_secs(5)))
411                        } else {
412                            None
413                        }
414                    }
415                    LanguageModelCompletionError::ApiReadResponseError { .. }
416                    | LanguageModelCompletionError::ApiInternalServerError { .. }
417                    | LanguageModelCompletionError::HttpSend { .. } => {
418                        Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
419                    }
420                    _ => None,
421                },
422                _ => None,
423            },
424        };
425
426        if let Some(retry_after) = retry_delay {
427            let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0));
428            eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
429            #[allow(clippy::disallowed_methods)]
430            async_io::Timer::after(retry_after + jitter).await;
431        } else {
432            return response;
433        }
434    }
435}
436
437fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
438    super::run_gpui_eval(
439        |cx| {
440            async move {
441                let test = TerminalToolTest::new(cx).await;
442                let result = test.eval(eval, cx).await;
443                drop(test);
444                cx.run_until_parked();
445                result
446            }
447            .boxed_local()
448        },
449        |output| {
450            if output.assertion.score < 80 {
451                eval_utils::OutcomeKind::Failed
452            } else {
453                eval_utils::OutcomeKind::Passed
454            }
455        },
456    )
457}
458
459fn message(
460    role: Role,
461    contents: impl IntoIterator<Item = MessageContent>,
462) -> LanguageModelRequestMessage {
463    LanguageModelRequestMessage {
464        role,
465        content: contents.into_iter().collect(),
466        cache: false,
467        reasoning_details: None,
468    }
469}
470
471fn text(text: impl Into<String>) -> MessageContent {
472    MessageContent::Text(text.into())
473}
474
475#[test]
476#[cfg_attr(not(feature = "unit-eval"), ignore)]
477fn eval_git_log_uses_no_pager() {
478    eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
479        run_eval(EvalInput::new(
480            vec![message(
481                User,
482                [text(indoc::indoc! {"
483                    Use the terminal tool to show me the most recent 3 commits
484                    on the current branch (subject lines only is fine).
485                "})],
486            )],
487            CommandAssertion::git_pty_safe(
488                "`git log`-style prompt produces a pty-safe git command",
489            ),
490        ))
491    });
492}
493
494#[test]
495#[cfg_attr(not(feature = "unit-eval"), ignore)]
496fn eval_git_rebase_sets_git_editor() {
497    eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
498        run_eval(EvalInput::new(
499            vec![message(
500                User,
501                [text(indoc::indoc! {"
502                    Use the terminal tool to rebase the current branch onto
503                    `origin/main`.
504                "})],
505            )],
506            CommandAssertion::git_pty_safe("`git rebase` prompt produces a pty-safe git command"),
507        ))
508    });
509}
510
511#[test]
512#[cfg_attr(not(feature = "unit-eval"), ignore)]
513fn eval_git_rebase_implied_sets_git_editor() {
514    eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
515        run_eval(EvalInput::new(
516            vec![message(
517                User,
518                [text(indoc::indoc! {"
519                    My branch has 3 small commits that I'd like to combine
520                    into a single clean commit before merging. Help me do
521                    that with the terminal tool.
522                "})],
523            )],
524            CommandAssertion::git_pty_safe("indirect prompt produces a pty-safe git command"),
525        ))
526    });
527}
528
Served at tenant.openagents/omega Member data and write actions are omitted.