raggiecode 0.2.1__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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- skills/tool.py +121 -0
Agent/agent.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import platform
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
from openai import OpenAI
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
|
|
13
|
+
from .config import load_roles, load_tools, load_keys
|
|
14
|
+
from .tools import ToolRegistry
|
|
15
|
+
from .command import CommandRegistry
|
|
16
|
+
from .chat_history_db import init_db, get_or_create_session, load_messages, save_message, update_chat_title, generate_title, get_active_todo_list, get_todo_tasks, create_session, set_redirect_session_id, save_handover, get_old_session_ids, is_subagent_session, get_session_effort, get_session_info, resolve_session_id, migrate_todo_lists
|
|
17
|
+
from .git_manager import GitManager
|
|
18
|
+
from skills import SkillManager
|
|
19
|
+
from indexing.code_index_sdk import CodeIndexSDK
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tool_summary(name, args):
|
|
23
|
+
parts = [f"{k}: {str(v)[:60]}" for k, v in args.items()]
|
|
24
|
+
return f"{name} {' '.join(parts)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Agent:
|
|
28
|
+
|
|
29
|
+
def __init__(self, role, chat_id=None, session_id=None, debug=False):
|
|
30
|
+
self.roles = load_roles()
|
|
31
|
+
self.tools = load_tools()
|
|
32
|
+
self.tool_registry = ToolRegistry()
|
|
33
|
+
self.tool_registry.agent_role = role
|
|
34
|
+
self.command_registry = CommandRegistry()
|
|
35
|
+
|
|
36
|
+
self.agent_role = role
|
|
37
|
+
self.debug = debug
|
|
38
|
+
self.console = Console()
|
|
39
|
+
|
|
40
|
+
if role not in self.roles:
|
|
41
|
+
raise ValueError(f"Role '{role}' is not defined in roles.json")
|
|
42
|
+
|
|
43
|
+
base_url = self.roles[role].get("base_url", "")
|
|
44
|
+
if not base_url:
|
|
45
|
+
raise ValueError(f"No base_url found for role '{role}' in roles")
|
|
46
|
+
|
|
47
|
+
keys = load_keys()
|
|
48
|
+
api_key = keys.get(base_url, "")
|
|
49
|
+
if not api_key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"No API key found for base_url '{base_url}' in your keys. make sure you used the correct urls in roles and keys section when you ran `raggie setup` "
|
|
52
|
+
f"If your model doesn't require a key (e.g. local AI), set it to 'nokey'."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
|
57
|
+
|
|
58
|
+
self.reasoning = self.roles[role].get("reasoning", False)
|
|
59
|
+
self.streaming = self.roles[role].get("stream", False)
|
|
60
|
+
|
|
61
|
+
self.system_prompt = self._build_system_prompt(role)
|
|
62
|
+
|
|
63
|
+
# Initialize database
|
|
64
|
+
init_db()
|
|
65
|
+
|
|
66
|
+
# Set chat_id
|
|
67
|
+
self.chat_id = chat_id
|
|
68
|
+
|
|
69
|
+
# Get or create session for this chat
|
|
70
|
+
if session_id is None and chat_id is not None:
|
|
71
|
+
self.session_id = get_or_create_session(chat_id, parent_session_id=None)
|
|
72
|
+
elif session_id is not None:
|
|
73
|
+
self.session_id = resolve_session_id(session_id)
|
|
74
|
+
else:
|
|
75
|
+
raise ValueError("Either chat_id or session_id must be provided")
|
|
76
|
+
|
|
77
|
+
# Load chat history from database
|
|
78
|
+
self.chat_history = load_messages(self.session_id)
|
|
79
|
+
|
|
80
|
+
# Track if this is a new session (no messages yet)
|
|
81
|
+
self.is_new_session = len(self.chat_history) == 0
|
|
82
|
+
|
|
83
|
+
# Track if this is a subagent session (skip git commit on finish)
|
|
84
|
+
self.is_subagent = is_subagent_session(self.session_id)
|
|
85
|
+
|
|
86
|
+
# Inject system prompt if not already present (for new sessions)
|
|
87
|
+
if self.is_new_session or not any(msg.get("role") == "system" for msg in self.chat_history):
|
|
88
|
+
self.chat_history.insert(0, {"role": "system", "content": self.system_prompt})
|
|
89
|
+
|
|
90
|
+
# Initialize code indexer for tracking changes
|
|
91
|
+
self.code_indexer = CodeIndexSDK(
|
|
92
|
+
db_path=".raggie/.code_index.raggie",
|
|
93
|
+
root_dir=os.getcwd()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Share code indexer with tool registry for selective re-indexing
|
|
97
|
+
self.tool_registry.code_indexer = self.code_indexer
|
|
98
|
+
|
|
99
|
+
# Initialize git manager for commit/undo/redo operations
|
|
100
|
+
self.git_manager = GitManager(root_dir=os.getcwd())
|
|
101
|
+
|
|
102
|
+
# Check if this looks like a project directory before indexing
|
|
103
|
+
cwd = os.getcwd()
|
|
104
|
+
project_markers = [
|
|
105
|
+
# VCS
|
|
106
|
+
".git", ".hg", ".svn",
|
|
107
|
+
# Python
|
|
108
|
+
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
|
|
109
|
+
"Pipfile", "poetry.lock", "uv.lock", "tox.ini", "MANIFEST.in",
|
|
110
|
+
# JavaScript / TypeScript
|
|
111
|
+
"package.json", "tsconfig.json", "yarn.lock", "pnpm-lock.yaml",
|
|
112
|
+
"package-lock.json", "bower.json", ".npmrc", "deno.json",
|
|
113
|
+
# Go
|
|
114
|
+
"go.mod", "go.sum", "go.work",
|
|
115
|
+
# Rust
|
|
116
|
+
"Cargo.toml",
|
|
117
|
+
# C / C++
|
|
118
|
+
"CMakeLists.txt", "Makefile", "Makefile.am", "configure.ac",
|
|
119
|
+
"meson.build", "BUCK", "BUILD", "BUILD.bazel", "WORKSPACE",
|
|
120
|
+
# C# / .NET
|
|
121
|
+
"Directory.Build.props", "global.json",
|
|
122
|
+
# Java / Kotlin
|
|
123
|
+
"pom.xml", "build.gradle", "build.gradle.kts",
|
|
124
|
+
"settings.gradle", "settings.gradle.kts", "gradle.properties",
|
|
125
|
+
# PHP
|
|
126
|
+
"composer.json", "artisan",
|
|
127
|
+
# Ruby
|
|
128
|
+
"Gemfile", "Rakefile", ".rspec",
|
|
129
|
+
# Elixir
|
|
130
|
+
"mix.exs",
|
|
131
|
+
# Zig
|
|
132
|
+
"build.zig",
|
|
133
|
+
# Dart / Flutter
|
|
134
|
+
"pubspec.yaml",
|
|
135
|
+
# Lua
|
|
136
|
+
"rockspec",
|
|
137
|
+
# Generic / editor
|
|
138
|
+
".raggie", ".vscode", ".idea", ".editorconfig",
|
|
139
|
+
]
|
|
140
|
+
is_project = any(os.path.exists(os.path.join(cwd, m)) for m in project_markers)
|
|
141
|
+
if not is_project:
|
|
142
|
+
is_project = bool(glob.glob(os.path.join(cwd, "*.csproj")) or glob.glob(os.path.join(cwd, "*.sln")))
|
|
143
|
+
|
|
144
|
+
if is_project:
|
|
145
|
+
try:
|
|
146
|
+
with self.console.status("[bold green]Indexing codebase...", spinner="dots"):
|
|
147
|
+
self.code_indexer.index_directory()
|
|
148
|
+
except (KeyboardInterrupt, EOFError):
|
|
149
|
+
self.console.print("\n[yellow]Indexing interrupted. Using existing index.[/yellow]")
|
|
150
|
+
self.code_indexer._connect()
|
|
151
|
+
elif self.is_subagent:
|
|
152
|
+
self.console.print(
|
|
153
|
+
f"[yellow]Warning:[/yellow] '{cwd}' doesn't look like a project directory. "
|
|
154
|
+
f"Skipping indexing."
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
self.console.print(
|
|
158
|
+
f"[yellow]Warning:[/yellow] '{cwd}' doesn't look like a project directory "
|
|
159
|
+
f"(no .git, pyproject.toml, package.json, go.mod, Cargo.toml, etc. found).\n"
|
|
160
|
+
f"Indexing here may scan unrelated files and take a long time."
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
response = input("\nIndex anyway? (y/N): ").strip().lower()
|
|
164
|
+
except (EOFError, KeyboardInterrupt):
|
|
165
|
+
response = "n"
|
|
166
|
+
|
|
167
|
+
if response in ("y", "yes"):
|
|
168
|
+
try:
|
|
169
|
+
with self.console.status("[bold green]Indexing codebase...", spinner="dots"):
|
|
170
|
+
self.code_indexer.index_directory()
|
|
171
|
+
except (KeyboardInterrupt, EOFError):
|
|
172
|
+
self.console.print("\n[yellow]Indexing interrupted. Using existing index.[/yellow]")
|
|
173
|
+
self.code_indexer._connect()
|
|
174
|
+
else:
|
|
175
|
+
self.console.print(
|
|
176
|
+
"Please cd into your project directory and try again.\n"
|
|
177
|
+
"To start a new project: raggie code <project-name>"
|
|
178
|
+
)
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
# Display previous chat history if it exists (skip for subagents — they
|
|
182
|
+
# share the chat_id but don't need the parent's conversation printed)
|
|
183
|
+
if self.chat_history and not self.is_subagent:
|
|
184
|
+
self._display_chat_history()
|
|
185
|
+
|
|
186
|
+
# Check for incomplete todo list and offer resumption
|
|
187
|
+
self._check_incomplete_todo_list()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_system_prompt(self, role):
|
|
192
|
+
"""Build the system prompt from file or direct prompt, including skills."""
|
|
193
|
+
from Tools.utils import RED, RESET
|
|
194
|
+
role_system_prompt = ""
|
|
195
|
+
|
|
196
|
+
# Load system prompt from file if specified, otherwise use direct prompt
|
|
197
|
+
if "system_prompt_file" in self.roles[role]:
|
|
198
|
+
prompt_file_name = self.roles[role]["system_prompt_file"]
|
|
199
|
+
# Resolve relative paths to ~/.config/raggie, absolute paths as-is
|
|
200
|
+
if os.path.isabs(prompt_file_name):
|
|
201
|
+
prompt_file_path = prompt_file_name
|
|
202
|
+
else:
|
|
203
|
+
from Agent.config import USER_CONFIG_DIR, DEFAULT_CONFIG_DIR
|
|
204
|
+
# Use just the basename so old paths like "src/config/foo.md" still resolve
|
|
205
|
+
base_name = os.path.basename(prompt_file_name)
|
|
206
|
+
prompt_file_path = USER_CONFIG_DIR / base_name
|
|
207
|
+
# Copy from source defaults if not present in user config
|
|
208
|
+
if not prompt_file_path.exists():
|
|
209
|
+
default_path = DEFAULT_CONFIG_DIR / base_name
|
|
210
|
+
if default_path.exists():
|
|
211
|
+
USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
shutil.copy2(default_path, prompt_file_path)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
with open(prompt_file_path, 'r', encoding='utf-8') as f:
|
|
216
|
+
role_system_prompt = f.read()
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
raise ValueError(f"System prompt file not found: {prompt_file_path}")
|
|
219
|
+
else:
|
|
220
|
+
role_system_prompt = self.roles[role]["system_prompt"]
|
|
221
|
+
|
|
222
|
+
system_prompt = f"{role_system_prompt}\n\ntoday is {date.today()} current directory is {os.getcwd()} and the host system is \"{str(platform.uname())}\""
|
|
223
|
+
|
|
224
|
+
# Advertise all available skills as brief summaries in the system prompt
|
|
225
|
+
skill_manager = SkillManager()
|
|
226
|
+
all_skills = skill_manager.list_skills()
|
|
227
|
+
if all_skills:
|
|
228
|
+
skills_section = "## Available Skills\n\nThe following skills are available. Use the GetSkill tool with the skill name to fetch the full skill content before working with it. (this will have instructions for you so you can operate better)\n\n"
|
|
229
|
+
for skill in all_skills:
|
|
230
|
+
skills_section += f"- **{skill['role']}/{skill['name']}**: {skill['summary']}\n"
|
|
231
|
+
system_prompt = f"{system_prompt}\n\n{skills_section}"
|
|
232
|
+
|
|
233
|
+
# Optionally load AGENTS.md if it exists in the project root (cwd)
|
|
234
|
+
agents_md_path = os.path.join(os.getcwd(), "AGENTS.md")
|
|
235
|
+
if os.path.exists(agents_md_path):
|
|
236
|
+
try:
|
|
237
|
+
with open(agents_md_path, 'r', encoding='utf-8') as f:
|
|
238
|
+
agents_content = f.read().strip()
|
|
239
|
+
if agents_content:
|
|
240
|
+
system_prompt = f"{system_prompt}\n\n{agents_content}"
|
|
241
|
+
except Exception as e:
|
|
242
|
+
print(f"{RED}Warning: Failed to read AGENTS.md: {e}{RESET}")
|
|
243
|
+
|
|
244
|
+
return system_prompt
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _display_chat_history(self):
|
|
249
|
+
"""Display the previous chat history to the user."""
|
|
250
|
+
G = "\033[32m"
|
|
251
|
+
B = "\033[34m"
|
|
252
|
+
R = "\033[0m"
|
|
253
|
+
DIM = "\033[2m"
|
|
254
|
+
|
|
255
|
+
# Display messages from old (handed-over) sessions as read-only text
|
|
256
|
+
old_session_ids = get_old_session_ids(self.chat_id, self.session_id)
|
|
257
|
+
for old_sid in old_session_ids:
|
|
258
|
+
old_messages = load_messages(old_sid)
|
|
259
|
+
if not old_messages:
|
|
260
|
+
continue
|
|
261
|
+
print(f"\n{DIM}--- Session #{old_sid} (handed over, not in context) ---{R}")
|
|
262
|
+
for msg in old_messages:
|
|
263
|
+
role = msg.get("role", "unknown")
|
|
264
|
+
content = msg.get("content", "")
|
|
265
|
+
if role == "system" and not self.debug:
|
|
266
|
+
continue
|
|
267
|
+
if role == "user" and content:
|
|
268
|
+
print(f"{DIM}You: {content}{R}")
|
|
269
|
+
elif role == "assistant":
|
|
270
|
+
if content:
|
|
271
|
+
print(f"{DIM}Agent: {content}{R}")
|
|
272
|
+
if msg.get("tool_calls") and self.debug:
|
|
273
|
+
for tc in msg["tool_calls"]:
|
|
274
|
+
func_name = tc.get("function", {}).get("name", "unknown")
|
|
275
|
+
print(f"{DIM} [tool] {func_name}{R}")
|
|
276
|
+
elif role == "tool":
|
|
277
|
+
if self.debug:
|
|
278
|
+
print(f"{DIM} [tool output] {content[:100]}{R}")
|
|
279
|
+
print(f"{DIM}--- End of session #{old_sid} ---{R}\n")
|
|
280
|
+
|
|
281
|
+
# Display current session's chat history (these ARE in the context)
|
|
282
|
+
for msg in self.chat_history:
|
|
283
|
+
role = msg.get("role", "unknown")
|
|
284
|
+
content = msg.get("content", "")
|
|
285
|
+
|
|
286
|
+
# Skip system messages unless debug mode is enabled
|
|
287
|
+
if role == "system" and not self.debug:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if role == "user":
|
|
291
|
+
print(f"\n\n{G}You:{R}")
|
|
292
|
+
if content:
|
|
293
|
+
print(content)
|
|
294
|
+
elif role == "assistant":
|
|
295
|
+
print(f"\n\n{G}Agent: {R}")
|
|
296
|
+
if content:
|
|
297
|
+
self.console.print(Markdown(content))
|
|
298
|
+
if msg.get("tool_calls"):
|
|
299
|
+
for tc in msg["tool_calls"]:
|
|
300
|
+
func_name = tc.get("function", {}).get("name", "unknown")
|
|
301
|
+
func_args = tc.get("function", {}).get("arguments", "{}")
|
|
302
|
+
try:
|
|
303
|
+
args_parsed = json.loads(func_args)
|
|
304
|
+
except json.JSONDecodeError:
|
|
305
|
+
args_parsed = {}
|
|
306
|
+
print(f"{B} [tool] {_tool_summary(func_name, args_parsed)}{R}")
|
|
307
|
+
elif role == "tool":
|
|
308
|
+
pass
|
|
309
|
+
else:
|
|
310
|
+
print(f"\n{role.capitalize()}:")
|
|
311
|
+
if content:
|
|
312
|
+
print(content)
|
|
313
|
+
|
|
314
|
+
print("\n" + "=" * 60 + "\n")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _check_incomplete_todo_list(self):
|
|
319
|
+
"""Check for incomplete todo list and offer resumption."""
|
|
320
|
+
from prompt_toolkit import prompt
|
|
321
|
+
from Tools.utils import BLUE, GREEN, YELLOW, RED, RESET
|
|
322
|
+
from Agent.chat_history_db import resolve_todo_session_id
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
todo_session_id = resolve_todo_session_id(self.session_id)
|
|
326
|
+
active_todo = get_active_todo_list(todo_session_id)
|
|
327
|
+
if active_todo and active_todo['status'] in ('pending', 'in_progress', 'rejected'):
|
|
328
|
+
tasks = get_todo_tasks(active_todo['id'])
|
|
329
|
+
pending_tasks = [t for t in tasks if t['status'] == 'pending']
|
|
330
|
+
in_progress_tasks = [t for t in tasks if t['status'] == 'in_progress']
|
|
331
|
+
|
|
332
|
+
if pending_tasks or in_progress_tasks:
|
|
333
|
+
print(f"\n{BLUE}Incomplete todo list found ({active_todo['id']}){RESET}")
|
|
334
|
+
print("=" * 60)
|
|
335
|
+
for task in tasks:
|
|
336
|
+
status_color = GREEN if task['status'] == 'completed' else YELLOW if task['status'] == 'in_progress' else RESET
|
|
337
|
+
print(f"{status_color}[{task['status']}] {YELLOW}{task['order_index'] + 1}.{RESET} {task['goal']}{RESET}")
|
|
338
|
+
print("=" * 60)
|
|
339
|
+
|
|
340
|
+
if active_todo['status'] == 'rejected':
|
|
341
|
+
print(f"{YELLOW}This todo list was previously rejected.{RESET}")
|
|
342
|
+
|
|
343
|
+
user_input = prompt("Resume this todo list? (y/n): ").strip().lower()
|
|
344
|
+
if user_input == 'y':
|
|
345
|
+
print(f"{GREEN}Resuming todo list...{RESET}")
|
|
346
|
+
# The agent will need to call ExecuteNextTask to continue
|
|
347
|
+
else:
|
|
348
|
+
reason = prompt(f"{YELLOW}Reason for skipping (optional, press Enter to skip): {RESET}").strip()
|
|
349
|
+
if reason:
|
|
350
|
+
print(f"{YELLOW}Skipping todo list resumption. Reason: {reason}{RESET}")
|
|
351
|
+
else:
|
|
352
|
+
print(f"{YELLOW}Skipping todo list resumption.{RESET}")
|
|
353
|
+
except Exception as e:
|
|
354
|
+
print(f"{RED}Warning: Failed to check for incomplete todo list: {e}{RESET}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def setup_tools(self, callback):
|
|
359
|
+
callback(self.tool_registry)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _get_tools(self, role):
|
|
364
|
+
tools_required = self.roles[role]["tools"]
|
|
365
|
+
formatted_tools = []
|
|
366
|
+
|
|
367
|
+
for tool in tools_required:
|
|
368
|
+
if tool not in self.tools:
|
|
369
|
+
print(f"Warning: tool '{tool}' required by role '{role}' is not defined in tools.json, skipping")
|
|
370
|
+
continue
|
|
371
|
+
formatted_tools.append(self.tools[tool])
|
|
372
|
+
return formatted_tools
|
|
373
|
+
|
|
374
|
+
def _tool_error(self, toolcall_id, message):
|
|
375
|
+
return {
|
|
376
|
+
"role": "tool",
|
|
377
|
+
"tool_call_id": toolcall_id,
|
|
378
|
+
"content": message,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _has_dangling_tool_work(self):
|
|
384
|
+
if not self.chat_history:
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
last_msg = self.chat_history[-1]
|
|
388
|
+
if last_msg.get("role") == "tool":
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
# Vision messages (role "user" with tool_call_id) are also tool responses
|
|
392
|
+
if last_msg.get("role") == "user" and last_msg.get("tool_call_id"):
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
return last_msg.get("role") == "assistant" and bool(last_msg.get("tool_calls"))
|
|
396
|
+
|
|
397
|
+
def _get_pending_toolcalls(self):
|
|
398
|
+
"""Find tool calls from the last assistant message that don't have tool responses yet.
|
|
399
|
+
|
|
400
|
+
This handles the case where an assistant message contains multiple tool_calls
|
|
401
|
+
but only some were executed before a crash/interrupt.
|
|
402
|
+
"""
|
|
403
|
+
last_assistant_idx = None
|
|
404
|
+
for i in range(len(self.chat_history) - 1, -1, -1):
|
|
405
|
+
msg = self.chat_history[i]
|
|
406
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
407
|
+
last_assistant_idx = i
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if last_assistant_idx is None:
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
assistant_msg = self.chat_history[last_assistant_idx]
|
|
414
|
+
tool_calls = assistant_msg.get("tool_calls", [])
|
|
415
|
+
|
|
416
|
+
responded_ids = set()
|
|
417
|
+
for msg in self.chat_history[last_assistant_idx + 1:]:
|
|
418
|
+
tcid = msg.get("tool_call_id")
|
|
419
|
+
if tcid:
|
|
420
|
+
responded_ids.add(tcid)
|
|
421
|
+
|
|
422
|
+
return [tc for tc in tool_calls if tc.get("id") not in responded_ids]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _execute_tool_call(self, toolcall_id, tool_name, tool_arguments):
|
|
427
|
+
yield ("tool_call", tool_name, tool_arguments)
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
args_dict = json.loads(tool_arguments)
|
|
431
|
+
except json.JSONDecodeError as err:
|
|
432
|
+
error_msg = self._tool_error(toolcall_id, f"Invalid tool arguments: {err}")
|
|
433
|
+
self.chat_history.append(error_msg)
|
|
434
|
+
save_message(self.session_id, error_msg)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
tool_output = self.tool_registry.call(
|
|
439
|
+
tool_name, args_dict, toolcall_id, self.session_id
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if self.debug:
|
|
443
|
+
print(f"\n[DEBUG] Tool output for {tool_name}:")
|
|
444
|
+
print(tool_output.get("content", ""))
|
|
445
|
+
print()
|
|
446
|
+
|
|
447
|
+
if "image_data" in tool_output:
|
|
448
|
+
image_data = tool_output["image_data"]
|
|
449
|
+
vision_content = [
|
|
450
|
+
{
|
|
451
|
+
"type": "text",
|
|
452
|
+
"text": tool_output.get("content", "Analyze this image:")
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"type": "image_url",
|
|
456
|
+
"image_url": {
|
|
457
|
+
"url": f"data:{image_data['mime_type']};base64,{image_data['base64_data']}"
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
vision_msg = {
|
|
462
|
+
"role": "user",
|
|
463
|
+
"content": vision_content,
|
|
464
|
+
"tool_call_id": toolcall_id,
|
|
465
|
+
}
|
|
466
|
+
self.chat_history.append(vision_msg)
|
|
467
|
+
save_message(self.session_id, vision_msg)
|
|
468
|
+
else:
|
|
469
|
+
self.chat_history.append(tool_output)
|
|
470
|
+
save_message(self.session_id, tool_output)
|
|
471
|
+
except Exception as err:
|
|
472
|
+
error_msg = self._tool_error(toolcall_id, str(err))
|
|
473
|
+
self.chat_history.append(error_msg)
|
|
474
|
+
save_message(self.session_id, error_msg)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def resume_dangling_tool_work(self):
|
|
479
|
+
if not self._has_dangling_tool_work():
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
last_msg = self.chat_history[-1]
|
|
483
|
+
if last_msg.get("role") == "assistant" and last_msg.get("tool_calls"):
|
|
484
|
+
for toolcall in last_msg["tool_calls"]:
|
|
485
|
+
function = toolcall.get("function", {})
|
|
486
|
+
yield from self._execute_tool_call(
|
|
487
|
+
toolcall.get("id"),
|
|
488
|
+
function.get("name"),
|
|
489
|
+
function.get("arguments", "{}"),
|
|
490
|
+
)
|
|
491
|
+
elif last_msg.get("tool_call_id"):
|
|
492
|
+
pending = self._get_pending_toolcalls()
|
|
493
|
+
for toolcall in pending:
|
|
494
|
+
function = toolcall.get("function", {})
|
|
495
|
+
yield from self._execute_tool_call(
|
|
496
|
+
toolcall.get("id"),
|
|
497
|
+
function.get("name"),
|
|
498
|
+
function.get("arguments", "{}"),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
yield from self.start()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _yield_reasoning(self, response):
|
|
506
|
+
"""Yield reasoning content from a non-streaming response if reasoning is enabled."""
|
|
507
|
+
if self.reasoning:
|
|
508
|
+
reasoning_content = getattr(response, "reasoning_content", None) or ""
|
|
509
|
+
if reasoning_content:
|
|
510
|
+
yield ("reasoning", reasoning_content)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _yield_reasoning_chunk(self, delta):
|
|
515
|
+
"""Yield a reasoning chunk from a streaming response if reasoning is enabled."""
|
|
516
|
+
if self.reasoning and getattr(delta, "reasoning_content", None):
|
|
517
|
+
yield ("reasoning_chunk", delta.reasoning_content)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _stream_completion(self, model):
|
|
522
|
+
"""Stream a chat completion, yielding chunk events.
|
|
523
|
+
|
|
524
|
+
Sets self._agent_msg and self._total_tokens.
|
|
525
|
+
On error, yields ("error", ...) and leaves self._agent_msg as None.
|
|
526
|
+
"""
|
|
527
|
+
self._agent_msg = None
|
|
528
|
+
self._total_tokens = 0
|
|
529
|
+
content = ""
|
|
530
|
+
tool_calls_accum = []
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
stream = self.client.chat.completions.create(
|
|
534
|
+
model=model,
|
|
535
|
+
messages=self.chat_history,
|
|
536
|
+
tools=self._get_tools(self.agent_role),
|
|
537
|
+
stream=True,
|
|
538
|
+
stream_options={"include_usage": True},
|
|
539
|
+
)
|
|
540
|
+
for chunk in stream:
|
|
541
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
542
|
+
self._total_tokens = chunk.usage.total_tokens or 0
|
|
543
|
+
if not chunk.choices:
|
|
544
|
+
continue
|
|
545
|
+
delta = chunk.choices[0].delta
|
|
546
|
+
|
|
547
|
+
yield from self._yield_reasoning_chunk(delta)
|
|
548
|
+
|
|
549
|
+
if delta.content:
|
|
550
|
+
content += delta.content
|
|
551
|
+
yield ("response_chunk", delta.content)
|
|
552
|
+
|
|
553
|
+
if delta.tool_calls:
|
|
554
|
+
for tc in delta.tool_calls:
|
|
555
|
+
idx = tc.index
|
|
556
|
+
while len(tool_calls_accum) <= idx:
|
|
557
|
+
tool_calls_accum.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
|
|
558
|
+
if tc.id:
|
|
559
|
+
tool_calls_accum[idx]["id"] = tc.id
|
|
560
|
+
if tc.type:
|
|
561
|
+
tool_calls_accum[idx]["type"] = tc.type
|
|
562
|
+
if tc.function:
|
|
563
|
+
if tc.function.name:
|
|
564
|
+
tool_calls_accum[idx]["function"]["name"] += tc.function.name
|
|
565
|
+
if tc.function.arguments:
|
|
566
|
+
tool_calls_accum[idx]["function"]["arguments"] += tc.function.arguments
|
|
567
|
+
except Exception as err:
|
|
568
|
+
yield ("error", str(err))
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
agent_msg = {
|
|
572
|
+
"role": "assistant",
|
|
573
|
+
"content": content or None,
|
|
574
|
+
}
|
|
575
|
+
if tool_calls_accum:
|
|
576
|
+
agent_msg["tool_calls"] = tool_calls_accum
|
|
577
|
+
self.chat_history.append(agent_msg)
|
|
578
|
+
save_message(self.session_id, agent_msg)
|
|
579
|
+
|
|
580
|
+
if content:
|
|
581
|
+
yield ("response_end", content)
|
|
582
|
+
|
|
583
|
+
self._agent_msg = agent_msg
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _non_stream_completion(self, model):
|
|
588
|
+
"""Make a non-streaming chat completion, yielding events.
|
|
589
|
+
|
|
590
|
+
Sets self._agent_msg and self._total_tokens.
|
|
591
|
+
On error, yields ("error", ...) and leaves self._agent_msg as None.
|
|
592
|
+
"""
|
|
593
|
+
self._agent_msg = None
|
|
594
|
+
self._total_tokens = 0
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
with self.console.status("[bold green]Thinking...", spinner="dots"):
|
|
598
|
+
chat = self.client.chat.completions.create(
|
|
599
|
+
model=model,
|
|
600
|
+
messages=self.chat_history,
|
|
601
|
+
tools=self._get_tools(self.agent_role),
|
|
602
|
+
)
|
|
603
|
+
except Exception as err:
|
|
604
|
+
yield ("error", str(err))
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
if not chat.choices or len(chat.choices) == 0:
|
|
608
|
+
yield ("error", "no choices in response")
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
if hasattr(chat, 'usage') and chat.usage:
|
|
612
|
+
self._total_tokens = chat.usage.total_tokens or 0
|
|
613
|
+
|
|
614
|
+
response = chat.choices[0].message
|
|
615
|
+
|
|
616
|
+
yield from self._yield_reasoning(response)
|
|
617
|
+
|
|
618
|
+
agent_msg = {
|
|
619
|
+
"role": response.role,
|
|
620
|
+
"content": response.content,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if hasattr(response, "tool_calls") and response.tool_calls:
|
|
624
|
+
agent_msg["tool_calls"] = [
|
|
625
|
+
{
|
|
626
|
+
"id": tc.id,
|
|
627
|
+
"type": tc.type,
|
|
628
|
+
"function": {
|
|
629
|
+
"name": tc.function.name,
|
|
630
|
+
"arguments": tc.function.arguments,
|
|
631
|
+
},
|
|
632
|
+
}
|
|
633
|
+
for tc in response.tool_calls
|
|
634
|
+
]
|
|
635
|
+
self.chat_history.append(agent_msg)
|
|
636
|
+
save_message(self.session_id, agent_msg)
|
|
637
|
+
|
|
638
|
+
content = response.content or ""
|
|
639
|
+
if content:
|
|
640
|
+
yield ("response", content)
|
|
641
|
+
|
|
642
|
+
self._agent_msg = agent_msg
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _stream_handover(self, model):
|
|
647
|
+
"""Stream a handover completion, yielding chunk events.
|
|
648
|
+
|
|
649
|
+
Sets self._handover_text on success.
|
|
650
|
+
On error, yields ("error", ...) and leaves self._handover_text as None.
|
|
651
|
+
"""
|
|
652
|
+
self._handover_text = None
|
|
653
|
+
handover_text = ""
|
|
654
|
+
try:
|
|
655
|
+
stream = self.client.chat.completions.create(
|
|
656
|
+
model=model,
|
|
657
|
+
messages=self.chat_history,
|
|
658
|
+
stream=True,
|
|
659
|
+
)
|
|
660
|
+
for chunk in stream:
|
|
661
|
+
if not chunk.choices:
|
|
662
|
+
continue
|
|
663
|
+
delta = chunk.choices[0].delta
|
|
664
|
+
if delta.content:
|
|
665
|
+
handover_text += delta.content
|
|
666
|
+
yield ("response_chunk", delta.content)
|
|
667
|
+
except Exception as err:
|
|
668
|
+
self.chat_history.pop()
|
|
669
|
+
yield ("error", f"Handover failed: {err}")
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
if not handover_text:
|
|
673
|
+
self.chat_history.pop()
|
|
674
|
+
yield ("error", "Handover failed: no content in response")
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
self._handover_text = handover_text
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _non_stream_handover(self, model):
|
|
682
|
+
"""Make a non-streaming handover completion.
|
|
683
|
+
|
|
684
|
+
Sets self._handover_text on success.
|
|
685
|
+
On error, yields ("error", ...) and leaves self._handover_text as None.
|
|
686
|
+
"""
|
|
687
|
+
self._handover_text = None
|
|
688
|
+
try:
|
|
689
|
+
with self.console.status("[bold green]Generating handover instructions...", spinner="dots"):
|
|
690
|
+
chat = self.client.chat.completions.create(
|
|
691
|
+
model=model,
|
|
692
|
+
messages=self.chat_history,
|
|
693
|
+
)
|
|
694
|
+
except Exception as err:
|
|
695
|
+
self.chat_history.pop()
|
|
696
|
+
yield ("error", f"Handover failed: {err}")
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
if not chat.choices or len(chat.choices) == 0:
|
|
700
|
+
self.chat_history.pop()
|
|
701
|
+
yield ("error", "Handover failed: no choices in response")
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
handover_response = chat.choices[0].message
|
|
705
|
+
self._handover_text = handover_response.content or ""
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _perform_handover(self, total_tokens: int, context_window: int):
|
|
710
|
+
"""Perform a handover to a new session when the context window is nearly full.
|
|
711
|
+
|
|
712
|
+
Sends a handover prompt to the agent (without tools), saves the response,
|
|
713
|
+
creates a new session, stores the handover record, and switches to the
|
|
714
|
+
new session so the agent can seamlessly continue working.
|
|
715
|
+
"""
|
|
716
|
+
BLUE = "\033[34m"
|
|
717
|
+
YELLOW = "\033[33m"
|
|
718
|
+
DIM = "\033[2m"
|
|
719
|
+
RESET = "\033[0m"
|
|
720
|
+
|
|
721
|
+
# Find the last real user message (not the handover prompt we're about to add)
|
|
722
|
+
last_user_prompt = ""
|
|
723
|
+
for msg in reversed(self.chat_history):
|
|
724
|
+
if msg.get("role") == "user" and msg.get("content"):
|
|
725
|
+
last_user_prompt = msg["content"]
|
|
726
|
+
break
|
|
727
|
+
|
|
728
|
+
print(f"{YELLOW}\n[handover] Context window nearly full ({total_tokens}/{context_window} tokens). Initiating handover...{RESET}")
|
|
729
|
+
if last_user_prompt:
|
|
730
|
+
print(f"{DIM}Continuing task: {last_user_prompt}{RESET}")
|
|
731
|
+
|
|
732
|
+
handover_prompt = (
|
|
733
|
+
"Without using any more tool calls, give me a handover instruction for the next agent session.\n\n"
|
|
734
|
+
"Focus ONLY on the current task you are working on right now. Do NOT summarize previous tasks that are already completed.\n\n"
|
|
735
|
+
f"The user's most recent request was:\n\"\"\"\n{last_user_prompt}\n\"\"\"\n\n"
|
|
736
|
+
"Include:\n\n"
|
|
737
|
+
"1. The user's most recent request (copy it verbatim from above)\n"
|
|
738
|
+
"2. Current state of the code: what you've changed so far for THIS task\n"
|
|
739
|
+
"3. Important files and symbols involved in THIS task\n"
|
|
740
|
+
"4. Errors, blockers, or failed attempts on THIS task\n"
|
|
741
|
+
"5. Exact next step you would take\n\n"
|
|
742
|
+
"Be specific. Do not write vague phrases like \"continue debugging\" without explaining where and how."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
handover_user_msg = {"role": "user", "content": handover_prompt}
|
|
746
|
+
self.chat_history.append(handover_user_msg)
|
|
747
|
+
|
|
748
|
+
model = self.roles[self.agent_role]["model"]
|
|
749
|
+
if self.streaming:
|
|
750
|
+
for event in self._stream_handover(model):
|
|
751
|
+
if event[0] == "error":
|
|
752
|
+
yield event
|
|
753
|
+
return
|
|
754
|
+
else:
|
|
755
|
+
for event in self._non_stream_handover(model):
|
|
756
|
+
if event[0] == "error":
|
|
757
|
+
yield event
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
if self._handover_text is None:
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
handover_text = self._handover_text
|
|
764
|
+
|
|
765
|
+
save_message(self.session_id, handover_user_msg)
|
|
766
|
+
|
|
767
|
+
handover_agent_msg = {"role": "assistant", "content": handover_text}
|
|
768
|
+
self.chat_history.append(handover_agent_msg)
|
|
769
|
+
save_message(self.session_id, handover_agent_msg)
|
|
770
|
+
|
|
771
|
+
session_info = get_session_info(self.session_id)
|
|
772
|
+
if session_info is None:
|
|
773
|
+
session_info = {"parent_session_id": None, "toolcall_id": None, "depth": 0}
|
|
774
|
+
|
|
775
|
+
new_session_id = create_session(
|
|
776
|
+
self.chat_id,
|
|
777
|
+
parent_session_id=session_info.get("parent_session_id"),
|
|
778
|
+
toolcall_id=session_info.get("toolcall_id"),
|
|
779
|
+
effort=get_session_effort(self.session_id),
|
|
780
|
+
depth=session_info.get("depth", 0),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
save_handover(self.session_id, new_session_id, handover_text, total_tokens, context_window)
|
|
784
|
+
|
|
785
|
+
set_redirect_session_id(self.session_id, new_session_id)
|
|
786
|
+
|
|
787
|
+
migrate_todo_lists(self.session_id, new_session_id)
|
|
788
|
+
|
|
789
|
+
system_msg = {"role": "system", "content": self.system_prompt}
|
|
790
|
+
save_message(new_session_id, system_msg)
|
|
791
|
+
|
|
792
|
+
handover_user_msg_new = {"role": "user", "content": handover_text}
|
|
793
|
+
save_message(new_session_id, handover_user_msg_new)
|
|
794
|
+
|
|
795
|
+
self.session_id = new_session_id
|
|
796
|
+
self.chat_history = [
|
|
797
|
+
{"role": "system", "content": self.system_prompt},
|
|
798
|
+
{"role": "user", "content": handover_text},
|
|
799
|
+
]
|
|
800
|
+
self.is_new_session = False
|
|
801
|
+
|
|
802
|
+
print(f"{BLUE}[handover] New session {new_session_id} created. Continuing...{RESET}")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def start(self, prompt=None):
|
|
807
|
+
if self.agent_role not in self.roles:
|
|
808
|
+
yield ("error", f"role '{self.agent_role}' is not defined")
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
if prompt is not None:
|
|
812
|
+
# Check for registered commands
|
|
813
|
+
should_process, effective_prompt = self.command_registry.try_handle(prompt, self)
|
|
814
|
+
if not should_process:
|
|
815
|
+
return
|
|
816
|
+
prompt = effective_prompt
|
|
817
|
+
|
|
818
|
+
user_msg = {"role": "user", "content": prompt}
|
|
819
|
+
self.chat_history.append(user_msg)
|
|
820
|
+
save_message(self.session_id, user_msg)
|
|
821
|
+
|
|
822
|
+
# Update chat title with first user message if this is a new session
|
|
823
|
+
if self.is_new_session and not self.is_subagent:
|
|
824
|
+
title = generate_title(prompt)
|
|
825
|
+
update_chat_title(self.chat_id, title)
|
|
826
|
+
self.is_new_session = False
|
|
827
|
+
|
|
828
|
+
model = self.roles[self.agent_role]["model"]
|
|
829
|
+
context_window = self.roles[self.agent_role].get("context_window")
|
|
830
|
+
HANDOVER_THRESHOLD = 50000
|
|
831
|
+
|
|
832
|
+
while True:
|
|
833
|
+
if self.streaming:
|
|
834
|
+
yield from self._stream_completion(model)
|
|
835
|
+
else:
|
|
836
|
+
yield from self._non_stream_completion(model)
|
|
837
|
+
|
|
838
|
+
if self._agent_msg is None:
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
agent_msg = self._agent_msg
|
|
842
|
+
total_tokens = self._total_tokens
|
|
843
|
+
|
|
844
|
+
BLUE = "\033[34m"
|
|
845
|
+
RESET = "\033[0m"
|
|
846
|
+
if not agent_msg.get("tool_calls"):
|
|
847
|
+
|
|
848
|
+
# Check if handover is needed before returning (main agent only)
|
|
849
|
+
if not self.is_subagent and context_window and total_tokens > 0 and (context_window - total_tokens) < HANDOVER_THRESHOLD:
|
|
850
|
+
yield from self._perform_handover(total_tokens, context_window)
|
|
851
|
+
continue
|
|
852
|
+
|
|
853
|
+
# Commit changes after agent's final response (main agent only — subagents skip this)
|
|
854
|
+
if not self.is_subagent:
|
|
855
|
+
try:
|
|
856
|
+
# Build commit message: user message + number of tool calls + agent response
|
|
857
|
+
user_message = prompt if prompt else "continuation"
|
|
858
|
+
tool_call_count = len([msg for msg in self.chat_history if msg.get("role") == "tool"])
|
|
859
|
+
commit_message = f"User: {user_message[:100]}... | Tool calls: {tool_call_count} | Agent response"
|
|
860
|
+
|
|
861
|
+
print(f"{BLUE}\ntracking changes...{RESET}")
|
|
862
|
+
self.git_manager.add_changed_files()
|
|
863
|
+
commit_id = self.git_manager.commit(commit_message)
|
|
864
|
+
except Exception as commit_err:
|
|
865
|
+
# Don't fail the agent if commit fails, just log it
|
|
866
|
+
self.console.print(f"[yellow]Warning: Failed to commit changes: {commit_err}[/yellow]")
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
print(f"{BLUE}type /undo to undo the last code changes{RESET}")
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
history_len_before_tools = len(self.chat_history)
|
|
873
|
+
|
|
874
|
+
for toolcall in agent_msg["tool_calls"]:
|
|
875
|
+
yield from self._execute_tool_call(
|
|
876
|
+
toolcall["id"],
|
|
877
|
+
toolcall["function"]["name"],
|
|
878
|
+
toolcall["function"]["arguments"],
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
# Estimate tokens added by tool results to avoid overshooting the context window
|
|
882
|
+
# on the next API call. total_tokens is from the previous response and doesn't
|
|
883
|
+
# include tool result messages that were just appended to chat_history.
|
|
884
|
+
new_msgs = self.chat_history[history_len_before_tools:]
|
|
885
|
+
tool_result_chars = sum(len(str(msg.get("content", ""))) for msg in new_msgs)
|
|
886
|
+
estimated_total = total_tokens + tool_result_chars // 4
|
|
887
|
+
|
|
888
|
+
# Check if handover is needed after tool calls, before next API call (main agent only)
|
|
889
|
+
if not self.is_subagent and context_window and estimated_total > 0 and (context_window - estimated_total) < HANDOVER_THRESHOLD:
|
|
890
|
+
yield from self._perform_handover(estimated_total, context_window)
|
|
891
|
+
continue
|