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,288 @@
|
|
|
1
|
+
"""Common utilities for benchmark framework."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .config import TEST_CATEGORIES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_inline_field(content: str, label: str) -> Optional[str]:
|
|
11
|
+
"""Extract single-line field values like Prompt or Expected Output.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
content: Markdown content to search
|
|
15
|
+
label: Field label to extract (e.g., "Prompt", "Expected Output")
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Extracted field value or None if not found
|
|
19
|
+
"""
|
|
20
|
+
pattern_label = re.escape(label)
|
|
21
|
+
|
|
22
|
+
# Try quoted format first
|
|
23
|
+
quoted = re.search(rf"\*\*{pattern_label}:\*\*\s*\"([^\"]+)\"", content)
|
|
24
|
+
if quoted:
|
|
25
|
+
return quoted.group(1).strip()
|
|
26
|
+
|
|
27
|
+
# Try block format
|
|
28
|
+
block = re.search(rf"\*\*{pattern_label}:\*\*\s*(.+?)(?=\n\*\*|$)", content, re.DOTALL)
|
|
29
|
+
if block:
|
|
30
|
+
return block.group(1).strip()
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_block(content: str, label: str) -> Optional[str]:
|
|
36
|
+
"""Extract multi-line blocks introduced by a bold label.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content: Markdown content to search
|
|
40
|
+
label: Block label (e.g., "Expected Behaviors")
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Block content or None if not found
|
|
44
|
+
"""
|
|
45
|
+
pattern_label = re.escape(label)
|
|
46
|
+
match = re.search(rf"\*\*{pattern_label}:\*\*\n(.*?)(?=\n\*\*|\Z)", content, re.DOTALL)
|
|
47
|
+
if match:
|
|
48
|
+
return match.group(1).strip()
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_bullet_list(block: Optional[str]) -> List[str]:
|
|
53
|
+
"""Parse a bullet list from a markdown block.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
block: Block containing bullet list
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of bullet items (without leading "- ")
|
|
60
|
+
"""
|
|
61
|
+
if not block:
|
|
62
|
+
return []
|
|
63
|
+
items = []
|
|
64
|
+
for line in block.splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if line.startswith("- "):
|
|
67
|
+
items.append(line[2:].strip())
|
|
68
|
+
return items
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def coerce_value(value: str) -> Any:
|
|
72
|
+
"""Convert string value to appropriate Python type.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
value: String value to convert
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Converted value (bool, None, number, or string)
|
|
79
|
+
"""
|
|
80
|
+
raw = value.strip()
|
|
81
|
+
lowered = raw.lower()
|
|
82
|
+
|
|
83
|
+
# Check for boolean
|
|
84
|
+
if lowered in {"true", "false"}:
|
|
85
|
+
return lowered == "true"
|
|
86
|
+
|
|
87
|
+
# Check for null
|
|
88
|
+
if lowered == "null":
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Try JSON parsing first
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(raw)
|
|
94
|
+
except (json.JSONDecodeError, TypeError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Try numeric conversion
|
|
98
|
+
try:
|
|
99
|
+
if "." in raw:
|
|
100
|
+
return float(raw)
|
|
101
|
+
return int(raw)
|
|
102
|
+
except ValueError:
|
|
103
|
+
# Return as string, removing quotes
|
|
104
|
+
return raw.strip("'\"")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_key_value_block(block: Optional[str]) -> Dict[str, Any]:
|
|
108
|
+
"""Parse a key-value block from markdown.
|
|
109
|
+
|
|
110
|
+
Supports both simple key-value pairs and nested YAML structures:
|
|
111
|
+
- simple_key: value
|
|
112
|
+
- nested_key:
|
|
113
|
+
sub_key: value
|
|
114
|
+
another: value
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
block: Block containing key-value pairs (bullet list format)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dictionary of parsed key-value pairs
|
|
121
|
+
"""
|
|
122
|
+
if not block:
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
result: Dict[str, Any] = {}
|
|
126
|
+
lines = block.splitlines()
|
|
127
|
+
i = 0
|
|
128
|
+
|
|
129
|
+
while i < len(lines):
|
|
130
|
+
line = lines[i]
|
|
131
|
+
stripped = line.strip()
|
|
132
|
+
|
|
133
|
+
# Skip non-bullet lines
|
|
134
|
+
if not stripped.startswith("- ") or ":" not in stripped:
|
|
135
|
+
i += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Extract key from bullet item
|
|
139
|
+
key_part = stripped[2:] # Remove "- "
|
|
140
|
+
key, raw_value = key_part.split(":", 1)
|
|
141
|
+
key = key.strip()
|
|
142
|
+
raw_value = raw_value.strip()
|
|
143
|
+
|
|
144
|
+
# Check if this is a nested structure (value is empty or whitespace)
|
|
145
|
+
if not raw_value:
|
|
146
|
+
# Collect indented lines that follow
|
|
147
|
+
nested_lines = []
|
|
148
|
+
indent_level = None
|
|
149
|
+
i += 1
|
|
150
|
+
|
|
151
|
+
while i < len(lines):
|
|
152
|
+
next_line = lines[i]
|
|
153
|
+
|
|
154
|
+
# Check if line is indented (part of nested structure)
|
|
155
|
+
if next_line and not next_line[0].isspace():
|
|
156
|
+
# Not indented, end of nested structure
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
if next_line.strip(): # Non-empty indented line
|
|
160
|
+
# Determine indent level from first indented line
|
|
161
|
+
if indent_level is None:
|
|
162
|
+
indent_level = len(next_line) - len(next_line.lstrip())
|
|
163
|
+
|
|
164
|
+
# Remove the base indent level to get relative indentation
|
|
165
|
+
if len(next_line) - len(next_line.lstrip()) >= indent_level:
|
|
166
|
+
nested_lines.append(next_line[indent_level:])
|
|
167
|
+
else:
|
|
168
|
+
nested_lines.append(next_line.lstrip())
|
|
169
|
+
|
|
170
|
+
i += 1
|
|
171
|
+
|
|
172
|
+
# Parse nested structure as YAML
|
|
173
|
+
if nested_lines:
|
|
174
|
+
import yaml
|
|
175
|
+
|
|
176
|
+
nested_yaml = "\n".join(nested_lines)
|
|
177
|
+
try:
|
|
178
|
+
result[key] = yaml.safe_load(nested_yaml)
|
|
179
|
+
except yaml.YAMLError:
|
|
180
|
+
# If YAML parsing fails, store as string
|
|
181
|
+
result[key] = nested_yaml
|
|
182
|
+
else:
|
|
183
|
+
result[key] = ""
|
|
184
|
+
else:
|
|
185
|
+
# Simple key-value pair on same line
|
|
186
|
+
result[key] = coerce_value(raw_value)
|
|
187
|
+
i += 1
|
|
188
|
+
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_test_category(test_id: str) -> str:
|
|
193
|
+
"""Extract category from test ID.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
test_id: Test identifier
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Category name (e.g., "basic", "tools", "unknown")
|
|
200
|
+
"""
|
|
201
|
+
return TEST_CATEGORIES.get_category(test_id)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def extract_prompt_from_markdown(markdown_content: str) -> str:
|
|
205
|
+
"""Derive a prompt from markdown content when none is provided explicitly.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
markdown_content: Markdown content
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Extracted prompt or default message
|
|
212
|
+
"""
|
|
213
|
+
for line in markdown_content.splitlines():
|
|
214
|
+
stripped = line.strip()
|
|
215
|
+
# Skip empty lines and headers
|
|
216
|
+
if not stripped or stripped.startswith("#"):
|
|
217
|
+
continue
|
|
218
|
+
return stripped
|
|
219
|
+
return "Describe the required task in detail."
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def normalize_code(code: str) -> str:
|
|
223
|
+
"""Normalize code for comparison by removing comments and extra whitespace.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
code: Source code
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Normalized code
|
|
230
|
+
"""
|
|
231
|
+
lines = []
|
|
232
|
+
for line in code.split("\n"):
|
|
233
|
+
# Remove comments (simplified - handles # and //)
|
|
234
|
+
line = re.sub(r"#.*$", "", line)
|
|
235
|
+
line = re.sub(r"//.*$", "", line)
|
|
236
|
+
line = line.strip()
|
|
237
|
+
if line:
|
|
238
|
+
lines.append(line)
|
|
239
|
+
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def json_similarity(obj1: Any, obj2: Any) -> float:
|
|
244
|
+
"""Calculate similarity between JSON objects recursively.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
obj1: First object
|
|
248
|
+
obj2: Second object
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Similarity score from 0.0 to 1.0
|
|
252
|
+
"""
|
|
253
|
+
# Type mismatch
|
|
254
|
+
if type(obj1) is not type(obj2):
|
|
255
|
+
return 0.0
|
|
256
|
+
|
|
257
|
+
# Dictionary comparison
|
|
258
|
+
if isinstance(obj1, dict):
|
|
259
|
+
if not obj1 and not obj2:
|
|
260
|
+
return 1.0
|
|
261
|
+
|
|
262
|
+
all_keys = set(obj1.keys()) | set(obj2.keys())
|
|
263
|
+
if not all_keys:
|
|
264
|
+
return 1.0
|
|
265
|
+
|
|
266
|
+
key_scores = []
|
|
267
|
+
for key in all_keys:
|
|
268
|
+
if key in obj1 and key in obj2:
|
|
269
|
+
key_scores.append(json_similarity(obj1[key], obj2[key]))
|
|
270
|
+
else:
|
|
271
|
+
key_scores.append(0.0)
|
|
272
|
+
|
|
273
|
+
return sum(key_scores) / len(key_scores)
|
|
274
|
+
|
|
275
|
+
# List comparison
|
|
276
|
+
elif isinstance(obj1, list):
|
|
277
|
+
if len(obj1) != len(obj2):
|
|
278
|
+
return 0.5 if obj1 == obj2 else 0.0
|
|
279
|
+
|
|
280
|
+
if not obj1:
|
|
281
|
+
return 1.0
|
|
282
|
+
|
|
283
|
+
scores = [json_similarity(a, b) for a, b in zip(obj1, obj2)]
|
|
284
|
+
return sum(scores) / len(scores)
|
|
285
|
+
|
|
286
|
+
# Primitive comparison
|
|
287
|
+
else:
|
|
288
|
+
return 1.0 if obj1 == obj2 else 0.0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chat-assistant
|
|
3
|
+
description: A conversational assistant that can respond naturally or use tools when needed
|
|
4
|
+
extends: none
|
|
5
|
+
text_mode: true
|
|
6
|
+
max_turns: 10
|
|
7
|
+
tools:
|
|
8
|
+
- read_file
|
|
9
|
+
- write_file
|
|
10
|
+
- list_files
|
|
11
|
+
- web_search
|
|
12
|
+
- fetch_text
|
|
13
|
+
- run
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
You are a helpful conversational assistant with access to tools.
|
|
17
|
+
|
|
18
|
+
## How to respond:
|
|
19
|
+
|
|
20
|
+
**For simple conversational questions:** Respond directly with just your Thought:
|
|
21
|
+
```
|
|
22
|
+
Thought: Your answer here
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**When you need to use tools or get information:** Write a code block:
|
|
26
|
+
```
|
|
27
|
+
Thought: I'll use [tool] to [action]
|
|
28
|
+
```python
|
|
29
|
+
result = list_files(path=".")
|
|
30
|
+
final_answer(result)
|
|
31
|
+
```
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Available tools you can use:
|
|
35
|
+
|
|
36
|
+
- `list_files(path=".", pattern="*")` - List files in a directory
|
|
37
|
+
- `read_file(path="file.txt")` - Read file contents
|
|
38
|
+
- `write_file(path="file.txt", content="...")` - Write to a file
|
|
39
|
+
- `web_search(query="...", max_results=5)` - Search the web and get a list of results
|
|
40
|
+
- Returns: `[{"title": "...", "url": "...", "snippet": "..."}]`
|
|
41
|
+
- **Important:** Format results nicely for the user! Extract relevant info from snippets and present clearly.
|
|
42
|
+
- Example for weather: Read the snippets and summarize the current conditions/forecast
|
|
43
|
+
- `fetch_text(url="...")` - Fetch full content from a webpage as text
|
|
44
|
+
- Use this when search snippets aren't enough and you need the full page
|
|
45
|
+
- `run(command="...")` - Run shell commands
|
|
46
|
+
|
|
47
|
+
**Important:** When the user asks about files, directories, or anything requiring system information, ALWAYS use the appropriate tool with a code block!
|
|
48
|
+
|
|
49
|
+
**Note:** When continuing a conversation, previous messages are automatically included in your context. You don't need to reference them explicitly - they're part of the conversation history.
|
|
50
|
+
|
|
51
|
+
## Current Request
|
|
52
|
+
|
|
53
|
+
{{ user_prompt }}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: default
|
|
3
|
+
description: Default base agent with sensible defaults
|
|
4
|
+
extends: none
|
|
5
|
+
max_turns: 10
|
|
6
|
+
tools:
|
|
7
|
+
- spawn_agent
|
|
8
|
+
- read_file
|
|
9
|
+
- list_files
|
|
10
|
+
- task_*
|
|
11
|
+
- write_file
|
|
12
|
+
- edit_file
|
|
13
|
+
prefetch:
|
|
14
|
+
- tool: list_agents
|
|
15
|
+
args: {}
|
|
16
|
+
assign: available_agents
|
|
17
|
+
instructions: |
|
|
18
|
+
You are a helpful AI assistant running in the Tsugite agent framework.
|
|
19
|
+
|
|
20
|
+
Follow these guidelines:
|
|
21
|
+
- Be concise and direct in your responses
|
|
22
|
+
- Use available tools when they help accomplish the task
|
|
23
|
+
- Use task tracking tools (task_add, task_update, task_complete) to organize your work
|
|
24
|
+
- Complete all required tasks (optional tasks marked with ✨ are nice-to-have)
|
|
25
|
+
- Break down complex tasks into clear steps
|
|
26
|
+
- Ask clarifying questions when the task is ambiguous
|
|
27
|
+
{% if text_mode %}
|
|
28
|
+
- For simple responses: respond directly with ONLY "Thought: [answer]" - no additional explanation
|
|
29
|
+
- When using tools: write Python code blocks and call final_answer(result)
|
|
30
|
+
{% else %}
|
|
31
|
+
- Write Python code to accomplish tasks
|
|
32
|
+
- Call final_answer(result) when you've completed the task
|
|
33
|
+
{% endif %}
|
|
34
|
+
|
|
35
|
+
**IMPORTANT - Seeing Tool Results:**
|
|
36
|
+
- Tool results are NOT automatically visible to you in the next turn
|
|
37
|
+
- You MUST print() results if you want to see and use them later
|
|
38
|
+
- Example:
|
|
39
|
+
```python
|
|
40
|
+
content = read_file("file.txt")
|
|
41
|
+
print(content) # Now you can see it in your next reasoning turn
|
|
42
|
+
```
|
|
43
|
+
- Or use final_answer() to see and return the result immediately
|
|
44
|
+
---
|
|
45
|
+
# Context
|
|
46
|
+
|
|
47
|
+
{% if is_interactive %}
|
|
48
|
+
**Interactive Mode**: You are currently in an interactive session with the user, you can ask questions to clarify the task.
|
|
49
|
+
{% else %}
|
|
50
|
+
**Non-Interactive Mode**: You are in a headless/non-interactive session. You cannot ask the user questions.
|
|
51
|
+
{% endif %}
|
|
52
|
+
|
|
53
|
+
{% if subagent_instructions is defined and subagent_instructions %}
|
|
54
|
+
{{ subagent_instructions }}
|
|
55
|
+
{% endif %}
|
|
56
|
+
|
|
57
|
+
**Note:** When continuing a conversation, previous messages are automatically included in your context as part of the conversation history. You don't need to reference them explicitly.
|
|
58
|
+
|
|
59
|
+
{% if step_number is defined %}
|
|
60
|
+
|
|
61
|
+
## Multi-Step Execution
|
|
62
|
+
|
|
63
|
+
You are in step {{ step_number }} of {{ total_steps }} ({{ step_name }}).
|
|
64
|
+
|
|
65
|
+
**IMPORTANT Step Completion**:
|
|
66
|
+
|
|
67
|
+
- Complete ONLY the task assigned in this step
|
|
68
|
+
{% if text_mode %}- After completing the task, call final_answer(result) with your result
|
|
69
|
+
{% else %}- After completing the task, write a Python code block with final_answer(result)
|
|
70
|
+
- Example: ```python
|
|
71
|
+
final_answer("step result")
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
{% endif %}- Do NOT generate additional conversational text after calling final_answer()
|
|
75
|
+
- The framework will automatically present the next step - you do not need to ask or wait
|
|
76
|
+
- Each step is independent - focus on this step's goal only
|
|
77
|
+
|
|
78
|
+
{% endif %}
|
|
79
|
+
|
|
80
|
+
{% if available_agents %}
|
|
81
|
+
## Available Specialized Agents
|
|
82
|
+
|
|
83
|
+
You can delegate to these specialized agents when they match the task:
|
|
84
|
+
|
|
85
|
+
{{ available_agents }}
|
|
86
|
+
|
|
87
|
+
To delegate a task, use: `spawn_agent(agent_path, prompt)`
|
|
88
|
+
|
|
89
|
+
Only delegate when:
|
|
90
|
+
1. A specialized agent clearly matches the task requirements
|
|
91
|
+
2. The task would benefit from specialized knowledge or tools
|
|
92
|
+
3. You can provide a clear, specific prompt for the agent
|
|
93
|
+
|
|
94
|
+
**CRITICAL: When a subagent fully completes the task, return its result immediately:**
|
|
95
|
+
```python
|
|
96
|
+
result = spawn_agent("agents/code_review.md", "Review app.py for security issues")
|
|
97
|
+
final_answer(result) # STOP HERE - task is done, return the result
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Example 2: Only process results further if the subagent output needs additional work**
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# Spawn agent and store result
|
|
104
|
+
review = spawn_agent("agents/code_review.md", "Review app.py")
|
|
105
|
+
print(review) # IMPORTANT: Print so you can see it in your next turn!
|
|
106
|
+
# DON'T call final_answer() here - let the agent continue thinking
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Then in the next turn, you can:
|
|
110
|
+
|
|
111
|
+
- Analyze the review results (you'll see them because you printed)
|
|
112
|
+
- Spawn additional agents
|
|
113
|
+
- Combine data from multiple sources
|
|
114
|
+
- Finally call `final_answer()` when truly done
|
|
115
|
+
|
|
116
|
+
**Key principles:**
|
|
117
|
+
|
|
118
|
+
- **If the subagent result fully answers the user's request → call final_answer(result) immediately**
|
|
119
|
+
- Only process results further if you genuinely need to combine/transform them
|
|
120
|
+
- Don't waste turns analyzing results that already answer the question
|
|
121
|
+
- Tool/agent results are NOT automatically visible unless printed or passed to final_answer()
|
|
122
|
+
|
|
123
|
+
{% endif %}
|
|
124
|
+
{% if 'web_search' in tools %}
|
|
125
|
+
|
|
126
|
+
## Web Search Guidelines
|
|
127
|
+
|
|
128
|
+
When searching the web:
|
|
129
|
+
|
|
130
|
+
- Use `web_search(query="...", max_results=5)` to get search results
|
|
131
|
+
- Returns: `[{"title": "...", "url": "...", "snippet": "..."}]`
|
|
132
|
+
- **Important:** Format results nicely for the user! Extract and summarize relevant information from snippets
|
|
133
|
+
- Use `fetch_text(url="...")` to get full page content when snippets aren't enough
|
|
134
|
+
{% endif %}
|
|
135
|
+
|
|
136
|
+
{{ task_summary }}
|
|
137
|
+
|
|
138
|
+
# Task
|
|
139
|
+
|
|
140
|
+
{{ user_prompt }}
|
tsugite/cache.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Cache management for attachment content."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
from tsugite.xdg import get_xdg_cache_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_cache_key(source: str) -> str:
|
|
13
|
+
"""Generate cache key from source URL or path.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
source: URL or file path to cache
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
16-character hex string (first 16 chars of SHA256)
|
|
20
|
+
"""
|
|
21
|
+
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_cache_file_path(source: str) -> Path:
|
|
25
|
+
"""Get cache file path for a source.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
source: URL or file path
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Path to cache file
|
|
32
|
+
"""
|
|
33
|
+
cache_dir = get_xdg_cache_path("attachments")
|
|
34
|
+
cache_key = get_cache_key(source)
|
|
35
|
+
return cache_dir / f"{cache_key}.txt"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_cached_content(source: str) -> Optional[str]:
|
|
39
|
+
"""Get cached content if exists.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
source: URL or file path
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Cached content if exists, None otherwise
|
|
46
|
+
"""
|
|
47
|
+
cache_file = get_cache_file_path(source)
|
|
48
|
+
if cache_file.exists():
|
|
49
|
+
return cache_file.read_text(encoding="utf-8")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_to_cache(source: str, content: str) -> None:
|
|
54
|
+
"""Save content to cache.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
source: URL or file path
|
|
58
|
+
content: Content to cache
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If cache save fails
|
|
62
|
+
"""
|
|
63
|
+
cache_file = get_cache_file_path(source)
|
|
64
|
+
|
|
65
|
+
# Ensure cache directory exists
|
|
66
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
cache_file.write_text(content, encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
# Update metadata
|
|
72
|
+
_update_cache_metadata(source, cache_file)
|
|
73
|
+
except IOError as e:
|
|
74
|
+
raise RuntimeError(f"Failed to save cache for {source}: {e}") from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _update_cache_metadata(source: str, cache_file: Path) -> None:
|
|
78
|
+
"""Update cache metadata file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
source: Original source URL/path
|
|
82
|
+
cache_file: Path to cache file
|
|
83
|
+
"""
|
|
84
|
+
metadata_file = get_xdg_cache_path("attachments") / "metadata.json"
|
|
85
|
+
|
|
86
|
+
# Load existing metadata
|
|
87
|
+
if metadata_file.exists():
|
|
88
|
+
try:
|
|
89
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
90
|
+
metadata = json.load(f)
|
|
91
|
+
except (json.JSONDecodeError, IOError):
|
|
92
|
+
metadata = {}
|
|
93
|
+
else:
|
|
94
|
+
metadata = {}
|
|
95
|
+
|
|
96
|
+
# Update entry
|
|
97
|
+
cache_key = get_cache_key(source)
|
|
98
|
+
metadata[cache_key] = {
|
|
99
|
+
"source": source,
|
|
100
|
+
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
101
|
+
"size": cache_file.stat().st_size,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Save metadata
|
|
105
|
+
metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
try:
|
|
107
|
+
with open(metadata_file, "w", encoding="utf-8") as f:
|
|
108
|
+
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
|
109
|
+
except IOError:
|
|
110
|
+
# Metadata update failure is not critical
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def clear_cache(source: Optional[str] = None) -> int:
|
|
115
|
+
"""Clear cache for source, or entire cache if source is None.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
source: URL or file path to clear, or None to clear all
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Number of cache files deleted
|
|
122
|
+
"""
|
|
123
|
+
cache_dir = get_xdg_cache_path("attachments")
|
|
124
|
+
|
|
125
|
+
if not cache_dir.exists():
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
count = 0
|
|
129
|
+
|
|
130
|
+
if source:
|
|
131
|
+
# Clear specific cache entry
|
|
132
|
+
cache_file = get_cache_file_path(source)
|
|
133
|
+
if cache_file.exists():
|
|
134
|
+
cache_file.unlink()
|
|
135
|
+
count = 1
|
|
136
|
+
|
|
137
|
+
# Update metadata
|
|
138
|
+
metadata_file = cache_dir / "metadata.json"
|
|
139
|
+
if metadata_file.exists():
|
|
140
|
+
try:
|
|
141
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
142
|
+
metadata = json.load(f)
|
|
143
|
+
|
|
144
|
+
cache_key = get_cache_key(source)
|
|
145
|
+
if cache_key in metadata:
|
|
146
|
+
del metadata[cache_key]
|
|
147
|
+
|
|
148
|
+
with open(metadata_file, "w", encoding="utf-8") as f:
|
|
149
|
+
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
|
150
|
+
except (json.JSONDecodeError, IOError):
|
|
151
|
+
pass
|
|
152
|
+
else:
|
|
153
|
+
# Clear all cache files
|
|
154
|
+
for cache_file in cache_dir.glob("*.txt"):
|
|
155
|
+
cache_file.unlink()
|
|
156
|
+
count += 1
|
|
157
|
+
|
|
158
|
+
# Clear metadata
|
|
159
|
+
metadata_file = cache_dir / "metadata.json"
|
|
160
|
+
if metadata_file.exists():
|
|
161
|
+
metadata_file.unlink()
|
|
162
|
+
|
|
163
|
+
return count
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_cache() -> Dict[str, Dict[str, any]]:
|
|
167
|
+
"""List all cached entries with metadata.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dictionary mapping cache keys to metadata
|
|
171
|
+
"""
|
|
172
|
+
metadata_file = get_xdg_cache_path("attachments") / "metadata.json"
|
|
173
|
+
|
|
174
|
+
if not metadata_file.exists():
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
179
|
+
return json.load(f)
|
|
180
|
+
except (json.JSONDecodeError, IOError):
|
|
181
|
+
return {}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_cache_info(source: str) -> Optional[Dict[str, any]]:
|
|
185
|
+
"""Get cache metadata for a specific source.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
source: URL or file path
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Metadata dict if cached, None otherwise
|
|
192
|
+
"""
|
|
193
|
+
metadata = list_cache()
|
|
194
|
+
cache_key = get_cache_key(source)
|
|
195
|
+
return metadata.get(cache_key)
|