Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:04:07.637Z 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

user_agents_md.rs

265 lines · 8.9 KB · rust
1//! User-global `AGENTS.md` support.
2//!
3//! Loads `~/.config/zed/AGENTS.md` (or the platform equivalent) into an
4//! in-memory global, watches the file for changes, and surfaces read errors
5//! through a caller-supplied notifier (so the host application can present
6//! them with the same UI it uses for settings/keymap errors).
7//!
8//! Empty or whitespace-only files are treated as "no user `AGENTS.md`".
9//! Read errors are also treated as "no user `AGENTS.md`" for the purpose of
10//! the system prompt, but the error itself is exposed via
11//! [`UserAgentsMdState::Error`] and forwarded to the notifier.
12//!
13//! The file is read in full, mirroring how project rules / repo `AGENTS.md`
14//! files are loaded by the native agent today.
15
16use std::sync::Arc;
17
18use fs::Fs;
19use futures::StreamExt as _;
20use gpui::{App, BorrowAppContext, Global, SharedString, Task};
21use settings::watch_config_file;
22
23/// In-memory state of the user-global `AGENTS.md` file.
24#[derive(Debug, Default, Clone)]
25pub enum UserAgentsMdState {
26    /// The file is missing, empty, or whitespace-only.
27    #[default]
28    Empty,
29    /// The file was loaded successfully; carries its trimmed contents.
30    Loaded(SharedString),
31    /// The file exists but could not be read; carries the error message.
32    Error(SharedString),
33}
34
35impl UserAgentsMdState {
36    /// The trimmed `AGENTS.md` content, if the file was loaded successfully.
37    pub fn content(&self) -> Option<&SharedString> {
38        match self {
39            Self::Loaded(content) => Some(content),
40            Self::Empty | Self::Error(_) => None,
41        }
42    }
43
44    /// The most recent read error, if the file exists but could not be read.
45    pub fn error(&self) -> Option<&SharedString> {
46        match self {
47            Self::Error(message) => Some(message),
48            Self::Empty | Self::Loaded(_) => None,
49        }
50    }
51}
52
53/// Global wrapper that owns the current [`UserAgentsMdState`] plus the watcher
54/// task responsible for keeping it up to date.
55///
56/// Holding the [`Task`] in a `_watcher` field (matching the
57/// `_settings_files_watcher` pattern in `SettingsStore`) ties the watcher's
58/// lifetime to the data it produces: replacing or removing the global cancels
59/// the watcher.
60pub struct UserAgentsMd {
61    state: UserAgentsMdState,
62    _watcher: Task<()>,
63}
64
65impl Global for UserAgentsMd {}
66
67impl UserAgentsMd {
68    pub fn global(cx: &App) -> Option<&Self> {
69        cx.try_global::<UserAgentsMd>()
70    }
71
72    pub fn state(&self) -> &UserAgentsMdState {
73        &self.state
74    }
75
76    /// Convenience accessor for the trimmed `AGENTS.md` content.
77    pub fn content(&self) -> Option<&SharedString> {
78        self.state.content()
79    }
80
81    /// Convenience accessor for the most recent read error.
82    pub fn error(&self) -> Option<&SharedString> {
83        self.state.error()
84    }
85}
86
87/// Initialize the user-global `AGENTS.md` watcher.
88///
89/// Starts a background task that watches [`paths::agents_file`] for changes
90/// and updates the [`UserAgentsMd`] global accordingly. The `on_change`
91/// callback is invoked on the foreground thread whenever a new read completes,
92/// so callers can show or dismiss notifications matching the
93/// settings/keymap-error UI.
94///
95/// Calling this more than once replaces the previous global, which drops the
96/// previous watcher task and cancels it.
97pub fn init(
98    fs: Arc<dyn Fs>,
99    cx: &mut App,
100    on_change: impl Fn(&UserAgentsMdState, &mut App) + 'static,
101) {
102    let watcher = spawn_watcher(fs, cx, on_change);
103    cx.set_global(UserAgentsMd {
104        state: UserAgentsMdState::default(),
105        _watcher: watcher,
106    });
107}
108
109fn spawn_watcher(
110    fs: Arc<dyn Fs>,
111    cx: &mut App,
112    on_change: impl Fn(&UserAgentsMdState, &mut App) + 'static,
113) -> Task<()> {
114    let path = paths::agents_file().clone();
115    let (mut rx, watcher_task) = watch_config_file(cx.background_executor(), fs.clone(), path);
116
117    cx.spawn(async move |cx| {
118        // Keep the file watcher task alive for as long as this task runs.
119        let _watcher_task = watcher_task;
120
121        // `watch_config_file` swallows file-open errors (it emits an empty
122        // string when the file is missing or unreadable), so we probe the
123        // path on each event to tell "missing / empty" apart from "exists but
124        // failed to read". This mirrors how `settings.json` is watched, with
125        // the extra probe being the only addition: settings.json doesn't need
126        // to surface read errors because invalid JSON is reported separately,
127        // but for AGENTS.md a raw read error is the only signal we get.
128        while let Some(raw) = rx.next().await {
129            let trimmed = raw.trim();
130            let new_state = if !trimmed.is_empty() {
131                UserAgentsMdState::Loaded(SharedString::from(trimmed.to_string()))
132            } else if let Some(error) = probe_read_error(fs.as_ref(), paths::agents_file()).await {
133                UserAgentsMdState::Error(error)
134            } else {
135                UserAgentsMdState::Empty
136            };
137
138            cx.update(|cx| {
139                cx.update_global::<UserAgentsMd, _>(|md, _| {
140                    md.state = new_state.clone();
141                });
142                on_change(&new_state, cx);
143            });
144        }
145    })
146}
147
148async fn probe_read_error(fs: &dyn Fs, path: &std::path::Path) -> Option<SharedString> {
149    match fs.load(path).await {
150        Ok(_) => None,
151        Err(err) => {
152            if let Some(io_err) = err.downcast_ref::<std::io::Error>()
153                && io_err.kind() == std::io::ErrorKind::NotFound
154            {
155                return None;
156            }
157            Some(SharedString::from(format!("{err:#}")))
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use fs::FakeFs;
166    use gpui::TestAppContext;
167    use std::cell::RefCell;
168    use std::rc::Rc;
169
170    async fn init_test(
171        cx: &mut TestAppContext,
172    ) -> (Arc<FakeFs>, Rc<RefCell<Vec<UserAgentsMdState>>>) {
173        cx.executor().allow_parking();
174        let fs = FakeFs::new(cx.executor());
175        // FakeFs requires the parent directory to exist before insert_file.
176        let config_dir = paths::agents_file()
177            .parent()
178            .expect("AGENTS.md path should have a parent")
179            .to_path_buf();
180        fs.create_dir(&config_dir).await.unwrap();
181
182        let history: Rc<RefCell<Vec<UserAgentsMdState>>> = Rc::new(RefCell::new(vec![]));
183        let history_clone = history.clone();
184        cx.update(|cx| {
185            init(fs.clone(), cx, move |state, _cx| {
186                history_clone.borrow_mut().push(state.clone());
187            });
188        });
189        (fs, history)
190    }
191
192    #[gpui::test]
193    async fn loads_initial_content(cx: &mut TestAppContext) {
194        let path = paths::agents_file();
195        let (fs, history) = init_test(cx).await;
196        fs.insert_file(path, b"be concise".to_vec()).await;
197
198        cx.run_until_parked();
199        cx.update(|cx| {
200            assert_eq!(
201                UserAgentsMd::global(cx)
202                    .and_then(|md| md.content().cloned())
203                    .as_deref(),
204                Some("be concise"),
205            );
206            assert!(
207                UserAgentsMd::global(cx)
208                    .and_then(|md| md.error().cloned())
209                    .is_none()
210            );
211        });
212        assert!(matches!(
213            history.borrow().last(),
214            Some(UserAgentsMdState::Loaded(_))
215        ));
216    }
217
218    #[gpui::test]
219    async fn empty_file_is_ignored(cx: &mut TestAppContext) {
220        let path = paths::agents_file();
221        let (fs, history) = init_test(cx).await;
222        fs.insert_file(path, b"   \n  \t".to_vec()).await;
223
224        cx.run_until_parked();
225        cx.update(|cx| {
226            assert!(
227                UserAgentsMd::global(cx)
228                    .and_then(|md| md.content().cloned())
229                    .is_none()
230            );
231        });
232        assert!(matches!(
233            history.borrow().last(),
234            Some(UserAgentsMdState::Empty)
235        ));
236    }
237
238    #[gpui::test]
239    async fn reacts_to_file_changes(cx: &mut TestAppContext) {
240        let path = paths::agents_file();
241        let (fs, _history) = init_test(cx).await;
242        fs.insert_file(path, b"first".to_vec()).await;
243        cx.run_until_parked();
244        cx.update(|cx| {
245            assert_eq!(
246                UserAgentsMd::global(cx)
247                    .and_then(|md| md.content().cloned())
248                    .as_deref(),
249                Some("first"),
250            );
251        });
252
253        fs.insert_file(path, b"second".to_vec()).await;
254        cx.run_until_parked();
255        cx.update(|cx| {
256            assert_eq!(
257                UserAgentsMd::global(cx)
258                    .and_then(|md| md.content().cloned())
259                    .as_deref(),
260                Some("second"),
261            );
262        });
263    }
264}
265
Served at tenant.openagents/omega Member data and write actions are omitted.