@@ -10,10 +10,7 @@ use std::collections::{
1010 HashSet ,
1111} ;
1212use std:: ffi:: OsStr ;
13- use std:: io:: {
14- self ,
15- Write ,
16- } ;
13+ use std:: io:: Write ;
1714use std:: path:: {
1815 Path ,
1916 PathBuf ,
@@ -37,12 +34,12 @@ use serde::{
3734 Serialize ,
3835} ;
3936use thiserror:: Error ;
40- use tokio:: fs:: ReadDir ;
4137use tracing:: {
4238 error,
4339 info,
4440 warn,
4541} ;
42+ use walkdir;
4643use wrapper_types:: ResourcePath ;
4744pub use wrapper_types:: {
4845 OriginalToolName ,
@@ -212,6 +209,38 @@ impl Default for Agent {
212209}
213210
214211impl 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}
0 commit comments