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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- 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()
|
tsugite/tools/agents.py
ADDED
|
@@ -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)
|