Skip to content

Commit 308ebb6

Browse files
committed
feat: Path based loading for agent
People can now do `q chat --agent team/myagent` in addition to the current `q chat --agent myagent`. This will enable people to better organize their agents in Q CLI directory
1 parent 8ff6db8 commit 308ebb6

File tree

4 files changed

+209
-86
lines changed

4 files changed

+209
-86
lines changed

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 196 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ use std::collections::{
1010
HashSet,
1111
};
1212
use std::ffi::OsStr;
13-
use std::io::{
14-
self,
15-
Write,
16-
};
13+
use std::io::Write;
1714
use std::path::{
1815
Path,
1916
PathBuf,
@@ -37,7 +34,6 @@ use serde::{
3734
Serialize,
3835
};
3936
use thiserror::Error;
40-
use tokio::fs::ReadDir;
4137
use tracing::{
4238
error,
4339
info,
@@ -210,6 +206,39 @@ impl Default for Agent {
210206
}
211207

212208
impl Agent {
209+
/// Calculate the path-based identifier for this agent
210+
/// Returns the relative path from agent directory to file (without .json extension)
211+
/// Example: "team/assistant" for file at agents/team/assistant.json
212+
pub fn path_identifier(&self, os: &Os) -> Option<String> {
213+
let full_path = self.path.as_ref()?;
214+
215+
// Extract just the filename without extension for fallback
216+
let file_stem = full_path.file_stem()?.to_str()?;
217+
218+
// Try to get the actual directory paths using the proper functions
219+
let resolver = PathResolver::new(os);
220+
// Check local workspace directory first
221+
if let Ok(local_dir) = resolver.workspace().agents_dir() {
222+
if let Ok(rel_path) = full_path.strip_prefix(&local_dir) {
223+
if let Some(path_str) = rel_path.with_extension("").to_str() {
224+
return Some(path_str.to_string());
225+
}
226+
}
227+
}
228+
229+
// Check global directory
230+
if let Ok(global_dir) = resolver.global().agents_dir() {
231+
if let Ok(rel_path) = full_path.strip_prefix(&global_dir) {
232+
if let Some(path_str) = rel_path.with_extension("").to_str() {
233+
return Some(path_str.to_string());
234+
}
235+
}
236+
}
237+
238+
// Fallback to just filename
239+
Some(file_stem.to_string())
240+
}
241+
213242
/// This function mutates the agent to a state that is writable.
214243
/// Practically this means reverting some fields back to their original values as they were
215244
/// written in the config.
@@ -337,40 +366,23 @@ impl Agent {
337366
}
338367
}
339368

340-
/// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
341-
/// and falling back to global dir if it does not exist in local.
369+
/// Retrieves an agent by name or path identifier. It does so via first seeking the given agent
370+
/// under local dir, and falling back to global dir if it does not exist in local.
371+
/// Supports both JSON name field lookup and path-based lookup (e.g., "team/assistant").
372+
/// Load all agents first and filter by both JSON name and path identifier
342373
pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> {
343-
let resolver = PathResolver::new(os);
344-
let config_path: Result<PathBuf, PathBuf> = 'config: {
345-
// local first, and then fall back to looking at global
346-
let local_config_dir = resolver.workspace().agents_dir()?.join(format!("{agent_name}.json"));
347-
if os.fs.exists(&local_config_dir) {
348-
break 'config Ok(local_config_dir);
349-
}
374+
let mut stderr = std::io::stderr();
375+
let (agents, _) = Agents::load(&mut os.clone(), None, true, &mut stderr, true).await;
350376

351-
let global_config_dir = resolver.global().agents_dir()?.join(format!("{agent_name}.json"));
352-
if os.fs.exists(&global_config_dir) {
353-
break 'config Ok(global_config_dir);
377+
for (_, agent) in agents.agents {
378+
if agent.name == agent_name || agent.path_identifier(os).as_deref() == Some(agent_name) {
379+
if let Some(path) = agent.path.clone() {
380+
return Ok((agent, path));
381+
}
354382
}
355-
356-
Err(global_config_dir)
357-
};
358-
359-
match config_path {
360-
Ok(config_path) => {
361-
let content = os.fs.read(&config_path).await?;
362-
let mut agent = serde_json::from_slice::<Agent>(&content)?;
363-
let legacy_mcp_config = if agent.use_legacy_mcp_json {
364-
load_legacy_mcp_config(os).await.unwrap_or(None)
365-
} else {
366-
None
367-
};
368-
let mut stderr = std::io::stderr();
369-
agent.thaw(&config_path, legacy_mcp_config.as_ref(), &mut stderr)?;
370-
Ok((agent, config_path))
371-
},
372-
_ => bail!("Agent {agent_name} does not exist"),
373383
}
384+
385+
bail!("Agent {agent_name} does not exist")
374386
}
375387

376388
pub async fn load(
@@ -475,14 +487,22 @@ impl Agents {
475487
self.agents.get_mut(&self.active_idx)
476488
}
477489

478-
pub fn switch(&mut self, name: &str) -> eyre::Result<&Agent> {
479-
if !self.agents.contains_key(name) {
490+
pub fn switch(&mut self, os: &Os, name: &str) -> eyre::Result<&Agent> {
491+
// Find agent by either JSON name or path identifier
492+
let matching_key = self
493+
.agents
494+
.iter()
495+
.find(|(_, agent)| agent.name.as_str() == name || agent.path_identifier(os).as_deref().is_some_and(|n| n == name))
496+
.map(|(key, _)| key.clone());
497+
498+
if let Some(key) = matching_key {
499+
self.active_idx = key;
500+
self.agents
501+
.get(&self.active_idx)
502+
.ok_or(eyre::eyre!("No agent with name {name} found"))
503+
} else {
480504
eyre::bail!("No agent with name {name} found");
481505
}
482-
self.active_idx = name.to_string();
483-
self.agents
484-
.get(name)
485-
.ok_or(eyre::eyre!("No agent with name {name} found"))
486506
}
487507

488508
/// This function does a number of things in the following order:
@@ -558,12 +578,9 @@ impl Agents {
558578
let Ok(path) = resolver.workspace().agents_dir() else {
559579
break 'local Vec::<Agent>::new();
560580
};
561-
let Ok(files) = os.fs.read_dir(path).await else {
562-
break 'local Vec::<Agent>::new();
563-
};
564581

565582
let mut agents = Vec::<Agent>::new();
566-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
583+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
567584
for result in results {
568585
match result {
569586
Ok(agent) => agents.push(agent),
@@ -588,20 +605,9 @@ impl Agents {
588605
let Ok(path) = resolver.global().agents_dir() else {
589606
break 'global Vec::<Agent>::new();
590607
};
591-
let files = match os.fs.read_dir(&path).await {
592-
Ok(files) => files,
593-
Err(e) => {
594-
if matches!(e.kind(), io::ErrorKind::NotFound) {
595-
if let Err(e) = os.fs.create_dir_all(&path).await {
596-
error!("Error creating global agent dir: {:?}", e);
597-
}
598-
}
599-
break 'global Vec::<Agent>::new();
600-
},
601-
};
602608

603609
let mut agents = Vec::<Agent>::new();
604-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
610+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
605611
for result in results {
606612
match result {
607613
Ok(agent) => agents.push(agent),
@@ -705,8 +711,14 @@ impl Agents {
705711
// 3. If the above is missing or invalid, assume the in-memory default
706712
let active_idx = 'active_idx: {
707713
if let Some(name) = agent_name {
708-
if all_agents.iter().any(|a| a.name.as_str() == name) {
709-
break 'active_idx name.to_string();
714+
// Dual lookup: try both JSON name field and path identifier
715+
if let Some(matching_agent) = all_agents.iter().find(|agent| {
716+
// Current behavior: match against JSON name field
717+
agent.name.as_str() == name ||
718+
// New behavior: match against file path identifier
719+
agent.path_identifier(os).as_deref() == Some(name)
720+
}) {
721+
break 'active_idx matching_agent.name.clone();
710722
}
711723
let _ = queue!(
712724
output,
@@ -881,24 +893,41 @@ pub struct AgentsLoadMetadata {
881893
pub launched_agent: String,
882894
}
883895

884-
async fn load_agents_from_entries(
885-
mut files: ReadDir,
896+
async fn load_agents_from_directory(
897+
dir_path: &Path,
886898
os: &Os,
887899
global_mcp_config: &mut Option<McpServerConfig>,
888900
mcp_enabled: bool,
889901
output: &mut impl Write,
890902
) -> Vec<Result<Agent, AgentConfigError>> {
891903
let mut res = Vec::<Result<Agent, AgentConfigError>>::new();
892904

893-
while let Ok(Some(file)) = files.next_entry().await {
894-
let file_path = &file.path();
895-
if file_path
896-
.extension()
897-
.and_then(OsStr::to_str)
898-
.is_some_and(|s| s == "json")
899-
{
900-
res.push(Agent::load(os, file_path, global_mcp_config, mcp_enabled, output).await);
901-
}
905+
// Check if directory exists before trying to walk it
906+
if !os.fs.exists(dir_path) {
907+
// Directory doesn't exist - return empty list (this is normal)
908+
return res;
909+
}
910+
911+
// Collect file paths in a blocking task to avoid blocking the async runtime
912+
let dir_path = dir_path.to_path_buf();
913+
let file_paths = tokio::task::spawn_blocking(move || {
914+
walkdir::WalkDir::new(&dir_path)
915+
.follow_links(false)
916+
.into_iter()
917+
.filter_map(|e| e.ok())
918+
.filter(|entry| {
919+
let path = entry.path();
920+
path.is_file() && path.extension().and_then(OsStr::to_str).is_some_and(|s| s == "json")
921+
})
922+
.map(|entry| entry.path().to_path_buf())
923+
.collect::<Vec<_>>()
924+
})
925+
.await
926+
.unwrap_or_default();
927+
928+
// Load agents asynchronously
929+
for file_path in file_paths {
930+
res.push(Agent::load(os, &file_path, global_mcp_config, mcp_enabled, output).await);
902931
}
903932

904933
res
@@ -1072,9 +1101,10 @@ mod tests {
10721101
);
10731102
}
10741103

1075-
#[test]
1076-
fn test_switch() {
1104+
#[tokio::test]
1105+
async fn test_switch() {
10771106
let mut collection = Agents::default();
1107+
let os = crate::os::Os::new().await.unwrap();
10781108

10791109
let default_agent = Agent::default();
10801110
let dev_agent = Agent {
@@ -1088,12 +1118,12 @@ mod tests {
10881118
collection.active_idx = "default".to_string();
10891119

10901120
// Test successful switch
1091-
let result = collection.switch("dev");
1121+
let result = collection.switch(&os, "dev");
10921122
assert!(result.is_ok());
10931123
assert_eq!(result.unwrap().name, "dev");
10941124

10951125
// Test switch to non-existent agent
1096-
let result = collection.switch("nonexistent");
1126+
let result = collection.switch(&os, "nonexistent");
10971127
assert!(result.is_err());
10981128
assert_eq!(result.unwrap_err().to_string(), "No agent with name nonexistent found");
10991129
}
@@ -1581,4 +1611,96 @@ mod tests {
15811611
let result = agent.resolve_prompt();
15821612
assert!(result.is_err());
15831613
}
1614+
1615+
#[tokio::test]
1616+
async fn test_path_identifier() {
1617+
use std::path::PathBuf;
1618+
1619+
// Create a mock Os for testing
1620+
let os = crate::os::Os::new().await.unwrap();
1621+
1622+
// Get the actual OS paths for testing
1623+
let resolver = PathResolver::new(&os);
1624+
let local_dir = resolver.workspace().agents_dir().unwrap();
1625+
let global_dir = resolver.global().agents_dir().unwrap();
1626+
1627+
// Test workspace agent path using actual OS paths
1628+
let mut agent = Agent::default();
1629+
agent.path = Some(local_dir.join("team/assistant.json"));
1630+
assert_eq!(agent.path_identifier(&os), Some("team/assistant".to_string()));
1631+
1632+
// Test global agent path using actual OS paths
1633+
agent.path = Some(global_dir.join("org/specialist.json"));
1634+
assert_eq!(agent.path_identifier(&os), Some("org/specialist".to_string()));
1635+
1636+
// Test nested path using actual OS paths
1637+
agent.path = Some(global_dir.join("company/team/expert.json"));
1638+
assert_eq!(agent.path_identifier(&os), Some("company/team/expert".to_string()));
1639+
1640+
// Test simple filename (fallback) - path that doesn't match agent directories
1641+
agent.path = Some(PathBuf::from("/some/other/path/simple.json"));
1642+
assert_eq!(agent.path_identifier(&os), Some("simple".to_string()));
1643+
1644+
// Test no path
1645+
agent.path = None;
1646+
assert_eq!(agent.path_identifier(&os), None);
1647+
1648+
// Test cross-platform path normalization using actual OS paths
1649+
agent.path = Some(global_dir.join("dev").join("helper.json"));
1650+
assert_eq!(agent.path_identifier(&os), Some("dev/helper".to_string()));
1651+
}
1652+
1653+
#[tokio::test]
1654+
async fn test_switch_with_path_identifier() {
1655+
let mut collection = Agents::default();
1656+
let os = crate::os::Os::new().await.unwrap();
1657+
1658+
// Get the actual OS paths for testing
1659+
let resolver = PathResolver::new(&os);
1660+
let global_dir = resolver.global().agents_dir().unwrap();
1661+
1662+
// Create agents with different paths using actual OS paths
1663+
let mut agent1 = Agent {
1664+
name: "helper".to_string(),
1665+
..Default::default()
1666+
};
1667+
agent1.path = Some(global_dir.join("dev/helper.json"));
1668+
1669+
let mut agent2 = Agent {
1670+
name: "assistant".to_string(),
1671+
..Default::default()
1672+
};
1673+
agent2.path = Some(global_dir.join("team/assistant.json"));
1674+
1675+
collection.agents.insert("helper".to_string(), agent1);
1676+
collection.agents.insert("assistant".to_string(), agent2);
1677+
collection.active_idx = "helper".to_string();
1678+
1679+
// Test switch by JSON name (existing behavior)
1680+
let result = collection.switch(&os, "assistant");
1681+
assert!(result.is_ok());
1682+
assert_eq!(result.unwrap().name, "assistant");
1683+
1684+
// Test switch by path identifier (new behavior)
1685+
let result = collection.switch(&os, "dev/helper");
1686+
assert!(result.is_ok());
1687+
assert_eq!(result.unwrap().name, "helper");
1688+
1689+
// Test switch by nested path identifier
1690+
let result = collection.switch(&os, "team/assistant");
1691+
assert!(result.is_ok());
1692+
assert_eq!(result.unwrap().name, "assistant");
1693+
1694+
// Test switch to non-existent agent (both name and path)
1695+
let result = collection.switch(&os, "nonexistent");
1696+
assert!(result.is_err());
1697+
assert_eq!(result.unwrap_err().to_string(), "No agent with name nonexistent found");
1698+
1699+
let result = collection.switch(&os, "nonexistent/path");
1700+
assert!(result.is_err());
1701+
assert_eq!(
1702+
result.unwrap_err().to_string(),
1703+
"No agent with name nonexistent/path found"
1704+
);
1705+
}
15841706
}

crates/chat-cli/src/cli/agent/root_command_args.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ impl AgentArgs {
287287
},
288288
Some(AgentSubcommands::SetDefault { name }) => {
289289
let mut agents = Agents::load(os, None, true, &mut stderr, mcp_enabled).await.0;
290-
match agents.switch(&name) {
290+
match agents.switch(os, &name) {
291291
Ok(agent) => {
292292
os.database
293293
.settings
@@ -353,7 +353,7 @@ pub async fn create_agent(
353353
}
354354

355355
let prepopulated_content = if let Some(from) = from {
356-
let mut agent_to_copy = agents.switch(from.as_str())?.clone();
356+
let mut agent_to_copy = agents.switch(os, from.as_str())?.clone();
357357
agent_to_copy.name = name.clone();
358358
agent_to_copy
359359
} else {

0 commit comments

Comments
 (0)