@@ -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,7 +34,6 @@ use serde::{
3734 Serialize ,
3835} ;
3936use thiserror:: Error ;
40- use tokio:: fs:: ReadDir ;
4137use tracing:: {
4238 error,
4339 info,
@@ -210,6 +206,39 @@ impl Default for Agent {
210206}
211207
212208impl 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}
0 commit comments