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
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}"