emdash-core 0.1.7__py3-none-any.whl → 0.1.33__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 (55) hide show
  1. emdash_core/__init__.py +6 -1
  2. emdash_core/agent/__init__.py +4 -0
  3. emdash_core/agent/events.py +52 -1
  4. emdash_core/agent/inprocess_subagent.py +123 -10
  5. emdash_core/agent/prompts/__init__.py +6 -0
  6. emdash_core/agent/prompts/main_agent.py +53 -3
  7. emdash_core/agent/prompts/plan_mode.py +255 -0
  8. emdash_core/agent/prompts/subagents.py +84 -16
  9. emdash_core/agent/prompts/workflow.py +270 -56
  10. emdash_core/agent/providers/base.py +4 -0
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/models.py +7 -0
  13. emdash_core/agent/providers/openai_provider.py +137 -13
  14. emdash_core/agent/runner/__init__.py +49 -0
  15. emdash_core/agent/runner/agent_runner.py +753 -0
  16. emdash_core/agent/runner/context.py +451 -0
  17. emdash_core/agent/runner/factory.py +108 -0
  18. emdash_core/agent/runner/plan.py +217 -0
  19. emdash_core/agent/runner/sdk_runner.py +324 -0
  20. emdash_core/agent/runner/utils.py +67 -0
  21. emdash_core/agent/skills.py +358 -0
  22. emdash_core/agent/toolkit.py +85 -5
  23. emdash_core/agent/toolkits/plan.py +9 -11
  24. emdash_core/agent/tools/__init__.py +3 -2
  25. emdash_core/agent/tools/coding.py +48 -4
  26. emdash_core/agent/tools/modes.py +207 -55
  27. emdash_core/agent/tools/search.py +4 -0
  28. emdash_core/agent/tools/skill.py +193 -0
  29. emdash_core/agent/tools/spec.py +61 -94
  30. emdash_core/agent/tools/task.py +41 -2
  31. emdash_core/agent/tools/tasks.py +15 -78
  32. emdash_core/api/agent.py +562 -8
  33. emdash_core/api/index.py +1 -1
  34. emdash_core/api/projectmd.py +4 -2
  35. emdash_core/api/router.py +2 -0
  36. emdash_core/api/skills.py +241 -0
  37. emdash_core/checkpoint/__init__.py +40 -0
  38. emdash_core/checkpoint/cli.py +175 -0
  39. emdash_core/checkpoint/git_operations.py +250 -0
  40. emdash_core/checkpoint/manager.py +231 -0
  41. emdash_core/checkpoint/models.py +107 -0
  42. emdash_core/checkpoint/storage.py +201 -0
  43. emdash_core/config.py +1 -1
  44. emdash_core/core/config.py +18 -2
  45. emdash_core/graph/schema.py +5 -5
  46. emdash_core/ingestion/orchestrator.py +19 -10
  47. emdash_core/models/agent.py +1 -1
  48. emdash_core/server.py +42 -0
  49. emdash_core/skills/frontend-design/SKILL.md +56 -0
  50. emdash_core/sse/stream.py +5 -0
  51. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -2
  52. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/RECORD +54 -37
  53. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +1 -0
  54. emdash_core/agent/runner.py +0 -601
  55. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
