tunacode-cli 0.0.17__py3-none-any.whl → 0.0.19__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands.py +73 -41
- tunacode/cli/main.py +29 -26
- tunacode/cli/repl.py +91 -37
- tunacode/cli/textual_app.py +69 -66
- tunacode/cli/textual_bridge.py +33 -32
- tunacode/configuration/settings.py +2 -9
- tunacode/constants.py +2 -4
- tunacode/context.py +1 -1
- tunacode/core/agents/__init__.py +12 -0
- tunacode/core/agents/main.py +89 -63
- tunacode/core/agents/orchestrator.py +99 -0
- tunacode/core/agents/planner_schema.py +9 -0
- tunacode/core/agents/readonly.py +51 -0
- tunacode/core/background/__init__.py +0 -0
- tunacode/core/background/manager.py +36 -0
- tunacode/core/llm/__init__.py +0 -0
- tunacode/core/llm/planner.py +63 -0
- tunacode/core/setup/config_setup.py +79 -44
- tunacode/core/setup/coordinator.py +20 -13
- tunacode/core/setup/git_safety_setup.py +35 -49
- tunacode/core/state.py +2 -9
- tunacode/exceptions.py +0 -2
- tunacode/prompts/system.txt +179 -69
- tunacode/tools/__init__.py +10 -1
- tunacode/tools/base.py +1 -1
- tunacode/tools/bash.py +5 -5
- tunacode/tools/grep.py +210 -250
- tunacode/tools/read_file.py +2 -8
- tunacode/tools/run_command.py +4 -11
- tunacode/tools/update_file.py +2 -6
- tunacode/ui/completers.py +32 -31
- tunacode/ui/console.py +3 -3
- tunacode/ui/input.py +8 -5
- tunacode/ui/keybindings.py +1 -3
- tunacode/ui/lexers.py +16 -16
- tunacode/ui/output.py +2 -2
- tunacode/ui/panels.py +8 -8
- tunacode/ui/prompt_manager.py +19 -7
- tunacode/utils/import_cache.py +11 -0
- tunacode/utils/user_configuration.py +24 -2
- {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/METADATA +68 -11
- tunacode_cli-0.0.19.dist-info/RECORD +75 -0
- tunacode_cli-0.0.17.dist-info/RECORD +0 -67
- {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/top_level.txt +0 -0
tunacode/core/agents/main.py
CHANGED
|
@@ -9,8 +9,22 @@ import re
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from typing import Optional
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Lazy import for Agent and Tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_agent_tool():
|
|
16
|
+
import importlib
|
|
17
|
+
|
|
18
|
+
pydantic_ai = importlib.import_module("pydantic_ai")
|
|
19
|
+
return pydantic_ai.Agent, pydantic_ai.Tool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_model_messages():
|
|
23
|
+
import importlib
|
|
24
|
+
|
|
25
|
+
messages = importlib.import_module("pydantic_ai.messages")
|
|
26
|
+
return messages.ModelRequest, messages.ToolReturnPart
|
|
27
|
+
|
|
14
28
|
|
|
15
29
|
from tunacode.core.state import StateManager
|
|
16
30
|
from tunacode.services.mcp import get_mcp_servers
|
|
@@ -20,15 +34,8 @@ from tunacode.tools.read_file import read_file
|
|
|
20
34
|
from tunacode.tools.run_command import run_command
|
|
21
35
|
from tunacode.tools.update_file import update_file
|
|
22
36
|
from tunacode.tools.write_file import write_file
|
|
23
|
-
from tunacode.types import (
|
|
24
|
-
|
|
25
|
-
ErrorMessage,
|
|
26
|
-
ModelName,
|
|
27
|
-
PydanticAgent,
|
|
28
|
-
ToolCallback,
|
|
29
|
-
ToolCallId,
|
|
30
|
-
ToolName,
|
|
31
|
-
)
|
|
37
|
+
from tunacode.types import (AgentRun, ErrorMessage, ModelName, PydanticAgent, ToolCallback,
|
|
38
|
+
ToolCallId, ToolName)
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
async def _process_node(node, tool_callback: Optional[ToolCallback], state_manager: StateManager):
|
|
@@ -40,63 +47,65 @@ async def _process_node(node, tool_callback: Optional[ToolCallback], state_manag
|
|
|
40
47
|
# Display thought immediately if show_thoughts is enabled
|
|
41
48
|
if state_manager.session.show_thoughts:
|
|
42
49
|
from tunacode.ui import console as ui
|
|
50
|
+
|
|
43
51
|
await ui.muted(f"💭 THOUGHT: {node.thought}")
|
|
44
52
|
|
|
45
53
|
if hasattr(node, "model_response"):
|
|
46
54
|
state_manager.session.messages.append(node.model_response)
|
|
47
|
-
|
|
55
|
+
|
|
48
56
|
# Enhanced ReAct thought processing
|
|
49
57
|
if state_manager.session.show_thoughts:
|
|
50
|
-
from tunacode.ui import console as ui
|
|
51
58
|
import json
|
|
52
59
|
import re
|
|
53
|
-
|
|
60
|
+
|
|
61
|
+
from tunacode.ui import console as ui
|
|
62
|
+
|
|
54
63
|
for part in node.model_response.parts:
|
|
55
|
-
if hasattr(part,
|
|
64
|
+
if hasattr(part, "content") and isinstance(part.content, str):
|
|
56
65
|
content = part.content.strip()
|
|
57
|
-
|
|
66
|
+
|
|
58
67
|
# Pattern 1: Inline JSON thoughts {"thought": "..."}
|
|
59
68
|
thought_pattern = r'\{"thought":\s*"([^"]+)"\}'
|
|
60
69
|
matches = re.findall(thought_pattern, content)
|
|
61
70
|
for thought in matches:
|
|
62
71
|
await ui.muted(f"💭 REASONING: {thought}")
|
|
63
|
-
|
|
72
|
+
|
|
64
73
|
# Pattern 2: Standalone thought JSON objects
|
|
65
74
|
try:
|
|
66
75
|
if content.startswith('{"thought"'):
|
|
67
76
|
thought_obj = json.loads(content)
|
|
68
|
-
if
|
|
77
|
+
if "thought" in thought_obj:
|
|
69
78
|
await ui.muted(f"💭 REASONING: {thought_obj['thought']}")
|
|
70
79
|
except (json.JSONDecodeError, KeyError):
|
|
71
80
|
pass
|
|
72
|
-
|
|
81
|
+
|
|
73
82
|
# Pattern 3: Multi-line thoughts with context
|
|
74
83
|
multiline_pattern = r'\{"thought":\s*"([^"]+(?:\\.[^"]*)*?)"\}'
|
|
75
84
|
multiline_matches = re.findall(multiline_pattern, content, re.DOTALL)
|
|
76
85
|
for thought in multiline_matches:
|
|
77
86
|
if thought not in [m for m in matches]: # Avoid duplicates
|
|
78
87
|
# Clean up escaped characters
|
|
79
|
-
cleaned_thought = thought.replace('\\"', '"').replace(
|
|
88
|
+
cleaned_thought = thought.replace('\\"', '"').replace("\\n", " ")
|
|
80
89
|
await ui.muted(f"💭 REASONING: {cleaned_thought}")
|
|
81
|
-
|
|
90
|
+
|
|
82
91
|
# Pattern 4: Text-based reasoning indicators
|
|
83
92
|
reasoning_indicators = [
|
|
84
|
-
(r
|
|
85
|
-
(r
|
|
86
|
-
(r
|
|
87
|
-
(r
|
|
93
|
+
(r"I need to (.+?)\.", "PLANNING"),
|
|
94
|
+
(r"Let me (.+?)\.", "ACTION"),
|
|
95
|
+
(r"The output shows (.+?)\.", "OBSERVATION"),
|
|
96
|
+
(r"Based on (.+?), I should (.+?)\.", "DECISION"),
|
|
88
97
|
]
|
|
89
|
-
|
|
98
|
+
|
|
90
99
|
for pattern, label in reasoning_indicators:
|
|
91
100
|
indicator_matches = re.findall(pattern, content, re.IGNORECASE)
|
|
92
101
|
for match in indicator_matches:
|
|
93
102
|
if isinstance(match, tuple):
|
|
94
|
-
match_text =
|
|
103
|
+
match_text = " ".join(match)
|
|
95
104
|
else:
|
|
96
105
|
match_text = match
|
|
97
106
|
await ui.muted(f"🎯 {label}: {match_text}")
|
|
98
107
|
break # Only show first match per pattern
|
|
99
|
-
|
|
108
|
+
|
|
100
109
|
# Check for tool calls and fallback to JSON parsing if needed
|
|
101
110
|
has_tool_calls = False
|
|
102
111
|
for part in node.model_response.parts:
|
|
@@ -106,28 +115,32 @@ async def _process_node(node, tool_callback: Optional[ToolCallback], state_manag
|
|
|
106
115
|
elif part.part_kind == "tool-return":
|
|
107
116
|
obs_msg = f"OBSERVATION[{part.tool_name}]: {part.content[:2_000]}"
|
|
108
117
|
state_manager.session.messages.append(obs_msg)
|
|
109
|
-
|
|
118
|
+
|
|
110
119
|
# If no structured tool calls found, try parsing JSON from text content
|
|
111
120
|
if not has_tool_calls and tool_callback:
|
|
112
121
|
for part in node.model_response.parts:
|
|
113
|
-
if hasattr(part,
|
|
122
|
+
if hasattr(part, "content") and isinstance(part.content, str):
|
|
114
123
|
await extract_and_execute_tool_calls(part.content, tool_callback, state_manager)
|
|
115
124
|
|
|
116
125
|
|
|
117
126
|
def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
|
|
118
127
|
if model not in state_manager.session.agents:
|
|
119
128
|
max_retries = state_manager.session.user_config["settings"]["max_retries"]
|
|
120
|
-
|
|
129
|
+
|
|
130
|
+
# Lazy import Agent and Tool
|
|
131
|
+
Agent, Tool = get_agent_tool()
|
|
132
|
+
|
|
121
133
|
# Load system prompt
|
|
122
134
|
import os
|
|
123
135
|
from pathlib import Path
|
|
124
|
-
|
|
136
|
+
|
|
137
|
+
prompt_path = Path(__file__).parent.parent.parent / "prompts" / "system.md"
|
|
125
138
|
try:
|
|
126
139
|
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
127
140
|
system_prompt = f.read().strip()
|
|
128
141
|
except FileNotFoundError:
|
|
129
142
|
system_prompt = None
|
|
130
|
-
|
|
143
|
+
|
|
131
144
|
state_manager.session.agents[model] = Agent(
|
|
132
145
|
model=model,
|
|
133
146
|
system_prompt=system_prompt,
|
|
@@ -186,6 +199,8 @@ def patch_tool_messages(
|
|
|
186
199
|
# Identify orphaned tools (those without responses and not being retried)
|
|
187
200
|
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
188
201
|
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
202
|
+
# Import ModelRequest and ToolReturnPart lazily
|
|
203
|
+
ModelRequest, ToolReturnPart = get_model_messages()
|
|
189
204
|
messages.append(
|
|
190
205
|
ModelRequest(
|
|
191
206
|
parts=[
|
|
@@ -202,39 +217,41 @@ def patch_tool_messages(
|
|
|
202
217
|
)
|
|
203
218
|
|
|
204
219
|
|
|
205
|
-
async def parse_json_tool_calls(
|
|
220
|
+
async def parse_json_tool_calls(
|
|
221
|
+
text: str, tool_callback: Optional[ToolCallback], state_manager: StateManager
|
|
222
|
+
):
|
|
206
223
|
"""
|
|
207
224
|
Parse JSON tool calls from text when structured tool calling fails.
|
|
208
225
|
Fallback for when API providers don't support proper tool calling.
|
|
209
226
|
"""
|
|
210
227
|
if not tool_callback:
|
|
211
228
|
return
|
|
212
|
-
|
|
229
|
+
|
|
213
230
|
# Pattern for JSON tool calls: {"tool": "tool_name", "args": {...}}
|
|
214
231
|
# Find potential JSON objects and parse them
|
|
215
232
|
potential_jsons = []
|
|
216
233
|
brace_count = 0
|
|
217
234
|
start_pos = -1
|
|
218
|
-
|
|
235
|
+
|
|
219
236
|
for i, char in enumerate(text):
|
|
220
|
-
if char ==
|
|
237
|
+
if char == "{":
|
|
221
238
|
if brace_count == 0:
|
|
222
239
|
start_pos = i
|
|
223
240
|
brace_count += 1
|
|
224
|
-
elif char ==
|
|
241
|
+
elif char == "}":
|
|
225
242
|
brace_count -= 1
|
|
226
243
|
if brace_count == 0 and start_pos != -1:
|
|
227
|
-
potential_json = text[start_pos:i+1]
|
|
244
|
+
potential_json = text[start_pos : i + 1]
|
|
228
245
|
try:
|
|
229
246
|
parsed = json.loads(potential_json)
|
|
230
|
-
if isinstance(parsed, dict) and
|
|
231
|
-
potential_jsons.append((parsed[
|
|
247
|
+
if isinstance(parsed, dict) and "tool" in parsed and "args" in parsed:
|
|
248
|
+
potential_jsons.append((parsed["tool"], parsed["args"]))
|
|
232
249
|
except json.JSONDecodeError:
|
|
233
250
|
pass
|
|
234
251
|
start_pos = -1
|
|
235
|
-
|
|
252
|
+
|
|
236
253
|
matches = potential_jsons
|
|
237
|
-
|
|
254
|
+
|
|
238
255
|
for tool_name, args in matches:
|
|
239
256
|
try:
|
|
240
257
|
# Create a mock tool call object
|
|
@@ -243,66 +260,73 @@ async def parse_json_tool_calls(text: str, tool_callback: Optional[ToolCallback]
|
|
|
243
260
|
self.tool_name = tool_name
|
|
244
261
|
self.args = args
|
|
245
262
|
self.tool_call_id = f"fallback_{datetime.now().timestamp()}"
|
|
246
|
-
|
|
263
|
+
|
|
247
264
|
class MockNode:
|
|
248
265
|
pass
|
|
249
|
-
|
|
266
|
+
|
|
250
267
|
# Execute the tool through the callback
|
|
251
268
|
mock_call = MockToolCall(tool_name, args)
|
|
252
269
|
mock_node = MockNode()
|
|
253
|
-
|
|
270
|
+
|
|
254
271
|
await tool_callback(mock_call, mock_node)
|
|
255
|
-
|
|
272
|
+
|
|
256
273
|
if state_manager.session.show_thoughts:
|
|
257
274
|
from tunacode.ui import console as ui
|
|
275
|
+
|
|
258
276
|
await ui.muted(f"🔧 FALLBACK: Executed {tool_name} via JSON parsing")
|
|
259
|
-
|
|
277
|
+
|
|
260
278
|
except Exception as e:
|
|
261
279
|
if state_manager.session.show_thoughts:
|
|
262
280
|
from tunacode.ui import console as ui
|
|
281
|
+
|
|
263
282
|
await ui.error(f"❌ Error executing fallback tool {tool_name}: {str(e)}")
|
|
264
283
|
|
|
265
284
|
|
|
266
|
-
async def extract_and_execute_tool_calls(
|
|
285
|
+
async def extract_and_execute_tool_calls(
|
|
286
|
+
text: str, tool_callback: Optional[ToolCallback], state_manager: StateManager
|
|
287
|
+
):
|
|
267
288
|
"""
|
|
268
289
|
Extract tool calls from text content and execute them.
|
|
269
290
|
Supports multiple formats for maximum compatibility.
|
|
270
291
|
"""
|
|
271
292
|
if not tool_callback:
|
|
272
293
|
return
|
|
273
|
-
|
|
294
|
+
|
|
274
295
|
# Format 1: {"tool": "name", "args": {...}}
|
|
275
296
|
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
276
|
-
|
|
297
|
+
|
|
277
298
|
# Format 2: Tool calls in code blocks
|
|
278
299
|
code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
|
|
279
300
|
code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
|
|
280
|
-
|
|
301
|
+
|
|
281
302
|
for match in code_matches:
|
|
282
303
|
try:
|
|
283
304
|
tool_data = json.loads(match)
|
|
284
|
-
if
|
|
305
|
+
if "tool" in tool_data and "args" in tool_data:
|
|
306
|
+
|
|
285
307
|
class MockToolCall:
|
|
286
308
|
def __init__(self, tool_name: str, args: dict):
|
|
287
309
|
self.tool_name = tool_name
|
|
288
310
|
self.args = args
|
|
289
311
|
self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
|
|
290
|
-
|
|
312
|
+
|
|
291
313
|
class MockNode:
|
|
292
314
|
pass
|
|
293
|
-
|
|
294
|
-
mock_call = MockToolCall(tool_data[
|
|
315
|
+
|
|
316
|
+
mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
|
|
295
317
|
mock_node = MockNode()
|
|
296
|
-
|
|
318
|
+
|
|
297
319
|
await tool_callback(mock_call, mock_node)
|
|
298
|
-
|
|
320
|
+
|
|
299
321
|
if state_manager.session.show_thoughts:
|
|
300
322
|
from tunacode.ui import console as ui
|
|
323
|
+
|
|
301
324
|
await ui.muted(f"🔧 FALLBACK: Executed {tool_data['tool']} from code block")
|
|
302
|
-
|
|
325
|
+
|
|
303
326
|
except (json.JSONDecodeError, KeyError, Exception) as e:
|
|
304
327
|
if state_manager.session.show_thoughts:
|
|
305
328
|
from tunacode.ui import console as ui
|
|
329
|
+
|
|
306
330
|
await ui.error(f"❌ Error parsing code block tool call: {str(e)}")
|
|
307
331
|
|
|
308
332
|
|
|
@@ -316,22 +340,24 @@ async def process_request(
|
|
|
316
340
|
mh = state_manager.session.messages.copy()
|
|
317
341
|
# Get max iterations from config (default: 20)
|
|
318
342
|
max_iterations = state_manager.session.user_config.get("settings", {}).get("max_iterations", 20)
|
|
319
|
-
|
|
343
|
+
|
|
320
344
|
async with agent.iter(message, message_history=mh) as agent_run:
|
|
321
345
|
i = 0
|
|
322
346
|
async for node in agent_run:
|
|
323
347
|
await _process_node(node, tool_callback, state_manager)
|
|
324
348
|
i += 1
|
|
325
|
-
|
|
349
|
+
|
|
326
350
|
# Display iteration progress if thoughts are enabled
|
|
327
351
|
if state_manager.session.show_thoughts and i > 1:
|
|
328
352
|
from tunacode.ui import console as ui
|
|
353
|
+
|
|
329
354
|
await ui.muted(f"🔄 Iteration {i}/{max_iterations}")
|
|
330
|
-
|
|
355
|
+
|
|
331
356
|
if i >= max_iterations:
|
|
332
357
|
if state_manager.session.show_thoughts:
|
|
333
358
|
from tunacode.ui import console as ui
|
|
359
|
+
|
|
334
360
|
await ui.warning(f"⚠️ Reached maximum iterations ({max_iterations})")
|
|
335
361
|
break
|
|
336
|
-
|
|
362
|
+
|
|
337
363
|
return agent_run
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Agent orchestration scaffolding.
|
|
2
|
+
|
|
3
|
+
This module defines an ``OrchestratorAgent`` class that demonstrates how
|
|
4
|
+
higher level planning and delegation could be layered on top of the existing
|
|
5
|
+
``process_request`` workflow. The goal is to keep orchestration logic isolated
|
|
6
|
+
from the core agent implementation while reusing all current tooling and state
|
|
7
|
+
handling provided by ``main.process_request``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import itertools
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
from ...types import AgentRun, ModelName
|
|
17
|
+
from ..llm.planner import make_plan
|
|
18
|
+
from ..state import StateManager
|
|
19
|
+
from . import main as agent_main
|
|
20
|
+
from .planner_schema import Task
|
|
21
|
+
from .readonly import ReadOnlyAgent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrchestratorAgent:
|
|
25
|
+
"""Plan and run a sequence of sub-agent tasks."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, state_manager: StateManager):
|
|
28
|
+
self.state = state_manager
|
|
29
|
+
|
|
30
|
+
async def plan(self, request: str, model: ModelName) -> List[Task]:
|
|
31
|
+
"""Plan tasks for a user request using the planner LLM."""
|
|
32
|
+
|
|
33
|
+
return await make_plan(request, model, self.state)
|
|
34
|
+
|
|
35
|
+
async def _run_sub_task(self, task: Task, model: ModelName) -> AgentRun:
|
|
36
|
+
"""Execute a single task using an appropriate sub-agent."""
|
|
37
|
+
from rich.console import Console
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
# Show which task is being executed
|
|
42
|
+
task_type = "WRITE" if task.mutate else "READ"
|
|
43
|
+
console.print(f"\n[dim][Task {task.id}] {task_type}[/dim]")
|
|
44
|
+
console.print(f"[dim] → {task.description}[/dim]")
|
|
45
|
+
|
|
46
|
+
if task.mutate:
|
|
47
|
+
agent_main.get_or_create_agent(model, self.state)
|
|
48
|
+
result = await agent_main.process_request(model, task.description, self.state)
|
|
49
|
+
else:
|
|
50
|
+
agent = ReadOnlyAgent(model, self.state)
|
|
51
|
+
result = await agent.process_request(task.description)
|
|
52
|
+
|
|
53
|
+
console.print(f"[dim][Task {task.id}] Complete[/dim]")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
async def run(self, request: str, model: ModelName | None = None) -> List[AgentRun]:
|
|
57
|
+
"""Plan and execute a user request.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
request:
|
|
62
|
+
The high level user request to process.
|
|
63
|
+
model:
|
|
64
|
+
Optional model name to use for sub agents. Defaults to the current
|
|
65
|
+
session model.
|
|
66
|
+
"""
|
|
67
|
+
from rich.console import Console
|
|
68
|
+
|
|
69
|
+
console = Console()
|
|
70
|
+
model = model or self.state.session.current_model
|
|
71
|
+
|
|
72
|
+
# Show orchestrator is starting
|
|
73
|
+
console.print(
|
|
74
|
+
"\n[cyan]Orchestrator Mode: Analyzing request and creating execution plan...[/cyan]"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
tasks = await self.plan(request, model)
|
|
78
|
+
|
|
79
|
+
# Show execution is starting
|
|
80
|
+
console.print(f"\n[cyan]Executing plan with {len(tasks)} tasks...[/cyan]")
|
|
81
|
+
|
|
82
|
+
results: List[AgentRun] = []
|
|
83
|
+
for mutate_flag, group in itertools.groupby(tasks, key=lambda t: t.mutate):
|
|
84
|
+
if mutate_flag:
|
|
85
|
+
for t in group:
|
|
86
|
+
results.append(await self._run_sub_task(t, model))
|
|
87
|
+
else:
|
|
88
|
+
# Show parallel execution
|
|
89
|
+
task_list = list(group)
|
|
90
|
+
if len(task_list) > 1:
|
|
91
|
+
console.print(
|
|
92
|
+
f"\n[dim][Parallel Execution] Running {len(task_list)} read-only tasks concurrently...[/dim]"
|
|
93
|
+
)
|
|
94
|
+
coros = [self._run_sub_task(t, model) for t in task_list]
|
|
95
|
+
results.extend(await asyncio.gather(*coros))
|
|
96
|
+
|
|
97
|
+
console.print("\n[green]Orchestrator completed all tasks successfully![/green]")
|
|
98
|
+
|
|
99
|
+
return results
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Task(BaseModel):
|
|
5
|
+
"""Single sub-task generated by the planner."""
|
|
6
|
+
|
|
7
|
+
id: int = Field(..., description="1-based task index in execution order")
|
|
8
|
+
description: str = Field(..., description="What the sub-agent must do")
|
|
9
|
+
mutate: bool = Field(..., description="True if the task changes code")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Read-only agent implementation for non-mutating operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ...tools.grep import grep
|
|
8
|
+
from ...tools.read_file import read_file
|
|
9
|
+
from ...types import AgentRun, ModelName
|
|
10
|
+
from ..state import StateManager
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ...types import PydanticAgent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadOnlyAgent:
|
|
17
|
+
"""Agent configured with read-only tools for analysis tasks."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, model: ModelName, state_manager: StateManager):
|
|
20
|
+
self.model = model
|
|
21
|
+
self.state_manager = state_manager
|
|
22
|
+
self._agent: PydanticAgent | None = None
|
|
23
|
+
|
|
24
|
+
def _get_agent(self) -> PydanticAgent:
|
|
25
|
+
"""Lazily create the agent with read-only tools."""
|
|
26
|
+
if self._agent is None:
|
|
27
|
+
from .main import get_agent_tool
|
|
28
|
+
|
|
29
|
+
Agent, Tool = get_agent_tool()
|
|
30
|
+
|
|
31
|
+
# Create agent with only read-only tools
|
|
32
|
+
self._agent = Agent(
|
|
33
|
+
model=self.model,
|
|
34
|
+
system_prompt="You are a read-only assistant. You can analyze and read files but cannot modify them.",
|
|
35
|
+
tools=[
|
|
36
|
+
Tool(read_file),
|
|
37
|
+
Tool(grep),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
return self._agent
|
|
41
|
+
|
|
42
|
+
async def process_request(self, request: str) -> AgentRun:
|
|
43
|
+
"""Process a request using only read-only tools."""
|
|
44
|
+
agent = self._get_agent()
|
|
45
|
+
|
|
46
|
+
# Use iter() like main.py does to get the full run context
|
|
47
|
+
async with agent.iter(request) as agent_run:
|
|
48
|
+
async for _ in agent_run:
|
|
49
|
+
pass # Let it complete
|
|
50
|
+
|
|
51
|
+
return agent_run
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Asynchronous background task management utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Awaitable, Callable, Dict, List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BackgroundTaskManager:
|
|
12
|
+
"""Simple manager for background asyncio tasks."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.tasks: Dict[str, asyncio.Task] = {}
|
|
16
|
+
self.listeners: Dict[str, List[Callable[[asyncio.Task], None]]] = defaultdict(list)
|
|
17
|
+
|
|
18
|
+
def spawn(self, coro: Awaitable, *, name: str | None = None) -> str:
|
|
19
|
+
task_id = name or uuid.uuid4().hex[:8]
|
|
20
|
+
task = asyncio.create_task(coro, name=task_id)
|
|
21
|
+
self.tasks[task_id] = task
|
|
22
|
+
task.add_done_callback(self._notify)
|
|
23
|
+
return task_id
|
|
24
|
+
|
|
25
|
+
def _notify(self, task: asyncio.Task) -> None:
|
|
26
|
+
for cb in self.listeners.get(task.get_name(), []):
|
|
27
|
+
cb(task)
|
|
28
|
+
|
|
29
|
+
async def shutdown(self) -> None:
|
|
30
|
+
for task in self.tasks.values():
|
|
31
|
+
task.cancel()
|
|
32
|
+
if self.tasks:
|
|
33
|
+
await asyncio.gather(*self.tasks.values(), return_exceptions=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BG_MANAGER = BackgroundTaskManager()
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from ...types import ModelName
|
|
5
|
+
from ..agents.planner_schema import Task
|
|
6
|
+
from ..state import StateManager
|
|
7
|
+
|
|
8
|
+
_SYSTEM = """You are a senior software project planner.
|
|
9
|
+
|
|
10
|
+
Your job is to break down a USER_REQUEST into a logical sequence of tasks.
|
|
11
|
+
|
|
12
|
+
Guidelines:
|
|
13
|
+
1. Use the FEWEST tasks possible - combine related operations
|
|
14
|
+
2. Order tasks logically - reads before writes, understand before modify
|
|
15
|
+
3. Mark read-only tasks (reading files, searching, analyzing) as mutate: false
|
|
16
|
+
4. Mark modifying tasks (writing, updating, creating files) as mutate: true
|
|
17
|
+
5. Write clear, actionable descriptions
|
|
18
|
+
|
|
19
|
+
Each task MUST have:
|
|
20
|
+
- id: Sequential number starting from 1
|
|
21
|
+
- description: Clear description of what needs to be done
|
|
22
|
+
- mutate: true if the task modifies files/code, false if it only reads/analyzes
|
|
23
|
+
|
|
24
|
+
Return ONLY a valid JSON array of tasks, no other text.
|
|
25
|
+
Example:
|
|
26
|
+
[
|
|
27
|
+
{"id": 1, "description": "Read the main.py file to understand the structure", "mutate": false},
|
|
28
|
+
{"id": 2, "description": "Update the function to handle edge cases", "mutate": true}
|
|
29
|
+
]"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def make_plan(request: str, model: ModelName, state_manager: StateManager) -> List[Task]:
|
|
33
|
+
"""Generate an execution plan from a user request using TunaCode's LLM infrastructure."""
|
|
34
|
+
# Lazy import to avoid circular dependencies
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
|
|
37
|
+
from ..agents.main import get_agent_tool
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
Agent, _ = get_agent_tool()
|
|
41
|
+
|
|
42
|
+
# Show planning is starting
|
|
43
|
+
console.print("\n[dim][Planning] Breaking down request into tasks...[/dim]")
|
|
44
|
+
|
|
45
|
+
# Create a simple planning agent
|
|
46
|
+
planner = Agent(
|
|
47
|
+
model=model,
|
|
48
|
+
system_prompt=_SYSTEM,
|
|
49
|
+
result_type=List[Task],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Get the plan from the agent
|
|
53
|
+
result = await planner.run(request)
|
|
54
|
+
tasks = result.data
|
|
55
|
+
|
|
56
|
+
# Display the plan
|
|
57
|
+
console.print(f"[dim][Planning] Generated {len(tasks)} tasks:[/dim]")
|
|
58
|
+
for task in tasks:
|
|
59
|
+
task_type = "WRITE" if task.mutate else "READ"
|
|
60
|
+
console.print(f"[dim] Task {task.id}: {task_type} - {task.description}[/dim]")
|
|
61
|
+
console.print("")
|
|
62
|
+
|
|
63
|
+
return tasks
|