Skip to content

Commit 26caae9

Browse files
authored
feat: Path based loading for agent (#2859)
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 1776a67 commit 26caae9

File tree

4 files changed

+204
-83
lines changed

4 files changed

+204
-83
lines changed

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

Lines changed: 194 additions & 73 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,12 +34,12 @@ use serde::{
3734
Serialize,
3835
};
3936
use thiserror::Error;
40-
use tokio::fs::ReadDir;
4137
use tracing::{
4238
error,
4339
info,
4440
warn,
4541
};
42+
use walkdir;
4643
use wrapper_types::ResourcePath;
4744
pub use wrapper_types::{
4845
OriginalToolName,
@@ -212,6 +209,38 @@ impl Default for Agent {
212209
}
213210

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

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

352-
let global_config_dir = directories::chat_global_agent_path(os)?.join(format!("{agent_name}.json"));
353-
if os.fs.exists(&global_config_dir) {
354-
break 'config Ok(global_config_dir);
379+
for (_, agent) in agents.agents {
380+
if agent.name == agent_name || agent.path_identifier(os).as_deref() == Some(agent_name) {
381+
if let Some(path) = agent.path.clone() {
382+
return Ok((agent, path));
383+
}
355384
}
356-
357-
Err(global_config_dir)
358-
};
359-
360-
match config_path {
361-
Ok(config_path) => {
362-
let content = os.fs.read(&config_path).await?;
363-
let mut agent = serde_json::from_slice::<Agent>(&content)?;
364-
let legacy_mcp_config = if agent.use_legacy_mcp_json {
365-
load_legacy_mcp_config(os).await.unwrap_or(None)
366-
} else {
367-
None
368-
};
369-
let mut stderr = std::io::stderr();
370-
agent.thaw(&config_path, legacy_mcp_config.as_ref(), &mut stderr)?;
371-
Ok((agent, config_path))
372-
},
373-
_ => bail!("Agent {agent_name} does not exist"),
374385
}
386+
387+
bail!("Agent {agent_name} does not exist")
375388
}
376389

377390
pub async fn load(
@@ -476,14 +489,22 @@ impl Agents {
476489
self.agents.get_mut(&self.active_idx)
477490
}
478491

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

489510
/// This function does a number of things in the following order:
@@ -558,12 +579,9 @@ impl Agents {
558579
let Ok(path) = directories::chat_local_agent_dir(os) else {
559580
break 'local Vec::<Agent>::new();
560581
};
561-
let Ok(files) = os.fs.read_dir(path).await else {
562-
break 'local Vec::<Agent>::new();
563-
};
564582

565583
let mut agents = Vec::<Agent>::new();
566-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
584+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
567585
for result in results {
568586
match result {
569587
Ok(agent) => agents.push(agent),
@@ -588,20 +606,9 @@ impl Agents {
588606
let Ok(path) = directories::chat_global_agent_path(os) else {
589607
break 'global Vec::<Agent>::new();
590608
};
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-
};
602609

603610
let mut agents = Vec::<Agent>::new();
604-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
611+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
605612
for result in results {
606613
match result {
607614
Ok(agent) => agents.push(agent),
@@ -704,8 +711,14 @@ impl Agents {
704711
// 3. If the above is missing or invalid, assume the in-memory default
705712
let active_idx = 'active_idx: {
706713
if let Some(name) = agent_name {
707-
if all_agents.iter().any(|a| a.name.as_str() == name) {
708-
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();
709722
}
710723
let _ = queue!(
711724
output,
@@ -880,24 +893,41 @@ pub struct AgentsLoadMetadata {
880893
pub launched_agent: String,
881894
}
882895

883-
async fn load_agents_from_entries(
884-
mut files: ReadDir,
896+
async fn load_agents_from_directory(
897+
dir_path: &Path,
885898
os: &Os,
886899
global_mcp_config: &mut Option<McpServerConfig>,
887900
mcp_enabled: bool,
888901
output: &mut impl Write,
889902
) -> Vec<Result<Agent, AgentConfigError>> {
890903
let mut res = Vec::<Result<Agent, AgentConfigError>>::new();
891904

892-
while let Ok(Some(file)) = files.next_entry().await {
893-
let file_path = &file.path();
894-
if file_path
895-
.extension()
896-
.and_then(OsStr::to_str)
897-
.is_some_and(|s| s == "json")
898-
{
899-
res.push(Agent::load(os, file_path, global_mcp_config, mcp_enabled, output).await);
900-
}
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);
901931
}
902932

903933
res
@@ -1070,9 +1100,10 @@ mod tests {
10701100
);
10711101
}
10721102

1073-
#[test]
1074-
fn test_switch() {
1103+
#[tokio::test]
1104+
async fn test_switch() {
10751105
let mut collection = Agents::default();
1106+
let os = crate::os::Os::new().await.unwrap();
10761107

10771108
let default_agent = Agent::default();
10781109
let dev_agent = Agent {
@@ -1086,12 +1117,12 @@ mod tests {
10861117
collection.active_idx = "default".to_string();
10871118

10881119
// Test successful switch
1089-
let result = collection.switch("dev");
1120+
let result = collection.switch("dev", &os);
10901121
assert!(result.is_ok());
10911122
assert_eq!(result.unwrap().name, "dev");
10921123

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

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

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

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

0 commit comments

Comments
 (0)