@@ -0,0 +1,753 @@
1
+ """Agent runner for LLM-powered exploration.
2
+
3
+ This module contains the main AgentRunner class that orchestrates
4
+ the agent loop, tool execution, and conversation management.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from typing import Any, Optional
11
+
12
+ from ...utils.logger import log
13
+ from ...core.exceptions import ContextLengthError
14
+ from ..toolkit import AgentToolkit
15
+ from ..events import AgentEventEmitter, NullEmitter
16
+ from ..providers import get_provider
17
+ from ..providers.factory import DEFAULT_MODEL
18
+ from ..context_manager import (
19
+ truncate_tool_output,
20
+ reduce_context_for_retry,
21
+ is_context_overflow_error,
22
+ )
23
+ from ..prompts import build_system_prompt
24
+ from ..tools.tasks import TaskState
25
+ from ...checkpoint import CheckpointManager
26
+
27
+ from .utils import SafeJSONEncoder, summarize_tool_result
28
+ from .context import (
29
+ estimate_context_tokens,
30
+ get_context_breakdown,
31
+ maybe_compact_context,
32
+ emit_context_frame,
33
+ )
34
+ from .plan import PlanMixin
35
+
36
+
37
+ class AgentRunner(PlanMixin):
38
+ """Runs an LLM agent with tool access for code exploration.
39
+
40
+ Example:
41
+ runner = AgentRunner()
42
+ response = runner.run("How does authentication work in this codebase?")
43
+ print(response)
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ toolkit: Optional[AgentToolkit] = None,
49
+ model: str = DEFAULT_MODEL,
50
+ system_prompt: Optional[str] = None,
51
+ emitter: Optional[AgentEventEmitter] = None,
52
+ max_iterations: int = int(os.getenv("EMDASH_MAX_ITERATIONS", "100")),
53
+ verbose: bool = False,
54
+ show_tool_results: bool = False,
55
+ enable_thinking: Optional[bool] = None,
56
+ checkpoint_manager: Optional[CheckpointManager] = None,
57
+ ):
58
+ """Initialize the agent runner.
59
+
60
+ Args:
61
+ toolkit: AgentToolkit instance. If None, creates default.
62
+ model: LLM model to use.
63
+ system_prompt: Custom system prompt. If None, uses default.
64
+ emitter: Event emitter for streaming output.
65
+ max_iterations: Maximum tool call iterations.
66
+ verbose: Whether to print verbose output.
67
+ show_tool_results: Whether to show detailed tool results.
68
+ enable_thinking: Enable extended thinking. If None, auto-detect from model.
69
+ checkpoint_manager: Optional checkpoint manager for git-based checkpoints.
70
+ """
71
+ self.toolkit = toolkit or AgentToolkit()
72
+ self.provider = get_provider(model)
73
+ self.model = model
74
+ # Build system prompt
75
+ if system_prompt:
76
+ self.system_prompt = system_prompt
77
+ else:
78
+ self.system_prompt = build_system_prompt(self.toolkit)
79
+ self.emitter = emitter or NullEmitter()
80
+ # Inject emitter into tools that need it (e.g., TaskTool for sub-agent streaming)
81
+ self.toolkit.set_emitter(self.emitter)
82
+ self.max_iterations = max_iterations
83
+ self.verbose = verbose
84
+ self.show_tool_results = show_tool_results
85
+ # Extended thinking support
86
+ if enable_thinking is None:
87
+ # Auto-detect from provider capabilities
88
+ self.enable_thinking = (
89
+ hasattr(self.provider, "supports_thinking")
90
+ and self.provider.supports_thinking()
91
+ )
92
+ else:
93
+ self.enable_thinking = enable_thinking
94
+ # Conversation history for multi-turn support
95
+ self._messages: list[dict] = []
96
+ # Token usage tracking
97
+ self._total_input_tokens: int = 0
98
+ self._total_output_tokens: int = 0
99
+ self._total_thinking_tokens: int = 0
100
+ # Store query for reranking
101
+ self._current_query: str = ""
102
+ # Todo state tracking for injection
103
+ self._last_todo_snapshot: str = ""
104
+ # Checkpoint manager for git-based checkpoints
105
+ self._checkpoint_manager = checkpoint_manager
106
+ # Track tools used during current run (for checkpoint metadata)
107
+ self._tools_used_this_run: set[str] = set()
108
+ # Plan approval state (from PlanMixin)
109
+ self._pending_plan: Optional[dict] = None
110
+
111
+ def _get_default_plan_file_path(self) -> str:
112
+ """Get the default plan file path based on repo root.
113
+
114
+ Returns:
115
+ Path to the plan file (e.g., .emdash/plan.md)
116
+ """
117
+ repo_root = self.toolkit._repo_root
118
+ return str(repo_root / ".emdash" / "plan.md")
119
+
120
+ def _get_todo_snapshot(self) -> str:
121
+ """Get current todo state as string for comparison."""
122
+ state = TaskState.get_instance()
123
+ return json.dumps(state.get_all_tasks(), sort_keys=True)
124
+
125
+ def _format_todo_reminder(self) -> str:
126
+ """Format current todos as XML reminder for injection into context."""
127
+ state = TaskState.get_instance()
128
+ tasks = state.get_all_tasks()
129
+ if not tasks:
130
+ return ""
131
+
132
+ counts = {"pending": 0, "in_progress": 0, "completed": 0}
133
+ lines = []
134
+ for t in tasks:
135
+ status = t.get("status", "pending")
136
+ counts[status] = counts.get(status, 0) + 1
137
+ status_icon = {"pending": "⬚", "in_progress": "🔄", "completed": "✅"}.get(status, "?")
138
+ lines.append(f' {t["id"]}. {status_icon} {t["title"]}')
139
+
140
+ header = f'Tasks: {counts["completed"]} completed, {counts["in_progress"]} in progress, {counts["pending"]} pending'
141
+ task_list = "\n".join(lines)
142
+ return f"<todo-state>\n{header}\n{task_list}\n</todo-state>"
143
+
144
+ def _execute_tools_parallel(self, parsed_calls: list) -> list:
145
+ """Execute multiple tool calls in parallel using a thread pool.
146
+
147
+ Args:
148
+ parsed_calls: List of (tool_call, args) tuples
149
+
150
+ Returns:
151
+ List of (tool_call, args, result) tuples in original order
152
+ """
153
+ # Emit tool start events for all calls
154
+ for tool_call, args in parsed_calls:
155
+ self.emitter.emit_tool_start(tool_call.name, args, tool_id=tool_call.id)
156
+
157
+ def execute_one(item):
158
+ tool_call, args = item
159
+ try:
160
+ result = self.toolkit.execute(tool_call.name, **args)
161
+ return (tool_call, args, result)
162
+ except Exception as e:
163
+ log.exception(f"Tool {tool_call.name} failed")
164
+ from ..tools.base import ToolResult
165
+ return (tool_call, args, ToolResult.error_result(str(e)))
166
+
167
+ # Execute in parallel with up to 3 workers
168
+ results: list = [None] * len(parsed_calls)
169
+ with ThreadPoolExecutor(max_workers=3) as executor:
170
+ futures = {executor.submit(execute_one, item): i for i, item in enumerate(parsed_calls)}
171
+ # Collect results maintaining order
172
+ for future in as_completed(futures):
173
+ idx = futures[future]
174
+ results[idx] = future.result()
175
+
176
+ # Emit tool result events for all calls
177
+ for tool_call, args, result in results:
178
+ self.emitter.emit_tool_result(
179
+ tool_call.name,
180
+ result.success,
181
+ summarize_tool_result(result),
182
+ tool_id=tool_call.id,
183
+ )
184
+
185
+ return results
186
+
187
+ def run(
188
+ self,
189
+ query: str,
190
+ context: Optional[str] = None,
191
+ images: Optional[list] = None,
192
+ ) -> str:
193
+ """Run the agent to answer a query.
194
+
195
+ Args:
196
+ query: User's question or request
197
+ context: Optional additional context
198
+ images: Optional list of images to include
199
+
200
+ Returns:
201
+ Agent's final response
202
+ """
203
+ # Store query for reranking context frame
204
+ self._current_query = query
205
+
206
+ # Reset per-cycle mode state (allows exit_plan to be called again)
207
+ from ..tools.modes import ModeState
208
+ ModeState.get_instance().reset_cycle()
209
+
210
+ # Build user message
211
+ if context:
212
+ user_message = {
213
+ "role": "user",
214
+ "content": f"Context:\n{context}\n\nQuestion: {query}",
215
+ }
216
+ else:
217
+ user_message = {
218
+ "role": "user",
219
+ "content": query,
220
+ }
221
+
222
+ # Save user message to history BEFORE running (so it's preserved even if interrupted)
223
+ self._messages.append(user_message)
224
+ messages = list(self._messages) # Copy for the loop
225
+
226
+ # TODO: Handle images if provided
227
+
228
+ # Get tool schemas
229
+ tools = self.toolkit.get_all_schemas()
230
+
231
+ try:
232
+ response, final_messages = self._run_loop(messages, tools)
233
+ # Update conversation history with full exchange
234
+ self._messages = final_messages
235
+ self.emitter.emit_end(success=True)
236
+ # Create checkpoint if manager is configured
237
+ self._create_checkpoint()
238
+ return response
239
+
240
+ except Exception as e:
241
+ log.exception("Agent run failed")
242
+ self.emitter.emit_error(str(e))
243
+ # Keep user message in history even on error (already appended above)
244
+ return f"Error: {str(e)}"
245
+
246
+ def _run_loop(
247
+ self,
248
+ messages: list[dict],
249
+ tools: list[dict],
250
+ ) -> tuple[str, list[dict]]:
251
+ """Run the agent loop until completion.
252
+
253
+ Args:
254
+ messages: Initial messages
255
+ tools: Tool schemas
256
+
257
+ Returns:
258
+ Tuple of (final response text, conversation messages)
259
+ """
260
+ max_retries = 3
261
+
262
+ for iteration in range(self.max_iterations):
263
+ # When approaching max iterations, ask agent to wrap up
264
+ if iteration == self.max_iterations - 2:
265
+ messages.append({
266
+ "role": "user",
267
+ "content": "[SYSTEM: You are approaching your iteration limit. Please provide your findings and conclusions now, even if incomplete. Summarize what you've learned and any recommendations.]",
268
+ })
269
+
270
+ # Try API call with retry on context overflow
271
+ retry_count = 0
272
+ response = None
273
+
274
+ while retry_count < max_retries:
275
+ try:
276
+ # Proactively compact context if approaching limit
277
+ messages = maybe_compact_context(
278
+ messages, self.provider, self.emitter, self.system_prompt
279
+ )
280
+
281
+ log.debug(
282
+ "Calling LLM iteration={} messages={} tools={}",
283
+ iteration,
284
+ len(messages),
285
+ len(tools) if tools else 0,
286
+ )
287
+
288
+ response = self.provider.chat(
289
+ messages=messages,
290
+ system=self.system_prompt,
291
+ tools=tools,
292
+ thinking=self.enable_thinking,
293
+ )
294
+
295
+ log.debug("LLM response received iteration={}", iteration)
296
+ break # Success
297
+
298
+ except Exception as exc:
299
+ if is_context_overflow_error(exc):
300
+ retry_count += 1
301
+ log.warning(
302
+ "Context overflow on attempt {}/{}, reducing context...",
303
+ retry_count,
304
+ max_retries,
305
+ )
306
+
307
+ if retry_count >= max_retries:
308
+ raise ContextLengthError(
309
+ f"Failed to reduce context after {max_retries} attempts: {exc}",
310
+ )
311
+
312
+ # Reduce context by removing old messages
313
+ messages = reduce_context_for_retry(
314
+ messages,
315
+ keep_recent=max(2, 6 - retry_count * 2), # Fewer messages each retry
316
+ )
317
+ else:
318
+ raise # Re-raise non-context errors
319
+
320
+ if response is None:
321
+ raise RuntimeError("Failed to get response from provider")
322
+
323
+ # Accumulate token usage
324
+ self._total_input_tokens += response.input_tokens
325
+ self._total_output_tokens += response.output_tokens
326
+ self._total_thinking_tokens += getattr(response, "thinking_tokens", 0)
327
+
328
+ # Emit thinking if present
329
+ if response.thinking:
330
+ self.emitter.emit_thinking(response.thinking)
331
+
332
+ # Check for tool calls
333
+ if response.tool_calls:
334
+ # Emit assistant text if present (shown as bullets between tool calls)
335
+ if response.content and response.content.strip():
336
+ self.emitter.emit_assistant_text(response.content)
337
+
338
+ # Track if we need to pause for user input
339
+ needs_user_input = False
340
+
341
+ # Parse all tool call arguments first
342
+ parsed_calls = []
343
+ for tool_call in response.tool_calls:
344
+ args = tool_call.arguments
345
+ if isinstance(args, str):
346
+ args = json.loads(args)
347
+ parsed_calls.append((tool_call, args))
348
+
349
+ # CRITICAL: Check if exit_plan is in the batch - if so, execute it FIRST
350
+ # and skip all other tools. This prevents the agent from continuing to
351
+ # work after submitting a plan.
352
+ exit_plan_idx = None
353
+ for i, (tc, _) in enumerate(parsed_calls):
354
+ if tc.name == "exit_plan":
355
+ exit_plan_idx = i
356
+ break
357
+
358
+ if exit_plan_idx is not None:
359
+ # Execute ONLY exit_plan, skip everything else
360
+ exit_call, exit_args = parsed_calls[exit_plan_idx]
361
+ self.emitter.emit_tool_start(exit_call.name, exit_args, tool_id=exit_call.id)
362
+ exit_result = self.toolkit.execute(exit_call.name, **exit_args)
363
+ self.emitter.emit_tool_result(
364
+ exit_call.name,
365
+ exit_result.success,
366
+ summarize_tool_result(exit_result),
367
+ tool_id=exit_call.id,
368
+ )
369
+
370
+ # Build results list with exit_plan result and skipped placeholders
371
+ results = []
372
+ from ..tools.base import ToolResult
373
+ for i, (tc, args) in enumerate(parsed_calls):
374
+ if i == exit_plan_idx:
375
+ results.append((tc, args, exit_result))
376
+ else:
377
+ # Skip this tool - don't execute it
378
+ log.warning(f"Skipping tool {tc.name} - exit_plan takes priority")
379
+ skip_result = ToolResult.error_result(
380
+ "Tool skipped: exit_plan was called. Agent must stop and wait for user approval."
381
+ )
382
+ results.append((tc, args, skip_result))
383
+
384
+ elif len(parsed_calls) > 1:
385
+ # No exit_plan - execute tools in parallel
386
+ results = self._execute_tools_parallel(parsed_calls)
387
+ else:
388
+ # Single tool - execute directly
389
+ tool_call, args = parsed_calls[0]
390
+ self.emitter.emit_tool_start(tool_call.name, args, tool_id=tool_call.id)
391
+ result = self.toolkit.execute(tool_call.name, **args)
392
+ self.emitter.emit_tool_result(
393
+ tool_call.name,
394
+ result.success,
395
+ summarize_tool_result(result),
396
+ tool_id=tool_call.id,
397
+ )
398
+ results = [(tool_call, args, result)]
399
+
400
+ # Track if we need to rebuild toolkit for mode change
401
+ mode_changed = False
402
+
403
+ # CRITICAL FIX: Add ONE assistant message with ALL tool calls
404
+ # This prevents the LLM from seeing multiple assistant messages
405
+ # which causes it to loop repeating the same tools
406
+ all_tool_calls = []
407
+ for tool_call, args in parsed_calls:
408
+ all_tool_calls.append({
409
+ "id": tool_call.id,
410
+ "type": "function",
411
+ "function": {
412
+ "name": tool_call.name,
413
+ "arguments": json.dumps(args),
414
+ },
415
+ })
416
+
417
+ messages.append({
418
+ "role": "assistant",
419
+ "content": response.content or "",
420
+ "tool_calls": all_tool_calls,
421
+ })
422
+
423
+ # Now process results and add tool result messages
424
+ for tool_call, args, result in results:
425
+ # Track tool for checkpoint metadata
426
+ self._tools_used_this_run.add(tool_call.name)
427
+
428
+ # Check if tool is asking a clarification question
429
+ if (result.success and
430
+ result.data and
431
+ result.data.get("status") == "awaiting_response" and
432
+ "question" in result.data):
433
+ self.emitter.emit_clarification(
434
+ question=result.data["question"],
435
+ context="",
436
+ options=result.data.get("options", []),
437
+ )
438
+ needs_user_input = True
439
+
440
+ # Check if agent entered plan mode
441
+ if (result.success and
442
+ result.data and
443
+ result.data.get("status") == "entered_plan_mode"):
444
+ mode_changed = True
445
+ # Get plan file path
446
+ plan_file_path = self._get_default_plan_file_path()
447
+ from ..tools.modes import ModeState
448
+ ModeState.get_instance().set_plan_file_path(plan_file_path)
449
+ # Rebuild toolkit with plan_mode=True
450
+ self.toolkit = AgentToolkit(
451
+ connection=self.toolkit.connection,
452
+ repo_root=self.toolkit._repo_root,
453
+ plan_mode=True,
454
+ plan_file_path=plan_file_path,
455
+ )
456
+ self.toolkit.set_emitter(self.emitter)
457
+ # Main agent uses normal prompt - delegates to Plan subagent
458
+ self.system_prompt = build_system_prompt(self.toolkit)
459
+ # Update tools for LLM
460
+ tools = self.toolkit.get_all_schemas()
461
+
462
+ # Check if agent requested to enter plan mode (enter_plan_mode)
463
+ if (result.success and
464
+ result.data and
465
+ result.data.get("status") == "plan_mode_requested"):
466
+ # Emit event for UI to show approval dialog
467
+ self.emitter.emit_plan_mode_requested(
468
+ reason=result.data.get("reason", ""),
469
+ )
470
+ # Pause and wait for user approval
471
+ needs_user_input = True
472
+
473
+ # Check if tool is submitting a plan for approval (exit_plan)
474
+ if (result.success and
475
+ result.data and
476
+ result.data.get("status") == "plan_submitted"):
477
+ # Store the pending plan (simple string)
478
+ self._pending_plan = {
479
+ "plan": result.data.get("plan", ""),
480
+ }
481
+ self.emitter.emit_plan_submitted(
482
+ plan=self._pending_plan["plan"],
483
+ )
484
+ # Pause and wait for approval (similar to clarification flow)
485
+ needs_user_input = True
486
+
487
+ # Serialize and truncate tool result to prevent context overflow
488
+ result_json = json.dumps(result.to_dict(), cls=SafeJSONEncoder)
489
+ result_json = truncate_tool_output(result_json)
490
+
491
+ # Check if todos changed and inject reminder
492
+ if tool_call.name in ("write_todo", "update_todo_list"):
493
+ new_snapshot = self._get_todo_snapshot()
494
+ if new_snapshot != self._last_todo_snapshot:
495
+ self._last_todo_snapshot = new_snapshot
496
+ reminder = self._format_todo_reminder()
497
+ if reminder:
498
+ result_json += f"\n\n{reminder}"
499
+
500
+ # Add tool result
501
+ messages.append({
502
+ "role": "tool",
503
+ "tool_call_id": tool_call.id,
504
+ "content": result_json,
505
+ })
506
+
507
+ # If a clarification question was asked, pause and wait for user input
508
+ if needs_user_input:
509
+ log.debug("Pausing agent loop - waiting for user input")
510
+ return "", messages
511
+
512
+ else:
513
+ # No tool calls - check if response was truncated
514
+ if response.stop_reason in ("max_tokens", "length"):
515
+ # Response was truncated, request continuation
516
+ log.debug("Response truncated ({}), requesting continuation", response.stop_reason)
517
+ if response.content:
518
+ messages.append({
519
+ "role": "assistant",
520
+ "content": response.content,
521
+ })
522
+ messages.append({
523
+ "role": "user",
524
+ "content": "Your response was cut off. Please continue.",
525
+ })
526
+ continue
527
+
528
+ # PLAN MODE ENFORCEMENT: In plan mode, reject text-only responses
529
+ # Force the model to use tools (task, ask_followup_question, exit_plan)
530
+ if self.toolkit.plan_mode:
531
+ log.warning("Plan mode: Agent output text without tools, forcing tool usage")
532
+ if response.content:
533
+ messages.append({
534
+ "role": "assistant",
535
+ "content": response.content,
536
+ })
537
+ messages.append({
538
+ "role": "user",
539
+ "content": """[SYSTEM ERROR] You are in plan mode but did not use any tools.
540
+
541
+ In plan mode you MUST use tools - text-only responses are not allowed.
542
+
543
+ YOUR REQUIRED ACTION NOW:
544
+ Use your exploration tools directly to investigate the codebase:
545
+
546
+ - glob(pattern="**/*.py") - Find files by pattern
547
+ - grep(pattern="class User", path="src/") - Search file contents
548
+ - read_file(path="path/to/file.py") - Read specific files
549
+ - semantic_search(query="authentication") - Find conceptually related code
550
+
551
+ You ARE the planner. Use tools to explore, then write your plan and call exit_plan.
552
+
553
+ DO NOT output more text. Use a tool NOW.""",
554
+ })
555
+ continue # Force another iteration with tool usage
556
+
557
+ # Agent is done - emit final response
558
+ if response.content:
559
+ self.emitter.emit_message_start()
560
+ self.emitter.emit_message_delta(response.content)
561
+ self.emitter.emit_message_end()
562
+ # Add final assistant message to history
563
+ messages.append({
564
+ "role": "assistant",
565
+ "content": response.content,
566
+ })
567
+ # Emit final context frame summary
568
+ self._emit_context_frame(messages)
569
+ return response.content, messages
570
+
571
+ # Agent finished without providing a summary - request one
572
+ log.debug("Agent finished without summary, requesting completion message")
573
+ try:
574
+ summary_response = self.provider.chat(
575
+ messages=messages + [{
576
+ "role": "user",
577
+ "content": "[SYSTEM: You have completed your task. Please provide a brief summary of what you accomplished.]",
578
+ }],
579
+ system=self.system_prompt,
580
+ tools=None, # No tools - force text response
581
+ thinking=self.enable_thinking,
582
+ )
583
+ if summary_response.content:
584
+ self.emitter.emit_message_start()
585
+ self.emitter.emit_message_delta(summary_response.content)
586
+ self.emitter.emit_message_end()
587
+ self._emit_context_frame(messages)
588
+ return summary_response.content, messages
589
+ except Exception as e:
590
+ log.warning(f"Failed to get completion summary: {e}")
591
+
592
+ # Fallback if summary request fails
593
+ fallback_message = "Task completed."
594
+ self.emitter.emit_message_start()
595
+ self.emitter.emit_message_delta(fallback_message)
596
+ self.emitter.emit_message_end()
597
+ self._emit_context_frame(messages)
598
+ return fallback_message, messages
599
+
600
+ # Hit max iterations - try one final request without tools to force a response
601
+ try:
602
+ final_response = self.provider.chat(
603
+ messages=messages + [{
604
+ "role": "user",
605
+ "content": "[SYSTEM: Maximum iterations reached. Provide your final response now with whatever information you have gathered. Do not use any tools.]",
606
+ }],
607
+ system=self.system_prompt,
608
+ tools=None, # No tools - force text response
609
+ thinking=self.enable_thinking,
610
+ )
611
+ # Emit thinking if present
612
+ if final_response.thinking:
613
+ self.emitter.emit_thinking(final_response.thinking)
614
+ if final_response.content:
615
+ self.emitter.emit_message_start()
616
+ self.emitter.emit_message_delta(final_response.content)
617
+ self.emitter.emit_message_end()
618
+ self._emit_context_frame(messages)
619
+ return final_response.content, messages
620
+ except Exception as e:
621
+ log.warning(f"Failed to get final response: {e}")
622
+
623
+ # Fallback message if final response fails
624
+ final_message = "Reached maximum iterations. The agent was unable to complete the task within the allowed iterations."
625
+ self.emitter.emit_message_start()
626
+ self.emitter.emit_message_delta(final_message)
627
+ self.emitter.emit_message_end()
628
+ self._emit_context_frame(messages)
629
+ return final_message, messages
630
+
631
+ def _emit_context_frame(self, messages: list[dict] | None = None) -> None:
632
+ """Emit a context frame event with current exploration state.
633
+
634
+ Args:
635
+ messages: Current conversation messages to estimate context size
636
+ """
637
+ emit_context_frame(
638
+ toolkit=self.toolkit,
639
+ emitter=self.emitter,
640
+ messages=messages or [],
641
+ system_prompt=self.system_prompt,
642
+ current_query=self._current_query,
643
+ total_input_tokens=self._total_input_tokens,
644
+ total_output_tokens=self._total_output_tokens,
645
+ )
646
+
647
+ def chat(self, message: str, images: Optional[list] = None) -> str:
648
+ """Continue a conversation with a new message.
649
+
650
+ This method maintains conversation history for multi-turn interactions.
651
+ Call run() first to start a conversation, then chat() for follow-ups.
652
+
653
+ Args:
654
+ message: User's follow-up message
655
+ images: Optional list of images to include
656
+
657
+ Returns:
658
+ Agent's response
659
+ """
660
+ if not self._messages:
661
+ # No history, just run fresh
662
+ return self.run(message, images=images)
663
+
664
+ # Store query for reranking context frame
665
+ self._current_query = message
666
+
667
+ # Add new user message to history
668
+ self._messages.append({
669
+ "role": "user",
670
+ "content": message,
671
+ })
672
+
673
+ # Get tool schemas
674
+ tools = self.toolkit.get_all_schemas()
675
+
676
+ try:
677
+ response, final_messages = self._run_loop(self._messages, tools)
678
+ # Update conversation history
679
+ self._messages = final_messages
680
+ self.emitter.emit_end(success=True)
681
+ # Create checkpoint if manager is configured
682
+ self._create_checkpoint()
683
+ return response
684
+
685
+ except Exception as e:
686
+ log.exception("Agent chat failed")
687
+ self.emitter.emit_error(str(e))
688
+ return f"Error: {str(e)}"
689
+
690
+ def _create_checkpoint(self) -> None:
691
+ """Create a git checkpoint after successful run.
692
+
693
+ Only creates a checkpoint if:
694
+ - A checkpoint manager is configured
695
+ - There are file changes to commit
696
+ """
697
+ if not self._checkpoint_manager:
698
+ return
699
+
700
+ try:
701
+ self._checkpoint_manager.create_checkpoint(
702
+ messages=self._messages,
703
+ model=self.model,
704
+ system_prompt=self.system_prompt,
705
+ tools_used=list(self._tools_used_this_run),
706
+ token_usage={
707
+ "input": self._total_input_tokens,
708
+ "output": self._total_output_tokens,
709
+ "thinking": self._total_thinking_tokens,
710
+ },
711
+ )
712
+ except Exception as e:
713
+ log.warning(f"Failed to create checkpoint: {e}")
714
+ finally:
715
+ # Clear tools for next run
716
+ self._tools_used_this_run.clear()
717
+
718
+ def reset(self) -> None:
719
+ """Reset the agent state."""
720
+ self.toolkit.reset_session()
721
+ self._total_input_tokens = 0
722
+ self._total_output_tokens = 0
723
+ self._current_query = ""
724
+
725
+ def answer_clarification(self, answer: str) -> str:
726
+ """Answer a pending clarification question and resume the agent.
727
+
728
+ This method is called when the user responds to a clarification question
729
+ asked via ask_followup_question tool. It clears the pending question state
730
+ and resumes the agent loop with the user's answer.
731
+
732
+ Args:
733
+ answer: The user's answer to the clarification question
734
+
735
+ Returns:
736
+ Agent's response after processing the answer
737
+ """
738
+ # Get current task state and clear the pending question
739
+ state = TaskState.get_instance()
740
+ pending_question = state.pending_question
741
+
742
+ # Clear the pending question state
743
+ state.pending_question = None
744
+ state.user_response = answer
745
+
746
+ # Build a context message that indicates this is the answer to the question
747
+ if pending_question:
748
+ context_message = f"[User answered the clarification question]\nQuestion: {pending_question}\nAnswer: {answer}"
749
+ else:
750
+ context_message = f"[User response]: {answer}"
751
+
752
+ # Continue the conversation with the answer
753
+ return self.chat(context_message)