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.
Files changed (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. 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