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
tsugite/md_agents.py
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""Agent markdown parser and template renderer."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from .utils import parse_yaml_frontmatter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_directive_attribute(
|
|
14
|
+
args: str,
|
|
15
|
+
attr_name: str,
|
|
16
|
+
value_pattern: str = r"([^\"']+)",
|
|
17
|
+
converter: Optional[Callable[[str], Any]] = None,
|
|
18
|
+
default: Any = None,
|
|
19
|
+
) -> Any:
|
|
20
|
+
"""Parse an attribute from directive arguments using regex.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
args: The directive arguments string
|
|
24
|
+
attr_name: Name of the attribute to extract
|
|
25
|
+
value_pattern: Regex pattern for the value (default: any non-quote chars)
|
|
26
|
+
converter: Optional function to convert the string value (e.g., int, float)
|
|
27
|
+
default: Default value if attribute not found
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Parsed and converted value, or default if not found
|
|
31
|
+
"""
|
|
32
|
+
pattern = rf'{attr_name}=(["\']?)({value_pattern})\1'
|
|
33
|
+
match = re.search(pattern, args)
|
|
34
|
+
if not match:
|
|
35
|
+
return default
|
|
36
|
+
|
|
37
|
+
value = match.group(2)
|
|
38
|
+
if converter:
|
|
39
|
+
return converter(value)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentConfig(BaseModel):
|
|
44
|
+
"""Agent configuration from YAML frontmatter."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(
|
|
47
|
+
extra="forbid", # Reject unknown fields to catch typos in YAML
|
|
48
|
+
str_strip_whitespace=True, # Auto-strip whitespace from strings
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
description: str = ""
|
|
53
|
+
model: Optional[str] = None
|
|
54
|
+
max_turns: int = 5
|
|
55
|
+
tools: List[str] = Field(default_factory=list)
|
|
56
|
+
prefetch: List[Dict[str, Any]] = Field(default_factory=list)
|
|
57
|
+
attachments: List[str] = Field(default_factory=list)
|
|
58
|
+
permissions_profile: str = "default"
|
|
59
|
+
context_budget: Dict[str, Any] = Field(default_factory=lambda: {"tokens": 8000, "priority": ["system", "task"]})
|
|
60
|
+
instructions: str = ""
|
|
61
|
+
mcp_servers: Dict[str, Optional[List[str]]] = Field(default_factory=dict)
|
|
62
|
+
extends: Optional[str] = None
|
|
63
|
+
reasoning_effort: Optional[str] = None # For reasoning models (low, medium, high)
|
|
64
|
+
custom_tools: List[Dict[str, Any]] = Field(default_factory=list) # Per-agent shell tools
|
|
65
|
+
text_mode: bool = False # Allow text-only responses (code blocks optional)
|
|
66
|
+
initial_tasks: List[Union[str, Dict[str, Any]]] = Field(
|
|
67
|
+
default_factory=list
|
|
68
|
+
) # Tasks to pre-populate (strings or dicts)
|
|
69
|
+
disable_history: bool = False # Disable conversation history persistence for this agent
|
|
70
|
+
auto_context: Optional[bool] = None # Auto-load context files (None = use config default)
|
|
71
|
+
|
|
72
|
+
@field_validator("initial_tasks", mode="after")
|
|
73
|
+
@classmethod
|
|
74
|
+
def normalize_initial_tasks(cls, v: List[Union[str, Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
75
|
+
"""Normalize initial_tasks: convert strings to dicts with defaults."""
|
|
76
|
+
normalized_tasks = []
|
|
77
|
+
for task in v:
|
|
78
|
+
if isinstance(task, str):
|
|
79
|
+
# Simple string format: convert to dict with defaults
|
|
80
|
+
normalized_tasks.append({"title": task, "status": "pending", "optional": False})
|
|
81
|
+
elif isinstance(task, dict):
|
|
82
|
+
# Dict format: ensure all required fields exist with defaults
|
|
83
|
+
normalized = {
|
|
84
|
+
"title": task.get("title", ""),
|
|
85
|
+
"status": task.get("status", "pending"),
|
|
86
|
+
"optional": task.get("optional", False),
|
|
87
|
+
}
|
|
88
|
+
normalized_tasks.append(normalized)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"Invalid initial_tasks entry: {task}. Must be string or dict.")
|
|
91
|
+
|
|
92
|
+
return normalized_tasks
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Agent:
|
|
97
|
+
"""Parsed agent with config and content."""
|
|
98
|
+
|
|
99
|
+
config: AgentConfig
|
|
100
|
+
content: str
|
|
101
|
+
file_path: Path
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def name(self) -> str:
|
|
105
|
+
return self.config.name
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_agent(text: str, file_path: Optional[Path] = None) -> Agent:
|
|
109
|
+
"""Parse agent markdown text with YAML frontmatter."""
|
|
110
|
+
# Parse YAML frontmatter
|
|
111
|
+
frontmatter, markdown_content = parse_yaml_frontmatter(text, "Agent text")
|
|
112
|
+
|
|
113
|
+
# Create config
|
|
114
|
+
config = AgentConfig(**frontmatter)
|
|
115
|
+
|
|
116
|
+
return Agent(config=config, content=markdown_content, file_path=file_path or Path(""))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_agent_file(file_path: Path) -> Agent:
|
|
120
|
+
"""Parse an agent markdown file with YAML frontmatter.
|
|
121
|
+
|
|
122
|
+
This function also resolves agent inheritance if configured.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
file_path: Path to agent markdown file
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Parsed Agent with resolved inheritance
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
FileNotFoundError: If agent file doesn't exist
|
|
132
|
+
ValueError: If circular inheritance or missing parent agent
|
|
133
|
+
"""
|
|
134
|
+
if not file_path.exists():
|
|
135
|
+
raise FileNotFoundError(f"Agent file not found: {file_path}")
|
|
136
|
+
|
|
137
|
+
content = file_path.read_text(encoding="utf-8")
|
|
138
|
+
agent = parse_agent(content, file_path)
|
|
139
|
+
|
|
140
|
+
# Resolve inheritance if needed
|
|
141
|
+
if agent.config.extends != "none":
|
|
142
|
+
from .agent_inheritance import resolve_agent_inheritance
|
|
143
|
+
|
|
144
|
+
agent = resolve_agent_inheritance(agent, file_path)
|
|
145
|
+
|
|
146
|
+
return agent
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def extract_directives(content: str) -> List[Dict[str, Any]]:
|
|
150
|
+
"""Extract tsugite directives from markdown content."""
|
|
151
|
+
directives = []
|
|
152
|
+
|
|
153
|
+
# Pattern to match <!-- tsu:directive ... -->
|
|
154
|
+
pattern = r"<!--\s*tsu:(\w+)\s+([^>]+)\s*-->"
|
|
155
|
+
|
|
156
|
+
for match in re.finditer(pattern, content):
|
|
157
|
+
directive_type = match.group(1)
|
|
158
|
+
directive_args = match.group(2).strip()
|
|
159
|
+
|
|
160
|
+
# Parse directive arguments (simplified for now)
|
|
161
|
+
directive = {
|
|
162
|
+
"type": directive_type,
|
|
163
|
+
"raw_args": directive_args,
|
|
164
|
+
"position": match.span(),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Basic parsing for common patterns
|
|
168
|
+
if "name=" in directive_args and "args=" in directive_args:
|
|
169
|
+
# Extract tool name and args
|
|
170
|
+
name_match = re.search(r'name=(["\']?)(\w+)\1', directive_args)
|
|
171
|
+
if name_match:
|
|
172
|
+
directive["name"] = name_match.group(2)
|
|
173
|
+
|
|
174
|
+
# Extract assign parameter
|
|
175
|
+
assign_match = re.search(r'assign=(["\']?)(\w+)\1', directive_args)
|
|
176
|
+
if assign_match:
|
|
177
|
+
directive["assign"] = assign_match.group(2)
|
|
178
|
+
|
|
179
|
+
directives.append(directive)
|
|
180
|
+
|
|
181
|
+
return directives
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class ToolDirective:
|
|
186
|
+
"""Represents a tool directive in agent content."""
|
|
187
|
+
|
|
188
|
+
name: str
|
|
189
|
+
args: Dict[str, Any]
|
|
190
|
+
assign_var: str
|
|
191
|
+
start_pos: int
|
|
192
|
+
end_pos: int
|
|
193
|
+
raw_match: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def extract_tool_directives(content: str) -> List[ToolDirective]:
|
|
197
|
+
"""Extract <!-- tsu:tool --> directives from content.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
content: Raw markdown content
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of ToolDirective objects with parsed name, args, and assignment
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
>>> content = '''
|
|
207
|
+
... <!-- tsu:tool name="fetch_json" args={"url": "http://example.com"} assign="data" -->
|
|
208
|
+
... '''
|
|
209
|
+
>>> directives = extract_tool_directives(content)
|
|
210
|
+
>>> directives[0].name
|
|
211
|
+
'fetch_json'
|
|
212
|
+
>>> directives[0].args
|
|
213
|
+
{'url': 'http://example.com'}
|
|
214
|
+
"""
|
|
215
|
+
directives = []
|
|
216
|
+
pattern = r"<!--\s*tsu:tool\s+([^>]+?)\s*-->"
|
|
217
|
+
|
|
218
|
+
for match in re.finditer(pattern, content):
|
|
219
|
+
raw_args = match.group(1).strip()
|
|
220
|
+
start_pos = match.start()
|
|
221
|
+
end_pos = match.end()
|
|
222
|
+
raw_match = match.group(0)
|
|
223
|
+
|
|
224
|
+
# Extract name (required)
|
|
225
|
+
name_match = re.search(r'name=(["\']?)(\w+)\1', raw_args)
|
|
226
|
+
if not name_match:
|
|
227
|
+
raise ValueError(f"Tool directive missing required 'name' attribute: {raw_args}")
|
|
228
|
+
tool_name = name_match.group(2)
|
|
229
|
+
|
|
230
|
+
# Extract and parse JSON args (required)
|
|
231
|
+
args_dict = extract_and_parse_json_args(raw_args, tool_name)
|
|
232
|
+
|
|
233
|
+
# Extract assign (required)
|
|
234
|
+
assign_match = re.search(r'assign=(["\']?)(\w+)\1', raw_args)
|
|
235
|
+
if not assign_match:
|
|
236
|
+
raise ValueError(f"Tool directive missing required 'assign' attribute: {raw_args}")
|
|
237
|
+
assign_var = assign_match.group(2)
|
|
238
|
+
|
|
239
|
+
directives.append(
|
|
240
|
+
ToolDirective(
|
|
241
|
+
name=tool_name,
|
|
242
|
+
args=args_dict,
|
|
243
|
+
assign_var=assign_var,
|
|
244
|
+
start_pos=start_pos,
|
|
245
|
+
end_pos=end_pos,
|
|
246
|
+
raw_match=raw_match,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return directives
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class StepDirective:
|
|
255
|
+
"""Represents a step in multi-step agent execution."""
|
|
256
|
+
|
|
257
|
+
name: str
|
|
258
|
+
content: str
|
|
259
|
+
assign_var: Optional[str] = None
|
|
260
|
+
model_kwargs: Dict[str, Any] = field(default_factory=dict)
|
|
261
|
+
max_retries: int = 0
|
|
262
|
+
retry_delay: float = 0.0
|
|
263
|
+
continue_on_error: bool = False
|
|
264
|
+
timeout: Optional[int] = None # Timeout in seconds, None = no timeout
|
|
265
|
+
start_pos: int = 0
|
|
266
|
+
end_pos: int = 0
|
|
267
|
+
|
|
268
|
+
# Loop control
|
|
269
|
+
repeat_while: Optional[str] = None # Jinja2 expression or helper name to continue repeating
|
|
270
|
+
repeat_until: Optional[str] = None # Jinja2 expression or helper name to stop repeating
|
|
271
|
+
max_iterations: int = 10 # Maximum loop iterations (safety valve)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def extract_step_directives(content: str, include_preamble: bool = True) -> tuple[str, List[StepDirective]]:
|
|
275
|
+
"""Extract step directives and preamble from markdown content.
|
|
276
|
+
|
|
277
|
+
Steps are marked with <!-- tsu:step name="..." assign="..." --> comments.
|
|
278
|
+
Each step's content runs from the directive to the next step (or EOF).
|
|
279
|
+
Content before the first step is the preamble and can be prepended to all steps.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
content: Raw markdown content
|
|
283
|
+
include_preamble: If True, prepend preamble to each step's content
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Tuple of (preamble, list of StepDirective objects with parsed attributes and content)
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
>>> content = '''
|
|
290
|
+
... Header content
|
|
291
|
+
... <!-- tsu:step name="research" assign="data" -->
|
|
292
|
+
... Do research here
|
|
293
|
+
... <!-- tsu:step name="write" -->
|
|
294
|
+
... Write content
|
|
295
|
+
... '''
|
|
296
|
+
>>> preamble, steps = extract_step_directives(content)
|
|
297
|
+
>>> len(steps)
|
|
298
|
+
2
|
|
299
|
+
>>> 'Header content' in preamble
|
|
300
|
+
True
|
|
301
|
+
"""
|
|
302
|
+
steps = []
|
|
303
|
+
pattern = r"<!--\s*tsu:step\s+([^>]+?)\s*-->"
|
|
304
|
+
matches = list(re.finditer(pattern, content))
|
|
305
|
+
|
|
306
|
+
# Extract preamble (content before first step)
|
|
307
|
+
preamble = ""
|
|
308
|
+
if matches:
|
|
309
|
+
preamble = content[: matches[0].start()].strip()
|
|
310
|
+
|
|
311
|
+
for i, match in enumerate(matches):
|
|
312
|
+
args = match.group(1).strip()
|
|
313
|
+
start_pos = match.end()
|
|
314
|
+
|
|
315
|
+
# Determine end position (next step or EOF)
|
|
316
|
+
end_pos = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
|
317
|
+
|
|
318
|
+
# Extract step content
|
|
319
|
+
step_content = content[start_pos:end_pos].strip()
|
|
320
|
+
|
|
321
|
+
# Prepend preamble if requested
|
|
322
|
+
if include_preamble and preamble:
|
|
323
|
+
step_content = f"{preamble}\n\n{step_content}"
|
|
324
|
+
|
|
325
|
+
# Parse name attribute (required)
|
|
326
|
+
name_match = re.search(r'name=(["\']?)(\w+)\1', args)
|
|
327
|
+
if not name_match:
|
|
328
|
+
raise ValueError(f"Step directive missing required 'name' attribute: {args}")
|
|
329
|
+
step_name = name_match.group(2)
|
|
330
|
+
|
|
331
|
+
# Parse optional attributes
|
|
332
|
+
assign_var = _parse_directive_attribute(args, "assign", r"\w+")
|
|
333
|
+
timeout = _parse_directive_attribute(args, "timeout", r"[0-9]+", int)
|
|
334
|
+
|
|
335
|
+
# Use helpers for complex parsing
|
|
336
|
+
model_kwargs = parse_model_kwargs_from_args(args, step_name)
|
|
337
|
+
max_retries, retry_delay, continue_on_error = parse_retry_params_from_args(args)
|
|
338
|
+
repeat_while, repeat_until, max_iterations = parse_loop_params_from_args(args, step_name)
|
|
339
|
+
|
|
340
|
+
steps.append(
|
|
341
|
+
StepDirective(
|
|
342
|
+
name=step_name,
|
|
343
|
+
content=step_content,
|
|
344
|
+
assign_var=assign_var,
|
|
345
|
+
model_kwargs=model_kwargs,
|
|
346
|
+
max_retries=max_retries,
|
|
347
|
+
retry_delay=retry_delay,
|
|
348
|
+
continue_on_error=continue_on_error,
|
|
349
|
+
timeout=timeout,
|
|
350
|
+
repeat_while=repeat_while,
|
|
351
|
+
repeat_until=repeat_until,
|
|
352
|
+
max_iterations=max_iterations,
|
|
353
|
+
start_pos=start_pos,
|
|
354
|
+
end_pos=end_pos,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return preamble, steps
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def has_step_directives(content: str) -> bool:
|
|
362
|
+
"""Check if markdown content contains step directives.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
content: Raw markdown content
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
True if content has at least one step directive
|
|
369
|
+
"""
|
|
370
|
+
pattern = r"<!--\s*tsu:step\s+"
|
|
371
|
+
return bool(re.search(pattern, content))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def validate_agent(agent: Agent) -> List[str]:
|
|
375
|
+
"""Validate agent configuration and content."""
|
|
376
|
+
errors = []
|
|
377
|
+
|
|
378
|
+
# Validate required fields
|
|
379
|
+
if not agent.config.name:
|
|
380
|
+
errors.append("Agent name is required")
|
|
381
|
+
|
|
382
|
+
# Model is now optional - it will be loaded from config if not specified
|
|
383
|
+
|
|
384
|
+
# Validate model format if specified
|
|
385
|
+
if agent.config.model and not _is_valid_model_format(agent.config.model):
|
|
386
|
+
errors.append(f"Invalid model format: {agent.config.model}")
|
|
387
|
+
|
|
388
|
+
# Validate tools exist
|
|
389
|
+
for tool in agent.config.tools:
|
|
390
|
+
if not isinstance(tool, str):
|
|
391
|
+
errors.append(f"Tool name must be string: {tool}")
|
|
392
|
+
|
|
393
|
+
# Validate max_turns is positive
|
|
394
|
+
if agent.config.max_turns <= 0:
|
|
395
|
+
errors.append("max_turns must be positive")
|
|
396
|
+
|
|
397
|
+
# Validate directives in content
|
|
398
|
+
directives = extract_directives(agent.content)
|
|
399
|
+
for directive in directives:
|
|
400
|
+
if directive["type"] == "tool" and "name" not in directive:
|
|
401
|
+
errors.append(f"Tool directive missing name: {directive['raw_args']}")
|
|
402
|
+
|
|
403
|
+
return errors
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_valid_model_format(model: str) -> bool:
|
|
407
|
+
"""Check if model string follows expected format."""
|
|
408
|
+
if ":" not in model:
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
provider, model_name = model.split(":", 1)
|
|
412
|
+
return bool(provider.strip() and model_name.strip())
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def validate_agent_execution(agent: Agent | Path) -> tuple[bool, str]:
|
|
416
|
+
"""Validate that an agent can be executed.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
agent: Parsed agent or path to agent file
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Tuple of (is_valid, error_message)
|
|
423
|
+
"""
|
|
424
|
+
# Handle both Path objects and Agent objects
|
|
425
|
+
if isinstance(agent, Path):
|
|
426
|
+
try:
|
|
427
|
+
agent_text = agent.read_text()
|
|
428
|
+
agent = parse_agent(agent_text, agent)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
return False, f"Failed to parse agent file: {e}"
|
|
431
|
+
|
|
432
|
+
# Basic validation
|
|
433
|
+
errors = validate_agent(agent)
|
|
434
|
+
if errors:
|
|
435
|
+
return False, "; ".join(errors)
|
|
436
|
+
|
|
437
|
+
# Validate model if specified
|
|
438
|
+
if agent.config.model:
|
|
439
|
+
is_valid, error = validate_model_string(agent.config.model)
|
|
440
|
+
if not is_valid:
|
|
441
|
+
return False, error
|
|
442
|
+
|
|
443
|
+
# Validate tool syntax
|
|
444
|
+
if agent.config.tools:
|
|
445
|
+
is_valid, error = validate_tool_specs(agent.config.tools)
|
|
446
|
+
if not is_valid:
|
|
447
|
+
return False, error
|
|
448
|
+
|
|
449
|
+
# Check template rendering with minimal context
|
|
450
|
+
from .renderer import AgentRenderer
|
|
451
|
+
|
|
452
|
+
renderer = AgentRenderer()
|
|
453
|
+
try:
|
|
454
|
+
test_context = build_validation_test_context(agent)
|
|
455
|
+
add_mock_step_variables(test_context, agent.content)
|
|
456
|
+
add_mock_tool_variables(test_context, agent.content)
|
|
457
|
+
renderer.render(agent.content, test_context)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
return False, f"Template validation failed: {e}"
|
|
460
|
+
|
|
461
|
+
return True, "Agent is valid"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def parse_model_kwargs_from_args(args: str, step_name: str) -> dict[str, Any]:
|
|
465
|
+
"""Parse model kwargs from step directive arguments.
|
|
466
|
+
|
|
467
|
+
Extracts temperature, max_tokens, top_p, penalties, response_format,
|
|
468
|
+
and reasoning_effort from directive args.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
args: Raw directive arguments string
|
|
472
|
+
step_name: Name of step (for error messages)
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Dict of model kwargs to pass to LLM
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
ValueError: If JSON parsing fails for response_format
|
|
479
|
+
"""
|
|
480
|
+
import json
|
|
481
|
+
|
|
482
|
+
model_kwargs = {}
|
|
483
|
+
|
|
484
|
+
# Check for json shorthand
|
|
485
|
+
json_match = re.search(r'json=(["\']?)(true|false)\1', args)
|
|
486
|
+
if json_match and json_match.group(2) == "true":
|
|
487
|
+
model_kwargs["response_format"] = {"type": "json_object"}
|
|
488
|
+
|
|
489
|
+
# Check for response_format (overrides json shorthand)
|
|
490
|
+
rf_match = re.search(r'response_format=(["\'])(.+?)\1', args)
|
|
491
|
+
if rf_match:
|
|
492
|
+
try:
|
|
493
|
+
rf_value = rf_match.group(2)
|
|
494
|
+
model_kwargs["response_format"] = json.loads(rf_value)
|
|
495
|
+
except json.JSONDecodeError as e:
|
|
496
|
+
raise ValueError(f"Invalid JSON in response_format for step '{step_name}': {e}") from e
|
|
497
|
+
|
|
498
|
+
# Parse numeric parameters
|
|
499
|
+
temperature = _parse_directive_attribute(args, "temperature", r"[0-9.]+", float)
|
|
500
|
+
if temperature is not None:
|
|
501
|
+
model_kwargs["temperature"] = temperature
|
|
502
|
+
|
|
503
|
+
max_tokens = _parse_directive_attribute(args, "max_tokens", r"[0-9]+", int)
|
|
504
|
+
if max_tokens is not None:
|
|
505
|
+
model_kwargs["max_tokens"] = max_tokens
|
|
506
|
+
|
|
507
|
+
top_p = _parse_directive_attribute(args, "top_p", r"[0-9.]+", float)
|
|
508
|
+
if top_p is not None:
|
|
509
|
+
model_kwargs["top_p"] = top_p
|
|
510
|
+
|
|
511
|
+
freq_penalty = _parse_directive_attribute(args, "frequency_penalty", r"[0-9.]+", float)
|
|
512
|
+
if freq_penalty is not None:
|
|
513
|
+
model_kwargs["frequency_penalty"] = freq_penalty
|
|
514
|
+
|
|
515
|
+
pres_penalty = _parse_directive_attribute(args, "presence_penalty", r"[0-9.]+", float)
|
|
516
|
+
if pres_penalty is not None:
|
|
517
|
+
model_kwargs["presence_penalty"] = pres_penalty
|
|
518
|
+
|
|
519
|
+
# Parse reasoning_effort (for o1, o3, Claude extended thinking)
|
|
520
|
+
reasoning_effort = _parse_directive_attribute(args, "reasoning_effort", r"low|medium|high")
|
|
521
|
+
if reasoning_effort:
|
|
522
|
+
model_kwargs["reasoning_effort"] = reasoning_effort
|
|
523
|
+
|
|
524
|
+
return model_kwargs
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def parse_retry_params_from_args(args: str) -> tuple[int, float, bool]:
|
|
528
|
+
"""Parse retry parameters from step directive arguments.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
args: Raw directive arguments string
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Tuple of (max_retries, retry_delay, continue_on_error)
|
|
535
|
+
"""
|
|
536
|
+
max_retries = _parse_directive_attribute(args, "max_retries", r"[0-9]+", int, default=0)
|
|
537
|
+
retry_delay = _parse_directive_attribute(args, "retry_delay", r"[0-9.]+", float, default=0.0)
|
|
538
|
+
continue_str = _parse_directive_attribute(args, "continue_on_error", r"true|false", default="false")
|
|
539
|
+
continue_on_error = continue_str.lower() == "true"
|
|
540
|
+
|
|
541
|
+
return max_retries, retry_delay, continue_on_error
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def parse_loop_params_from_args(args: str, step_name: str) -> tuple[Optional[str], Optional[str], int]:
|
|
545
|
+
"""Parse loop control parameters from step directive arguments.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
args: Raw directive arguments string
|
|
549
|
+
step_name: Name of step (for error messages)
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Tuple of (repeat_while, repeat_until, max_iterations)
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
ValueError: If both repeat_while and repeat_until are specified
|
|
556
|
+
"""
|
|
557
|
+
repeat_while = _parse_directive_attribute(args, "repeat_while", r".+?")
|
|
558
|
+
repeat_until = _parse_directive_attribute(args, "repeat_until", r".+?")
|
|
559
|
+
max_iterations = _parse_directive_attribute(args, "max_iterations", r"[0-9]+", int, default=10)
|
|
560
|
+
|
|
561
|
+
# Validate: cannot specify both
|
|
562
|
+
if repeat_while and repeat_until:
|
|
563
|
+
raise ValueError(f"Step '{step_name}' cannot specify both repeat_while and repeat_until. Use one or the other.")
|
|
564
|
+
|
|
565
|
+
return repeat_while, repeat_until, max_iterations
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def find_json_object_in_string(text: str, start_keyword: str) -> tuple[int, int]:
|
|
569
|
+
"""Find a JSON object in a string starting after a keyword.
|
|
570
|
+
|
|
571
|
+
Handles nested braces correctly.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
text: Text to search in
|
|
575
|
+
start_keyword: Keyword before JSON (e.g., "args=")
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Tuple of (json_start_pos, json_end_pos)
|
|
579
|
+
|
|
580
|
+
Raises:
|
|
581
|
+
ValueError: If JSON object not found or has unmatched braces
|
|
582
|
+
"""
|
|
583
|
+
keyword_pos = text.find(start_keyword)
|
|
584
|
+
if keyword_pos == -1:
|
|
585
|
+
raise ValueError(f"Keyword '{start_keyword}' not found in text")
|
|
586
|
+
|
|
587
|
+
# Find the opening brace after keyword
|
|
588
|
+
json_start = text.find("{", keyword_pos)
|
|
589
|
+
if json_start == -1:
|
|
590
|
+
raise ValueError(f"No JSON object found after '{start_keyword}'")
|
|
591
|
+
|
|
592
|
+
# Find matching closing brace (handle nested braces)
|
|
593
|
+
brace_count = 0
|
|
594
|
+
json_end = json_start
|
|
595
|
+
for i in range(json_start, len(text)):
|
|
596
|
+
if text[i] == "{":
|
|
597
|
+
brace_count += 1
|
|
598
|
+
elif text[i] == "}":
|
|
599
|
+
brace_count -= 1
|
|
600
|
+
if brace_count == 0:
|
|
601
|
+
json_end = i + 1
|
|
602
|
+
break
|
|
603
|
+
|
|
604
|
+
if brace_count != 0:
|
|
605
|
+
raise ValueError(f"Unmatched braces in JSON object after '{start_keyword}'")
|
|
606
|
+
|
|
607
|
+
return json_start, json_end
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def extract_and_parse_json_args(raw_args: str, tool_name: str) -> dict[str, Any]:
|
|
611
|
+
"""Extract and parse JSON args from tool directive arguments.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
raw_args: Raw directive arguments string
|
|
615
|
+
tool_name: Name of tool (for error messages)
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Parsed args dict
|
|
619
|
+
|
|
620
|
+
Raises:
|
|
621
|
+
ValueError: If args missing, invalid JSON, or has syntax errors
|
|
622
|
+
"""
|
|
623
|
+
import json
|
|
624
|
+
|
|
625
|
+
# Find args= and extract JSON object
|
|
626
|
+
args_start = raw_args.find("args=")
|
|
627
|
+
if args_start == -1:
|
|
628
|
+
raise ValueError(f"Tool directive missing required 'args' attribute: {raw_args}")
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
json_start, json_end = find_json_object_in_string(raw_args, "args=")
|
|
632
|
+
except ValueError as e:
|
|
633
|
+
raise ValueError(f"Tool directive args must be a JSON object: {raw_args}") from e
|
|
634
|
+
|
|
635
|
+
# Parse JSON args
|
|
636
|
+
try:
|
|
637
|
+
args_json_str = raw_args[json_start:json_end]
|
|
638
|
+
return json.loads(args_json_str)
|
|
639
|
+
except json.JSONDecodeError as e:
|
|
640
|
+
raise ValueError(f"Invalid JSON in tool directive args for '{tool_name}': {e}") from e
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def build_validation_test_context(agent, include_prefetch: bool = True) -> dict[str, Any]:
|
|
644
|
+
"""Build minimal test context for agent validation.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
agent: Agent object to build context for
|
|
648
|
+
include_prefetch: Whether to include mock prefetch variables
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Dict of context variables for template rendering
|
|
652
|
+
"""
|
|
653
|
+
test_context = {
|
|
654
|
+
"user_prompt": "test",
|
|
655
|
+
"task_summary": "## Current Tasks\nNo tasks yet.",
|
|
656
|
+
"is_interactive": False,
|
|
657
|
+
"tools": agent.config.tools or [],
|
|
658
|
+
"text_mode": agent.config.text_mode,
|
|
659
|
+
"is_subagent": False,
|
|
660
|
+
"parent_agent": None,
|
|
661
|
+
"chat_history": [],
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# Add mock prefetch variables
|
|
665
|
+
if include_prefetch and agent.config.prefetch:
|
|
666
|
+
for item in agent.config.prefetch:
|
|
667
|
+
assign_name = item.get("assign")
|
|
668
|
+
if assign_name:
|
|
669
|
+
test_context[assign_name] = "mock_prefetch_data"
|
|
670
|
+
|
|
671
|
+
return test_context
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def add_mock_step_variables(test_context: dict[str, Any], agent_content: str) -> None:
|
|
675
|
+
"""Add mock variables for step assignments to test context.
|
|
676
|
+
|
|
677
|
+
Modifies test_context in place.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
test_context: Context dict to modify
|
|
681
|
+
agent_content: Agent content to extract steps from
|
|
682
|
+
"""
|
|
683
|
+
if has_step_directives(agent_content):
|
|
684
|
+
try:
|
|
685
|
+
preamble, steps = extract_step_directives(agent_content)
|
|
686
|
+
for step in steps:
|
|
687
|
+
if step.assign_var:
|
|
688
|
+
test_context[step.assign_var] = "mock_step_result"
|
|
689
|
+
except Exception:
|
|
690
|
+
# If step parsing fails, let it be caught by normal validation
|
|
691
|
+
pass
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def add_mock_tool_variables(test_context: dict[str, Any], agent_content: str) -> None:
|
|
695
|
+
"""Add mock variables for tool directive assignments to test context.
|
|
696
|
+
|
|
697
|
+
Modifies test_context in place.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
test_context: Context dict to modify
|
|
701
|
+
agent_content: Agent content to extract tool directives from
|
|
702
|
+
"""
|
|
703
|
+
try:
|
|
704
|
+
directives = extract_tool_directives(agent_content)
|
|
705
|
+
for directive in directives:
|
|
706
|
+
if directive.assign_var:
|
|
707
|
+
test_context[directive.assign_var] = "mock_tool_result"
|
|
708
|
+
except Exception:
|
|
709
|
+
# If tool directive parsing fails, let it be caught by normal validation
|
|
710
|
+
pass
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def validate_model_string(model: str) -> tuple[bool, Optional[str]]:
|
|
714
|
+
"""Validate model string format.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
model: Model string to validate (e.g., "openai:gpt-4")
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
Tuple of (is_valid, error_message). Error is None if valid.
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
from .models import parse_model_string
|
|
724
|
+
|
|
725
|
+
parse_model_string(model)
|
|
726
|
+
return True, None
|
|
727
|
+
except Exception as e:
|
|
728
|
+
return False, f"Model validation failed: {e}"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def validate_tool_specs(tool_specs: list[str]) -> tuple[bool, Optional[str]]:
|
|
732
|
+
"""Validate tool specifications are syntactically correct.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
tool_specs: List of tool specification strings
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Tuple of (is_valid, error_message). Error is None if valid.
|
|
739
|
+
"""
|
|
740
|
+
try:
|
|
741
|
+
# Import to ensure module can be loaded
|
|
742
|
+
from .tools import expand_tool_specs # noqa: F401
|
|
743
|
+
|
|
744
|
+
# Check syntax is valid
|
|
745
|
+
for tool_spec in tool_specs:
|
|
746
|
+
if not isinstance(tool_spec, str):
|
|
747
|
+
return False, f"Tool specification must be string: {tool_spec}"
|
|
748
|
+
|
|
749
|
+
return True, None
|
|
750
|
+
except Exception as e:
|
|
751
|
+
return False, f"Tool import error: {e}"
|