tsugite-cli 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
@@ -0,0 +1,265 @@
1
+ """Tool registry for Tsugite agents."""
2
+
3
+ import inspect
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, Dict, List
6
+
7
+ from ..utils import tool_error, validation_error
8
+
9
+
10
+ @dataclass
11
+ class ToolInfo:
12
+ """Information about a registered tool."""
13
+
14
+ name: str
15
+ func: Callable
16
+ description: str
17
+ parameters: Dict[str, Any]
18
+
19
+
20
+ # Global tool registry
21
+ _tools: Dict[str, ToolInfo] = {}
22
+
23
+
24
+ def tool(func: Callable) -> Callable:
25
+ """Register a function as a tool."""
26
+ # Extract function signature and docstring
27
+ sig = inspect.signature(func)
28
+ doc = func.__doc__ or "No description available"
29
+
30
+ # Extract parameter info
31
+ parameters = {}
32
+ for param_name, param in sig.parameters.items():
33
+ parameters[param_name] = {
34
+ "type": (param.annotation if param.annotation != inspect.Parameter.empty else str),
35
+ "default": (param.default if param.default != inspect.Parameter.empty else None),
36
+ "required": param.default == inspect.Parameter.empty,
37
+ }
38
+
39
+ tool_info = ToolInfo(
40
+ name=func.__name__,
41
+ func=func,
42
+ description=doc.split("\n")[0].strip(), # First line of docstring
43
+ parameters=parameters,
44
+ )
45
+
46
+ _tools[func.__name__] = tool_info
47
+ return func
48
+
49
+
50
+ def get_tool(name: str) -> ToolInfo:
51
+ """Get a registered tool by name."""
52
+ if name not in _tools:
53
+ # Provide helpful error message
54
+ from ..shell_tool_config import get_custom_tools_config_path
55
+
56
+ error_parts = ["not found"]
57
+
58
+ # Check if it might be a custom tool
59
+ config_path = get_custom_tools_config_path()
60
+ if config_path.exists():
61
+ error_parts.append(f"Check if '{name}' is defined in {config_path}")
62
+ else:
63
+ error_parts.append(f"Custom tools config not found at {config_path}")
64
+
65
+ # Suggest similar tool names
66
+ all_tools = list(_tools.keys())
67
+ similar = [t for t in all_tools if name.lower() in t.lower() or t.lower() in name.lower()]
68
+ if similar:
69
+ error_parts.append(f"Did you mean: {', '.join(similar[:3])}?")
70
+
71
+ error_parts.append("Run 'tsugite tools list' to see all available tools")
72
+
73
+ raise validation_error("tool", name, ". ".join(error_parts))
74
+ return _tools[name]
75
+
76
+
77
+ def call_tool(name: str, **kwargs) -> Any:
78
+ """Call a tool with the given arguments."""
79
+ tool_info = get_tool(name)
80
+
81
+ # Validate required parameters
82
+ for param_name, param_info in tool_info.parameters.items():
83
+ if param_info["required"] and param_name not in kwargs:
84
+ raise validation_error("parameter", param_name, f"missing for tool '{name}'")
85
+
86
+ try:
87
+ return tool_info.func(**kwargs)
88
+ except Exception as e:
89
+ raise tool_error(name, "execute", str(e))
90
+
91
+
92
+ def list_tools() -> List[str]:
93
+ """List all registered tool names."""
94
+ return list(_tools.keys())
95
+
96
+
97
+ def get_tools_by_category(category: str) -> List[str]:
98
+ """Get all tool names in a specific category.
99
+
100
+ Args:
101
+ category: Category name (e.g., 'fs', 'http', 'shell')
102
+
103
+ Returns:
104
+ List of tool names in the category
105
+ """
106
+ category_tools = []
107
+ for tool_name, tool_info in _tools.items():
108
+ module = tool_info.func.__module__.split(".")[-1]
109
+ if module == category:
110
+ category_tools.append(tool_name)
111
+
112
+ return sorted(category_tools)
113
+
114
+
115
+ def _expand_single_spec(spec: str, strict: bool = True) -> List[str]:
116
+ """Expand a single tool specification to tool names.
117
+
118
+ Args:
119
+ spec: Tool specification (name, @category, or glob pattern)
120
+ strict: If True, raise error when spec matches nothing. If False, return empty list.
121
+
122
+ Returns:
123
+ List of matching tool names
124
+ """
125
+ import fnmatch
126
+
127
+ if spec.startswith("@"):
128
+ # Category reference: @fs, @http, etc.
129
+ category = spec[1:]
130
+ category_tools = get_tools_by_category(category)
131
+ if not category_tools and strict:
132
+ raise validation_error("tool category", category, "not found or empty")
133
+ return category_tools
134
+ elif "*" in spec or "?" in spec or "[" in spec:
135
+ # Glob pattern
136
+ all_tool_names = list_tools()
137
+ matches = fnmatch.filter(all_tool_names, spec)
138
+ if not matches and strict:
139
+ raise validation_error("tool pattern", spec, "matched no tools")
140
+ return matches
141
+ else:
142
+ # Regular tool name
143
+ if spec not in _tools:
144
+ if strict:
145
+ available = ", ".join(list(_tools.keys())) if _tools else "none"
146
+ raise validation_error("tool", spec, f"not found. Available: {available}")
147
+ return []
148
+ return [spec]
149
+
150
+
151
+ def expand_tool_specs(tool_specs: List[str]) -> List[str]:
152
+ """Expand tool specifications to actual tool names.
153
+
154
+ Supports:
155
+ - Regular tool names: 'read_file' -> ['read_file']
156
+ - Category references: '@fs' -> all tools in fs category
157
+ - Glob patterns: '*_file' -> all tools matching pattern
158
+ - Exclusions: '-tool_name', '-@category', '-pattern*' -> remove matching tools
159
+
160
+ Args:
161
+ tool_specs: List of tool specifications (names, @category, globs, or exclusions with -)
162
+
163
+ Returns:
164
+ Expanded list of unique tool names
165
+
166
+ Examples:
167
+ >>> expand_tool_specs(['read_file', 'write_file'])
168
+ ['read_file', 'write_file']
169
+ >>> expand_tool_specs(['@fs'])
170
+ ['create_directory', 'file_exists', 'list_files', 'read_file', 'write_file']
171
+ >>> expand_tool_specs(['*_file'])
172
+ ['read_file', 'write_file']
173
+ >>> expand_tool_specs(['@fs', '-*_directory'])
174
+ ['file_exists', 'list_files', 'read_file', 'write_file']
175
+ >>> expand_tool_specs(['@http', '-web_search'])
176
+ ['check_url', 'download_file', 'fetch_json', 'fetch_text', 'post_json']
177
+ """
178
+ # Separate inclusions and exclusions
179
+ inclusions = []
180
+ exclusions = []
181
+
182
+ for spec in tool_specs:
183
+ if spec.startswith("-"):
184
+ exclusions.append(spec[1:]) # Strip the - prefix
185
+ else:
186
+ inclusions.append(spec)
187
+
188
+ # Expand inclusions (strict - must match something)
189
+ expanded = []
190
+ for spec in inclusions:
191
+ expanded.extend(_expand_single_spec(spec, strict=True))
192
+
193
+ # Expand exclusions (non-strict - silently ignore if nothing matches)
194
+ excluded_tools = set()
195
+ for spec in exclusions:
196
+ excluded_tools.update(_expand_single_spec(spec, strict=False))
197
+
198
+ # Apply exclusions
199
+ result_with_exclusions = [tool for tool in expanded if tool not in excluded_tools]
200
+
201
+ # Return unique tools while preserving order
202
+ seen = set()
203
+ result = []
204
+ for tool in result_with_exclusions:
205
+ if tool not in seen:
206
+ seen.add(tool)
207
+ result.append(tool)
208
+
209
+ return result
210
+
211
+
212
+ def load_custom_shell_tools() -> None:
213
+ """Load custom shell tools from config file.
214
+
215
+ This is called automatically at module import time to register
216
+ user-defined shell tools from custom_tools.yaml.
217
+ """
218
+ import os
219
+ import sys
220
+
221
+ try:
222
+ from ..shell_tool_config import get_custom_tools_config_path, load_custom_tools_config
223
+ from .shell_tools import register_shell_tools
224
+
225
+ config_path = get_custom_tools_config_path()
226
+
227
+ # Only try to load if config file exists
228
+ if not config_path.exists():
229
+ # Silently skip if no custom tools configured
230
+ return
231
+
232
+ definitions = load_custom_tools_config()
233
+ if definitions:
234
+ register_shell_tools(definitions)
235
+
236
+ # Show helpful message if verbose mode enabled
237
+ if os.environ.get("TSUGITE_VERBOSE") or os.environ.get("TSUGITE_DEBUG"):
238
+ tool_names = [d.name for d in definitions]
239
+ print(
240
+ f"✓ Loaded {len(definitions)} custom tool(s): {', '.join(tool_names)}",
241
+ file=sys.stderr,
242
+ )
243
+ else:
244
+ # Config exists but no tools defined
245
+ if os.environ.get("TSUGITE_VERBOSE") or os.environ.get("TSUGITE_DEBUG"):
246
+ print(f"âš  Custom tools config exists but no tools defined: {config_path}", file=sys.stderr)
247
+
248
+ except Exception as e:
249
+ # Don't fail startup if custom tools can't be loaded, but show clear error
250
+ print(f"âš  Failed to load custom tools: {e}", file=sys.stderr)
251
+ print(f" Config file: {get_custom_tools_config_path()}", file=sys.stderr)
252
+ print(" Use 'tsugite tools validate' to check your config", file=sys.stderr)
253
+
254
+
255
+ # Import tool modules at the end to avoid circular imports
256
+ # (they need to import 'tool' decorator from this module)
257
+ from . import agents as agents # noqa: E402
258
+ from . import fs as fs # noqa: E402
259
+ from . import http as http # noqa: E402
260
+ from . import interactive as interactive # noqa: E402
261
+ from . import shell as shell # noqa: E402
262
+ from . import tasks as tasks # noqa: E402
263
+
264
+ # Load custom shell tools after built-in tools
265
+ load_custom_shell_tools()
@@ -0,0 +1,312 @@
1
+ """Agent orchestration tools for spawning and managing sub-agents."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from ..tools import tool
7
+ from ..utils import parse_yaml_frontmatter
8
+
9
+
10
+ @tool
11
+ def spawn_agent(
12
+ agent_path: str,
13
+ prompt: str,
14
+ context: Optional[Dict[str, Any]] = None,
15
+ model_override: Optional[str] = None,
16
+ timeout: int = 300,
17
+ ) -> str:
18
+ """Spawn subagent as subprocess.
19
+
20
+ Args:
21
+ agent_path: Path to agent .md file
22
+ prompt: Task for the subagent
23
+ context: Optional context dict (must be JSON-serializable)
24
+ model_override: Optional model to use
25
+ timeout: Timeout in seconds (default: 5 minutes)
26
+
27
+ Returns:
28
+ Subagent's final result as string
29
+
30
+ Raises:
31
+ ValueError: If agent not found or context not JSON-serializable
32
+ RuntimeError: If subagent fails, times out, or errors
33
+ """
34
+ import json
35
+ import subprocess
36
+
37
+ from ..agent_runner import get_current_agent
38
+
39
+ # Validate agent path
40
+ agent_file = Path(agent_path)
41
+ if not agent_file.is_absolute():
42
+ agent_file = Path.cwd() / agent_file
43
+ if not agent_file.exists():
44
+ raise ValueError(f"Agent not found: {agent_path}")
45
+
46
+ # Prepare context
47
+ context_data = {
48
+ "prompt": prompt,
49
+ "context": {
50
+ **(context or {}),
51
+ "parent_agent": get_current_agent(),
52
+ "is_subagent": True,
53
+ },
54
+ }
55
+
56
+ # Validate JSON serializability early
57
+ try:
58
+ context_json = json.dumps(context_data)
59
+ except (TypeError, ValueError) as e:
60
+ # Try to identify problematic value
61
+ bad_type = "unknown"
62
+ if hasattr(e, "__context__") and e.__context__:
63
+ bad_type = str(type(e.__context__)).split("'")[1]
64
+ raise ValueError(
65
+ f"Context contains non-JSON-serializable data (type: {bad_type}). "
66
+ "Only use dicts, lists, strings, numbers, bools, and None."
67
+ ) from e
68
+
69
+ # Build command
70
+ cmd = ["uv", "run", "tsu", "run", str(agent_file), "--subagent-mode"]
71
+ if model_override:
72
+ cmd.extend(["--model", model_override])
73
+
74
+ # Set up progress spinner
75
+ import queue
76
+ import threading
77
+
78
+ from ..ui_context import get_progress, get_ui_handler
79
+
80
+ progress = get_progress()
81
+ ui_handler = get_ui_handler()
82
+ agent_name = agent_file.stem
83
+
84
+ # Show initial message through event system
85
+ if ui_handler and not progress:
86
+ from ..events import EventBus, InfoEvent
87
+
88
+ event_bus = EventBus()
89
+ event_bus.subscribe(ui_handler.handle_event)
90
+ event_bus.emit(InfoEvent(message=f"🚀 Spawning subagent: [cyan]{agent_name}[/cyan]..."))
91
+
92
+ try:
93
+ # Spawn subprocess with line buffering
94
+ proc = subprocess.Popen(
95
+ cmd,
96
+ stdin=subprocess.PIPE,
97
+ stdout=subprocess.PIPE,
98
+ stderr=subprocess.PIPE,
99
+ text=True,
100
+ bufsize=1, # Line buffered for real-time output
101
+ cwd=Path.cwd(), # Subagent inherits parent's working directory
102
+ )
103
+
104
+ # Write context to stdin then close it
105
+ proc.stdin.write(context_json)
106
+ proc.stdin.close()
107
+
108
+ # Queue for passing lines from reader thread to main thread
109
+ line_queue = queue.Queue()
110
+ reader_exception = None
111
+
112
+ def read_stdout():
113
+ """Read stdout lines in separate thread and put in queue."""
114
+ nonlocal reader_exception
115
+ try:
116
+ for line in proc.stdout:
117
+ line_queue.put(line)
118
+ line_queue.put(None) # Signal EOF
119
+ except Exception as e:
120
+ reader_exception = e
121
+ line_queue.put(None)
122
+
123
+ # Start reader thread
124
+ reader_thread = threading.Thread(target=read_stdout, daemon=True)
125
+ reader_thread.start()
126
+
127
+ # Read JSONL stream and collect events
128
+ final_result = None
129
+ errors = []
130
+
131
+ while True:
132
+ # Try to get line from queue with timeout for periodic updates
133
+ try:
134
+ line = line_queue.get(timeout=0.5)
135
+ except queue.Empty:
136
+ # No data yet - just waiting for subprocess output
137
+ # Don't update progress here to avoid too many updates
138
+ continue
139
+
140
+ # Check for EOF or reader thread exception
141
+ if line is None:
142
+ if reader_exception:
143
+ raise reader_exception
144
+ break
145
+
146
+ # Process JSONL event
147
+ try:
148
+ event = json.loads(line.strip())
149
+
150
+ # Skip non-dict events (e.g., if line is just a number)
151
+ if not isinstance(event, dict):
152
+ continue
153
+
154
+ event_type = event.get("type")
155
+
156
+ # Update progress spinner for key events only
157
+ if ui_handler:
158
+ if event_type == "turn_start":
159
+ ui_handler.update_progress(f"🚀 {agent_name}: Turn {event['turn']}")
160
+ elif event_type == "tool_call":
161
+ ui_handler.update_progress(f"🚀 {agent_name}: {event['tool']}(...)")
162
+ elif event_type == "code":
163
+ ui_handler.update_progress(f"🚀 {agent_name}: Running code...")
164
+
165
+ # Collect results/errors
166
+ if event_type == "final_result":
167
+ final_result = event["result"]
168
+ elif event_type == "error":
169
+ errors.append(event)
170
+
171
+ except json.JSONDecodeError:
172
+ continue # Skip malformed lines
173
+
174
+ # Wait for reader thread to finish
175
+ reader_thread.join(timeout=1.0)
176
+
177
+ # Wait for completion
178
+ try:
179
+ return_code = proc.wait(timeout=timeout)
180
+ except subprocess.TimeoutExpired:
181
+ proc.kill()
182
+ raise RuntimeError(f"Subagent timed out after {timeout}s")
183
+
184
+ # Check for failures
185
+ if return_code != 0:
186
+ stderr = proc.stderr.read()
187
+ error_msg = f"Subagent failed with exit code {return_code}"
188
+ if stderr:
189
+ error_msg += f": {stderr}"
190
+ if errors:
191
+ error_msg += f"\nErrors: {[e['error'] for e in errors]}"
192
+ raise RuntimeError(error_msg)
193
+
194
+ if errors and final_result is None:
195
+ # Errors occurred and no result was returned
196
+ error_details = errors[-1] # Use most recent error
197
+ raise RuntimeError(
198
+ f"Subagent error at step {error_details.get('step', 'unknown')}: {error_details['error']}"
199
+ )
200
+
201
+ if final_result is None:
202
+ raise RuntimeError("Subagent did not return a result")
203
+
204
+ return final_result
205
+
206
+ finally:
207
+ # Restore progress to parent agent state
208
+ if ui_handler:
209
+ ui_handler.update_progress("Agent running...")
210
+
211
+
212
+ def _show_progress(message: str):
213
+ """Show subagent progress in parent UI."""
214
+ from ..events import InfoEvent
215
+ from ..ui_context import get_ui_handler
216
+
217
+ ui = get_ui_handler()
218
+ if ui:
219
+ ui.handle_event(InfoEvent(message=f"[Subagent] {message}"))
220
+
221
+
222
+ @tool
223
+ def list_agents() -> str:
224
+ """List all available agents for delegation.
225
+
226
+ Scans standard agent directories and returns information about
227
+ available specialized agents. Use this to discover which agents
228
+ are available for delegation.
229
+
230
+ Returns:
231
+ Formatted list of available agents with their descriptions.
232
+ Returns empty string if no agents are found.
233
+ """
234
+ from ..agent_inheritance import get_builtin_agents_path, get_global_agents_paths
235
+ from ..agent_runner import get_current_agent
236
+
237
+ agents_info: List[Dict[str, str]] = []
238
+ seen_names = set()
239
+
240
+ # Get current agent name to exclude it from the list
241
+ current_agent_name = get_current_agent()
242
+
243
+ # Define search paths in priority order
244
+ search_paths = [
245
+ Path.cwd() / ".tsugite" / "agents",
246
+ Path.cwd() / "agents",
247
+ ]
248
+
249
+ # Add built-in agents directory
250
+ builtin_path = get_builtin_agents_path()
251
+ search_paths.append(builtin_path)
252
+
253
+ # Add global paths
254
+ search_paths.extend(get_global_agents_paths())
255
+
256
+ # Scan each directory for agent files
257
+ for search_dir in search_paths:
258
+ if not search_dir.exists() or not search_dir.is_dir():
259
+ continue
260
+
261
+ is_builtin_dir = search_dir == builtin_path
262
+
263
+ for agent_file in search_dir.glob("*.md"):
264
+ # Skip if we've already seen this agent name (higher priority paths win)
265
+ if agent_file.stem in seen_names:
266
+ continue
267
+
268
+ try:
269
+ content = agent_file.read_text(encoding="utf-8")
270
+ frontmatter, _ = parse_yaml_frontmatter(content, str(agent_file))
271
+
272
+ name = frontmatter.get("name", agent_file.stem)
273
+ description = frontmatter.get("description", "No description")
274
+
275
+ # Skip the currently running agent to prevent self-spawning
276
+ if current_agent_name and name == current_agent_name:
277
+ continue
278
+
279
+ # Store relative path from cwd if possible, otherwise use name for built-ins
280
+ if is_builtin_dir:
281
+ display_path = name
282
+ else:
283
+ try:
284
+ display_path = str(agent_file.relative_to(Path.cwd()))
285
+ except ValueError:
286
+ display_path = str(agent_file)
287
+
288
+ # Add marker for built-in agents
289
+ description_with_marker = f"{description} (built-in)" if is_builtin_dir else description
290
+
291
+ agents_info.append(
292
+ {
293
+ "name": name,
294
+ "description": description_with_marker,
295
+ "path": display_path,
296
+ }
297
+ )
298
+
299
+ seen_names.add(agent_file.stem)
300
+ except Exception:
301
+ # Skip files that can't be parsed
302
+ continue
303
+
304
+ if not agents_info:
305
+ return ""
306
+
307
+ # Format as a simple markdown list
308
+ lines = []
309
+ for agent in agents_info:
310
+ lines.append(f"- **{agent['name']}** (`{agent['path']}`): {agent['description']}")
311
+
312
+ return "\n".join(lines)