emdash-core 0.1.7__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 (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,601 @@
1
+ """Agent runner for LLM-powered exploration."""
2
+
3
+ import json
4
+ from datetime import datetime, date
5
+ from typing import Any, Optional
6
+
7
+ from ..utils.logger import log
8
+ from ..core.config import get_config
9
+ from ..core.exceptions import ContextLengthError
10
+ from .toolkit import AgentToolkit
11
+ from .events import AgentEventEmitter, NullEmitter
12
+ from .providers import get_provider
13
+ from .providers.factory import DEFAULT_MODEL
14
+ from .context_manager import (
15
+ truncate_tool_output,
16
+ reduce_context_for_retry,
17
+ is_context_overflow_error,
18
+ )
19
+ from .prompts import BASE_SYSTEM_PROMPT, build_system_prompt
20
+ from .tools.tasks import TaskState
21
+
22
+
23
+ class SafeJSONEncoder(json.JSONEncoder):
24
+ """JSON encoder that handles Neo4j types and other non-serializable objects."""
25
+
26
+ def default(self, obj: Any) -> Any:
27
+ # Handle datetime objects
28
+ if isinstance(obj, (datetime, date)):
29
+ return obj.isoformat()
30
+
31
+ # Handle Neo4j DateTime
32
+ if hasattr(obj, 'isoformat'):
33
+ return obj.isoformat()
34
+
35
+ # Handle Neo4j Date, Time, etc.
36
+ if hasattr(obj, 'to_native'):
37
+ return str(obj.to_native())
38
+
39
+ # Handle sets
40
+ if isinstance(obj, set):
41
+ return list(obj)
42
+
43
+ # Handle bytes
44
+ if isinstance(obj, bytes):
45
+ return obj.decode('utf-8', errors='replace')
46
+
47
+ # Fallback to string representation
48
+ try:
49
+ return str(obj)
50
+ except Exception:
51
+ return f"<non-serializable: {type(obj).__name__}>"
52
+
53
+
54
+ class AgentRunner:
55
+ """Runs an LLM agent with tool access for code exploration.
56
+
57
+ Example:
58
+ runner = AgentRunner()
59
+ response = runner.run("How does authentication work in this codebase?")
60
+ print(response)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ toolkit: Optional[AgentToolkit] = None,
66
+ model: str = DEFAULT_MODEL,
67
+ system_prompt: Optional[str] = None,
68
+ emitter: Optional[AgentEventEmitter] = None,
69
+ max_iterations: int = 50,
70
+ verbose: bool = False,
71
+ show_tool_results: bool = False,
72
+ ):
73
+ """Initialize the agent runner.
74
+
75
+ Args:
76
+ toolkit: AgentToolkit instance. If None, creates default.
77
+ model: LLM model to use.
78
+ system_prompt: Custom system prompt. If None, uses default.
79
+ emitter: Event emitter for streaming output.
80
+ max_iterations: Maximum tool call iterations.
81
+ verbose: Whether to print verbose output.
82
+ show_tool_results: Whether to show detailed tool results.
83
+ """
84
+ self.toolkit = toolkit or AgentToolkit()
85
+ self.provider = get_provider(model)
86
+ self.model = model
87
+ self.system_prompt = system_prompt or build_system_prompt(self.toolkit)
88
+ self.emitter = emitter or NullEmitter()
89
+ # Inject emitter into tools that need it (e.g., TaskTool for sub-agent streaming)
90
+ self.toolkit.set_emitter(self.emitter)
91
+ self.max_iterations = max_iterations
92
+ self.verbose = verbose
93
+ self.show_tool_results = show_tool_results
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
+ # Store query for reranking
100
+ self._current_query: str = ""
101
+ # Todo state tracking for injection
102
+ self._last_todo_snapshot: str = ""
103
+
104
+ def _get_todo_snapshot(self) -> str:
105
+ """Get current todo state as string for comparison."""
106
+ state = TaskState.get_instance()
107
+ return json.dumps(state.get_all_tasks(), sort_keys=True)
108
+
109
+ def _format_todo_reminder(self) -> str:
110
+ """Format current todos as XML reminder for injection into context."""
111
+ state = TaskState.get_instance()
112
+ tasks = state.get_all_tasks()
113
+ if not tasks:
114
+ return ""
115
+
116
+ counts = {"pending": 0, "in_progress": 0, "completed": 0}
117
+ lines = []
118
+ for t in tasks:
119
+ status = t.get("status", "pending")
120
+ counts[status] = counts.get(status, 0) + 1
121
+ status_icon = {"pending": "⬚", "in_progress": "🔄", "completed": "✅"}.get(status, "?")
122
+ lines.append(f' {t["id"]}. {status_icon} {t["title"]}')
123
+
124
+ header = f'Tasks: {counts["completed"]} completed, {counts["in_progress"]} in progress, {counts["pending"]} pending'
125
+ task_list = "\n".join(lines)
126
+ return f"<todo-state>\n{header}\n{task_list}\n</todo-state>"
127
+
128
+ def run(
129
+ self,
130
+ query: str,
131
+ context: Optional[str] = None,
132
+ images: Optional[list] = None,
133
+ ) -> str:
134
+ """Run the agent to answer a query.
135
+
136
+ Args:
137
+ query: User's question or request
138
+ context: Optional additional context
139
+ images: Optional list of images to include
140
+
141
+ Returns:
142
+ Agent's final response
143
+ """
144
+ # Store query for reranking context frame
145
+ self._current_query = query
146
+ self.emitter.emit_start(goal=query)
147
+
148
+ # Build messages
149
+ messages = []
150
+
151
+ if context:
152
+ messages.append({
153
+ "role": "user",
154
+ "content": f"Context:\n{context}\n\nQuestion: {query}",
155
+ })
156
+ else:
157
+ messages.append({
158
+ "role": "user",
159
+ "content": query,
160
+ })
161
+
162
+ # TODO: Handle images if provided
163
+
164
+ # Get tool schemas
165
+ tools = self.toolkit.get_all_schemas()
166
+
167
+ try:
168
+ response, final_messages = self._run_loop(messages, tools)
169
+ # Save conversation history for multi-turn support
170
+ self._messages = final_messages
171
+ self.emitter.emit_end(success=True)
172
+ return response
173
+
174
+ except Exception as e:
175
+ log.exception("Agent run failed")
176
+ self.emitter.emit_error(str(e))
177
+ return f"Error: {str(e)}"
178
+
179
+ def _run_loop(
180
+ self,
181
+ messages: list[dict],
182
+ tools: list[dict],
183
+ ) -> tuple[str, list[dict]]:
184
+ """Run the agent loop until completion.
185
+
186
+ Args:
187
+ messages: Initial messages
188
+ tools: Tool schemas
189
+
190
+ Returns:
191
+ Tuple of (final response text, conversation messages)
192
+ """
193
+ max_retries = 3
194
+
195
+ for iteration in range(self.max_iterations):
196
+ # Try API call with retry on context overflow
197
+ retry_count = 0
198
+ response = None
199
+
200
+ while retry_count < max_retries:
201
+ try:
202
+ response = self.provider.chat(
203
+ messages=messages,
204
+ system=self.system_prompt,
205
+ tools=tools,
206
+ )
207
+ break # Success
208
+
209
+ except Exception as exc:
210
+ if is_context_overflow_error(exc):
211
+ retry_count += 1
212
+ log.warning(
213
+ "Context overflow on attempt {}/{}, reducing context...",
214
+ retry_count,
215
+ max_retries,
216
+ )
217
+
218
+ if retry_count >= max_retries:
219
+ raise ContextLengthError(
220
+ f"Failed to reduce context after {max_retries} attempts: {exc}",
221
+ )
222
+
223
+ # Reduce context by removing old messages
224
+ messages = reduce_context_for_retry(
225
+ messages,
226
+ keep_recent=max(2, 6 - retry_count * 2), # Fewer messages each retry
227
+ )
228
+ else:
229
+ raise # Re-raise non-context errors
230
+
231
+ if response is None:
232
+ raise RuntimeError("Failed to get response from provider")
233
+
234
+ # Accumulate token usage
235
+ self._total_input_tokens += response.input_tokens
236
+ self._total_output_tokens += response.output_tokens
237
+
238
+ # Check for tool calls
239
+ if response.tool_calls:
240
+ # Emit any content that accompanies tool calls (e.g., thinking text)
241
+ if response.content:
242
+ self.emitter.emit_message_start()
243
+ self.emitter.emit_message_delta(response.content)
244
+ self.emitter.emit_message_end()
245
+
246
+ # Execute tools and add results
247
+ for tool_call in response.tool_calls:
248
+ # Parse arguments if they're a JSON string
249
+ args = tool_call.arguments
250
+ if isinstance(args, str):
251
+ args = json.loads(args)
252
+
253
+ self.emitter.emit_tool_start(
254
+ tool_call.name,
255
+ args,
256
+ )
257
+
258
+ result = self.toolkit.execute(
259
+ tool_call.name,
260
+ **args,
261
+ )
262
+
263
+ self.emitter.emit_tool_result(
264
+ tool_call.name,
265
+ result.success,
266
+ self._summarize_result(result),
267
+ )
268
+
269
+ # Check if tool is asking a clarification question
270
+ if (result.success and
271
+ result.data and
272
+ result.data.get("status") == "awaiting_response" and
273
+ "question" in result.data):
274
+ self.emitter.emit_clarification(
275
+ question=result.data["question"],
276
+ context="",
277
+ options=result.data.get("options", []),
278
+ )
279
+
280
+ # Add assistant message with tool call
281
+ messages.append({
282
+ "role": "assistant",
283
+ "content": response.content or "",
284
+ "tool_calls": [{
285
+ "id": tool_call.id,
286
+ "type": "function",
287
+ "function": {
288
+ "name": tool_call.name,
289
+ "arguments": json.dumps(args),
290
+ },
291
+ }],
292
+ })
293
+
294
+ # Serialize and truncate tool result to prevent context overflow
295
+ result_json = json.dumps(result.to_dict(), cls=SafeJSONEncoder)
296
+ result_json = truncate_tool_output(result_json)
297
+
298
+ # Check if todos changed and inject reminder
299
+ if tool_call.name in ("write_todo", "update_todo_list"):
300
+ new_snapshot = self._get_todo_snapshot()
301
+ if new_snapshot != self._last_todo_snapshot:
302
+ self._last_todo_snapshot = new_snapshot
303
+ reminder = self._format_todo_reminder()
304
+ if reminder:
305
+ result_json += f"\n\n{reminder}"
306
+
307
+ # Add tool result
308
+ messages.append({
309
+ "role": "tool",
310
+ "tool_call_id": tool_call.id,
311
+ "content": result_json,
312
+ })
313
+
314
+
315
+ else:
316
+ # No tool calls - check if response was truncated
317
+ if response.stop_reason in ("max_tokens", "length"):
318
+ # Response was truncated, request continuation
319
+ log.debug("Response truncated ({}), requesting continuation", response.stop_reason)
320
+ if response.content:
321
+ messages.append({
322
+ "role": "assistant",
323
+ "content": response.content,
324
+ })
325
+ messages.append({
326
+ "role": "user",
327
+ "content": "Your response was cut off. Please continue.",
328
+ })
329
+ continue
330
+
331
+ # Agent is done - emit final response
332
+ if response.content:
333
+ self.emitter.emit_message_start()
334
+ self.emitter.emit_message_delta(response.content)
335
+ self.emitter.emit_message_end()
336
+ # Add final assistant message to history
337
+ messages.append({
338
+ "role": "assistant",
339
+ "content": response.content,
340
+ })
341
+
342
+ # Emit final context frame summary
343
+ self._emit_context_frame(messages)
344
+
345
+ return response.content or "", messages
346
+
347
+ # Hit max iterations - emit final message and context frame
348
+ final_message = "Reached maximum iterations without completing."
349
+ self.emitter.emit_message_start()
350
+ self.emitter.emit_message_delta(final_message)
351
+ self.emitter.emit_message_end()
352
+ self._emit_context_frame(messages)
353
+ return final_message, messages
354
+
355
+ def _summarize_result(self, result: Any) -> str:
356
+ """Create a brief summary of a tool result."""
357
+ if not result.success:
358
+ return f"Error: {result.error}"
359
+
360
+ if not result.data:
361
+ return "Empty result"
362
+
363
+ data = result.data
364
+
365
+ if "results" in data:
366
+ return f"{len(data['results'])} results"
367
+ elif "root_node" in data:
368
+ node = data["root_node"]
369
+ name = node.get("qualified_name") or node.get("file_path", "unknown")
370
+ return f"Expanded: {name}"
371
+ elif "callers" in data:
372
+ return f"{len(data['callers'])} callers"
373
+ elif "callees" in data:
374
+ return f"{len(data['callees'])} callees"
375
+
376
+ return "Completed"
377
+
378
+ def _emit_context_frame(self, messages: list[dict] = None) -> None:
379
+ """Emit a context frame event with current exploration state.
380
+
381
+ Args:
382
+ messages: Current conversation messages to estimate context size
383
+ """
384
+ # Get exploration steps from toolkit session
385
+ steps = self.toolkit.get_exploration_steps()
386
+
387
+ # Estimate current context window tokens
388
+ context_tokens = 0
389
+ if messages:
390
+ context_tokens = self._estimate_context_tokens(messages)
391
+
392
+ # Summarize exploration by tool
393
+ tool_counts: dict[str, int] = {}
394
+ entities_found = 0
395
+ step_details: list[dict] = []
396
+
397
+ for step in steps:
398
+ tool_name = getattr(step, 'tool', 'unknown')
399
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
400
+
401
+ # Count entities from the step
402
+ step_entities = getattr(step, 'entities_found', [])
403
+ entities_found += len(step_entities)
404
+
405
+ # Collect step details
406
+ params = getattr(step, 'params', {})
407
+ summary = getattr(step, 'result_summary', '')
408
+
409
+ # Extract meaningful info based on tool type
410
+ detail = {
411
+ "tool": tool_name,
412
+ "summary": summary,
413
+ }
414
+
415
+ # Add relevant params based on tool
416
+ if tool_name == 'read_file' and 'file_path' in params:
417
+ detail["file"] = params['file_path']
418
+ elif tool_name == 'read_file' and 'path' in params:
419
+ detail["file"] = params['path']
420
+ elif tool_name in ('grep', 'semantic_search') and 'query' in params:
421
+ detail["query"] = params['query']
422
+ elif tool_name == 'glob' and 'pattern' in params:
423
+ detail["pattern"] = params['pattern']
424
+ elif tool_name == 'list_files' and 'path' in params:
425
+ detail["path"] = params['path']
426
+
427
+ # Add content preview if available
428
+ content_preview = getattr(step, 'content_preview', None)
429
+ if content_preview:
430
+ detail["content_preview"] = content_preview
431
+
432
+ # Add token count if available
433
+ token_count = getattr(step, 'token_count', 0)
434
+ if token_count > 0:
435
+ detail["tokens"] = token_count
436
+
437
+ # Add entities if any
438
+ if step_entities:
439
+ detail["entities"] = step_entities[:5] # Limit to 5
440
+
441
+ step_details.append(detail)
442
+
443
+ exploration_steps = [
444
+ {"tool": tool, "count": count}
445
+ for tool, count in tool_counts.items()
446
+ ]
447
+
448
+ # Build context frame data
449
+ adding = {
450
+ "exploration_steps": exploration_steps,
451
+ "entities_found": entities_found,
452
+ "step_count": len(steps),
453
+ "details": step_details[-20:], # Last 20 steps
454
+ "input_tokens": self._total_input_tokens,
455
+ "output_tokens": self._total_output_tokens,
456
+ "context_tokens": context_tokens, # Current context window size
457
+ }
458
+
459
+ # Get reranked context items
460
+ reading = self._get_reranked_context()
461
+
462
+ # Emit the context frame
463
+ self.emitter.emit_context_frame(adding=adding, reading=reading)
464
+
465
+ def _estimate_context_tokens(self, messages: list[dict]) -> int:
466
+ """Estimate the current context window size in tokens.
467
+
468
+ Args:
469
+ messages: Conversation messages
470
+
471
+ Returns:
472
+ Estimated token count for the context
473
+ """
474
+ total_chars = 0
475
+
476
+ # Count characters in all messages
477
+ for msg in messages:
478
+ content = msg.get("content", "")
479
+ if isinstance(content, str):
480
+ total_chars += len(content)
481
+ elif isinstance(content, list):
482
+ # Handle multi-part messages (e.g., with images)
483
+ for part in content:
484
+ if isinstance(part, dict) and "text" in part:
485
+ total_chars += len(part["text"])
486
+
487
+ # Add role overhead (~4 tokens per message for role/structure)
488
+ total_chars += 16
489
+
490
+ # Also count system prompt
491
+ if self.system_prompt:
492
+ total_chars += len(self.system_prompt)
493
+
494
+ # Estimate: ~4 characters per token
495
+ return total_chars // 4
496
+
497
+ def _get_reranked_context(self) -> dict:
498
+ """Get reranked context items based on the current query.
499
+
500
+ Returns:
501
+ Dict with item_count and items list
502
+ """
503
+ try:
504
+ from ..context.service import ContextService
505
+ from ..context.reranker import rerank_context_items
506
+
507
+ # Get exploration steps for context extraction
508
+ steps = self.toolkit.get_exploration_steps()
509
+ if not steps:
510
+ return {"item_count": 0, "items": []}
511
+
512
+ # Use context service to extract context items from exploration
513
+ service = ContextService(connection=self.toolkit.connection)
514
+ terminal_id = service.get_terminal_id()
515
+
516
+ # Update context with exploration steps
517
+ service.update_context(
518
+ terminal_id=terminal_id,
519
+ exploration_steps=steps,
520
+ )
521
+
522
+ # Get context items
523
+ items = service.get_context_items(terminal_id)
524
+ if not items:
525
+ return {"item_count": 0, "items": []}
526
+
527
+ # Rerank by query relevance
528
+ if self._current_query:
529
+ items = rerank_context_items(
530
+ items,
531
+ self._current_query,
532
+ top_k=20,
533
+ )
534
+
535
+ # Convert to serializable format
536
+ result_items = []
537
+ for item in items[:20]: # Limit to 20 items
538
+ result_items.append({
539
+ "name": item.qualified_name,
540
+ "type": item.entity_type,
541
+ "file": item.file_path,
542
+ "score": round(item.score, 3) if hasattr(item, 'score') else None,
543
+ })
544
+
545
+ return {
546
+ "item_count": len(result_items),
547
+ "items": result_items,
548
+ }
549
+
550
+ except Exception as e:
551
+ log.debug(f"Failed to get reranked context: {e}")
552
+ return {"item_count": 0, "items": []}
553
+
554
+ def chat(self, message: str, images: Optional[list] = None) -> str:
555
+ """Continue a conversation with a new message.
556
+
557
+ This method maintains conversation history for multi-turn interactions.
558
+ Call run() first to start a conversation, then chat() for follow-ups.
559
+
560
+ Args:
561
+ message: User's follow-up message
562
+ images: Optional list of images to include
563
+
564
+ Returns:
565
+ Agent's response
566
+ """
567
+ if not self._messages:
568
+ # No history, just run fresh
569
+ return self.run(message, images=images)
570
+
571
+ # Store query for reranking context frame
572
+ self._current_query = message
573
+ self.emitter.emit_start(goal=message)
574
+
575
+ # Add new user message to history
576
+ self._messages.append({
577
+ "role": "user",
578
+ "content": message,
579
+ })
580
+
581
+ # Get tool schemas
582
+ tools = self.toolkit.get_all_schemas()
583
+
584
+ try:
585
+ response, final_messages = self._run_loop(self._messages, tools)
586
+ # Update conversation history
587
+ self._messages = final_messages
588
+ self.emitter.emit_end(success=True)
589
+ return response
590
+
591
+ except Exception as e:
592
+ log.exception("Agent chat failed")
593
+ self.emitter.emit_error(str(e))
594
+ return f"Error: {str(e)}"
595
+
596
+ def reset(self) -> None:
597
+ """Reset the agent state."""
598
+ self.toolkit.reset_session()
599
+ self._total_input_tokens = 0
600
+ self._total_output_tokens = 0
601
+ self._current_query = ""