Skip to repository content265 lines · 8.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:04:07.637Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
user_agents_md.rs
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