Skip to content

Commit da6abd8

Browse files
authored
feat(agent): agent schema (#2273)
* adds agent config schema * adjusts agent config schema * separates context migrate from agent file for readability * vends agent schema using schemars * adds agent subcommand to print config schema * changes hooks types to untagged union in agent config
1 parent a5a78ac commit da6abd8

File tree

13 files changed

+663
-373
lines changed

13 files changed

+663
-373
lines changed

Cargo.lock

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ whoami = "1.6.0"
126126
windows = { version = "0.61.1", features = ["Foundation", "Win32_System_ProcessStatus", "Win32_System_Kernel", "Win32_System_Threading", "Wdk_System_Threading"] }
127127
winnow = "=0.6.2"
128128
winreg = "0.55.0"
129+
schemars = "1.0.4"
129130

130131
[workspace.lints.rust]
131132
future_incompatible = "warn"

crates/chat-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ walkdir.workspace = true
120120
webpki-roots.workspace = true
121121
whoami.workspace = true
122122
winnow.workspace = true
123+
schemars.workspace = true
123124

124125
[target.'cfg(unix)'.dependencies]
125126
nix.workspace = true
@@ -150,3 +151,4 @@ quote.workspace = true
150151
serde.workspace = true
151152
serde_json.workspace = true
152153
syn.workspace = true
154+
schemars.workspace = true
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
use std::collections::HashMap;
2+
3+
use dialoguer::Select;
4+
use eyre::bail;
5+
use tracing::{
6+
error,
7+
info,
8+
warn,
9+
};
10+
11+
use super::{
12+
Agent,
13+
McpServerConfig,
14+
};
15+
use crate::cli::agent::{
16+
CreateHooks,
17+
PromptHooks,
18+
};
19+
use crate::cli::chat::cli::hooks::{
20+
Hook,
21+
HookTrigger,
22+
};
23+
use crate::cli::chat::context::ContextConfig;
24+
use crate::database::settings::Setting;
25+
use crate::os::Os;
26+
use crate::util::directories;
27+
28+
pub(in crate::cli::agent) struct ContextMigrate<const S: char> {
29+
legacy_global_context: Option<ContextConfig>,
30+
legacy_profiles: HashMap<String, ContextConfig>,
31+
mcp_servers: Option<McpServerConfig>,
32+
new_agents: Vec<Agent>,
33+
}
34+
35+
impl ContextMigrate<'a'> {
36+
pub async fn scan(os: &Os) -> eyre::Result<ContextMigrate<'b'>> {
37+
let legacy_global_context_path = directories::chat_global_context_path(os)?;
38+
let legacy_global_context: Option<ContextConfig> = 'global: {
39+
let Ok(content) = os.fs.read(&legacy_global_context_path).await else {
40+
break 'global None;
41+
};
42+
serde_json::from_slice::<ContextConfig>(&content).ok()
43+
};
44+
45+
let legacy_profile_path = directories::chat_profiles_dir(os)?;
46+
let legacy_profiles: HashMap<String, ContextConfig> = 'profiles: {
47+
let mut profiles = HashMap::<String, ContextConfig>::new();
48+
let Ok(mut read_dir) = os.fs.read_dir(&legacy_profile_path).await else {
49+
break 'profiles profiles;
50+
};
51+
52+
// Here we assume every profile is stored under their own folders
53+
// And that the profile config is in profile_name/context.json
54+
while let Ok(Some(entry)) = read_dir.next_entry().await {
55+
let config_file_path = entry.path().join("context.json");
56+
if !os.fs.exists(&config_file_path) {
57+
continue;
58+
}
59+
let Some(profile_name) = entry.file_name().to_str().map(|s| s.to_string()) else {
60+
continue;
61+
};
62+
let Ok(content) = tokio::fs::read_to_string(&config_file_path).await else {
63+
continue;
64+
};
65+
let Ok(mut context_config) = serde_json::from_str::<ContextConfig>(content.as_str()) else {
66+
continue;
67+
};
68+
69+
// Combine with global context since you can now only choose one agent at a time
70+
// So this is how we make what is previously global available to every new agent migrated
71+
if let Some(context) = legacy_global_context.as_ref() {
72+
context_config.paths.extend(context.paths.clone());
73+
context_config.hooks.extend(context.hooks.clone());
74+
}
75+
76+
profiles.insert(profile_name.clone(), context_config);
77+
}
78+
79+
profiles
80+
};
81+
82+
let mcp_servers = {
83+
let config_path = directories::chat_legacy_mcp_config(os)?;
84+
if os.fs.exists(&config_path) {
85+
match McpServerConfig::load_from_file(os, config_path).await {
86+
Ok(config) => Some(config),
87+
Err(e) => {
88+
error!("Malformed legacy global mcp config detected: {e}. Skipping mcp migration.");
89+
None
90+
},
91+
}
92+
} else {
93+
None
94+
}
95+
};
96+
97+
if legacy_global_context.is_some() || !legacy_profiles.is_empty() {
98+
Ok(ContextMigrate {
99+
legacy_global_context,
100+
legacy_profiles,
101+
mcp_servers,
102+
new_agents: vec![],
103+
})
104+
} else {
105+
bail!("Nothing to migrate");
106+
}
107+
}
108+
}
109+
110+
impl ContextMigrate<'b'> {
111+
pub async fn prompt_migrate(self) -> eyre::Result<ContextMigrate<'c'>> {
112+
let ContextMigrate {
113+
legacy_global_context,
114+
legacy_profiles,
115+
mcp_servers,
116+
new_agents,
117+
} = self;
118+
119+
let labels = vec!["Yes", "No"];
120+
let selection: Option<_> = match Select::with_theme(&crate::util::dialoguer_theme())
121+
.with_prompt("Legacy profiles detected. Would you like to migrate them?")
122+
.items(&labels)
123+
.default(1)
124+
.interact_on_opt(&dialoguer::console::Term::stdout())
125+
{
126+
Ok(sel) => {
127+
let _ = crossterm::execute!(
128+
std::io::stdout(),
129+
crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta)
130+
);
131+
sel
132+
},
133+
// Ctrl‑C -> Err(Interrupted)
134+
Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => None,
135+
Err(e) => bail!("Failed to choose an option: {e}"),
136+
};
137+
138+
if let Some(0) = selection {
139+
Ok(ContextMigrate {
140+
legacy_global_context,
141+
legacy_profiles,
142+
mcp_servers,
143+
new_agents,
144+
})
145+
} else {
146+
bail!("Aborting migration")
147+
}
148+
}
149+
}
150+
151+
impl ContextMigrate<'c'> {
152+
pub async fn migrate(self, os: &Os) -> eyre::Result<ContextMigrate<'d'>> {
153+
const LEGACY_GLOBAL_AGENT_NAME: &str = "migrated_agent_from_global_context";
154+
const DEFAULT_DESC: &str = "This is an agent migrated from global context";
155+
const PROFILE_DESC: &str = "This is an agent migrated from profile context";
156+
157+
let ContextMigrate {
158+
legacy_global_context,
159+
mut legacy_profiles,
160+
mcp_servers,
161+
mut new_agents,
162+
} = self;
163+
164+
let has_global_context = legacy_global_context.is_some();
165+
166+
// Migration of global context
167+
if let Some(context) = legacy_global_context {
168+
let (create_hooks, prompt_hooks) =
169+
context
170+
.hooks
171+
.into_iter()
172+
.partition::<HashMap<String, Hook>, _>(|(_, hook)| {
173+
matches!(hook.trigger, HookTrigger::ConversationStart)
174+
});
175+
176+
new_agents.push(Agent {
177+
name: LEGACY_GLOBAL_AGENT_NAME.to_string(),
178+
description: Some(DEFAULT_DESC.to_string()),
179+
path: Some(directories::chat_global_agent_path(os)?.join(format!("{LEGACY_GLOBAL_AGENT_NAME}.json"))),
180+
included_files: context.paths,
181+
create_hooks: CreateHooks::Map(create_hooks),
182+
prompt_hooks: PromptHooks::Map(prompt_hooks),
183+
mcp_servers: mcp_servers.clone().unwrap_or_default(),
184+
..Default::default()
185+
});
186+
}
187+
188+
let global_agent_path = directories::chat_global_agent_path(os)?;
189+
190+
// Migration of profile context
191+
for (profile_name, context) in legacy_profiles.drain() {
192+
let (create_hooks, prompt_hooks) =
193+
context
194+
.hooks
195+
.into_iter()
196+
.partition::<HashMap<String, Hook>, _>(|(_, hook)| {
197+
matches!(hook.trigger, HookTrigger::ConversationStart)
198+
});
199+
200+
new_agents.push(Agent {
201+
path: Some(global_agent_path.join(format!("{profile_name}.json"))),
202+
name: profile_name,
203+
description: Some(PROFILE_DESC.to_string()),
204+
included_files: context.paths,
205+
create_hooks: CreateHooks::Map(create_hooks),
206+
prompt_hooks: PromptHooks::Map(prompt_hooks),
207+
mcp_servers: mcp_servers.clone().unwrap_or_default(),
208+
..Default::default()
209+
});
210+
}
211+
212+
if !os.fs.exists(&global_agent_path) {
213+
os.fs.create_dir_all(&global_agent_path).await?;
214+
}
215+
216+
for agent in &new_agents {
217+
let content = serde_json::to_string_pretty(agent)?;
218+
if let Some(path) = agent.path.as_ref() {
219+
info!("Agent {} peristed in path {}", agent.name, path.to_string_lossy());
220+
os.fs.write(path, content).await?;
221+
} else {
222+
warn!(
223+
"Agent with name {} does not have path associated and is thus not migrated.",
224+
agent.name
225+
);
226+
}
227+
}
228+
229+
let legacy_profile_config_path = directories::chat_profiles_dir(os)?;
230+
let profile_backup_path = legacy_profile_config_path
231+
.parent()
232+
.ok_or(eyre::eyre!("Failed to obtain profile config parent path"))?
233+
.join("profiles.bak");
234+
os.fs.rename(legacy_profile_config_path, profile_backup_path).await?;
235+
236+
if has_global_context {
237+
let legacy_global_config_path = directories::chat_global_context_path(os)?;
238+
let legacy_global_config_file_name = legacy_global_config_path
239+
.file_name()
240+
.ok_or(eyre::eyre!("Failed to obtain legacy global config name"))?
241+
.to_string_lossy();
242+
let global_context_backup_path = legacy_global_config_path
243+
.parent()
244+
.ok_or(eyre::eyre!("Failed to obtain parent path for global context"))?
245+
.join(format!("{}.bak", legacy_global_config_file_name));
246+
os.fs
247+
.rename(legacy_global_config_path, global_context_backup_path)
248+
.await?;
249+
}
250+
251+
Ok(ContextMigrate {
252+
legacy_global_context: None,
253+
legacy_profiles,
254+
mcp_servers: None,
255+
new_agents,
256+
})
257+
}
258+
}
259+
260+
impl ContextMigrate<'d'> {
261+
pub async fn prompt_set_default(self, os: &mut Os) -> eyre::Result<(Option<String>, Vec<Agent>)> {
262+
let ContextMigrate { new_agents, .. } = self;
263+
264+
let labels = new_agents
265+
.iter()
266+
.map(|a| a.name.as_str())
267+
.chain(vec!["Let me do this on my own later"])
268+
.collect::<Vec<_>>();
269+
// This yields 0 if it's negative, which is acceptable.
270+
let later_idx = labels.len().saturating_sub(1);
271+
let selection: Option<_> = match Select::with_theme(&crate::util::dialoguer_theme())
272+
.with_prompt(
273+
"Set an agent as default. This is the agent that q chat will launch with unless specified otherwise.",
274+
)
275+
.default(0)
276+
.items(&labels)
277+
.interact_on_opt(&dialoguer::console::Term::stdout())
278+
{
279+
Ok(sel) => {
280+
let _ = crossterm::execute!(
281+
std::io::stdout(),
282+
crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta)
283+
);
284+
sel
285+
},
286+
// Ctrl‑C -> Err(Interrupted)
287+
Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => None,
288+
Err(e) => bail!("Failed to choose an option: {e}"),
289+
};
290+
291+
let mut agent_to_load = None::<String>;
292+
if let Some(i) = selection {
293+
if later_idx != i {
294+
if let Some(name) = labels.get(i) {
295+
if let Ok(value) = serde_json::to_value(name) {
296+
if os.database.settings.set(Setting::ChatDefaultAgent, value).await.is_ok() {
297+
let chosen_name = (*name).to_string();
298+
agent_to_load.replace(chosen_name);
299+
}
300+
}
301+
}
302+
}
303+
}
304+
305+
Ok((agent_to_load, new_agents))
306+
}
307+
}

0 commit comments

Comments
 (0)