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.
- emdash_core/__init__.py +3 -0
- emdash_core/agent/__init__.py +37 -0
- emdash_core/agent/agents.py +225 -0
- emdash_core/agent/code_reviewer.py +476 -0
- emdash_core/agent/compaction.py +143 -0
- emdash_core/agent/context_manager.py +140 -0
- emdash_core/agent/events.py +338 -0
- emdash_core/agent/handlers.py +224 -0
- emdash_core/agent/inprocess_subagent.py +377 -0
- emdash_core/agent/mcp/__init__.py +50 -0
- emdash_core/agent/mcp/client.py +346 -0
- emdash_core/agent/mcp/config.py +302 -0
- emdash_core/agent/mcp/manager.py +496 -0
- emdash_core/agent/mcp/tool_factory.py +213 -0
- emdash_core/agent/prompts/__init__.py +38 -0
- emdash_core/agent/prompts/main_agent.py +104 -0
- emdash_core/agent/prompts/subagents.py +131 -0
- emdash_core/agent/prompts/workflow.py +136 -0
- emdash_core/agent/providers/__init__.py +34 -0
- emdash_core/agent/providers/base.py +143 -0
- emdash_core/agent/providers/factory.py +80 -0
- emdash_core/agent/providers/models.py +220 -0
- emdash_core/agent/providers/openai_provider.py +463 -0
- emdash_core/agent/providers/transformers_provider.py +217 -0
- emdash_core/agent/research/__init__.py +81 -0
- emdash_core/agent/research/agent.py +143 -0
- emdash_core/agent/research/controller.py +254 -0
- emdash_core/agent/research/critic.py +428 -0
- emdash_core/agent/research/macros.py +469 -0
- emdash_core/agent/research/planner.py +449 -0
- emdash_core/agent/research/researcher.py +436 -0
- emdash_core/agent/research/state.py +523 -0
- emdash_core/agent/research/synthesizer.py +594 -0
- emdash_core/agent/reviewer_profile.py +475 -0
- emdash_core/agent/rules.py +123 -0
- emdash_core/agent/runner.py +601 -0
- emdash_core/agent/session.py +262 -0
- emdash_core/agent/spec_schema.py +66 -0
- emdash_core/agent/specification.py +479 -0
- emdash_core/agent/subagent.py +397 -0
- emdash_core/agent/subagent_prompts.py +13 -0
- emdash_core/agent/toolkit.py +482 -0
- emdash_core/agent/toolkits/__init__.py +64 -0
- emdash_core/agent/toolkits/base.py +96 -0
- emdash_core/agent/toolkits/explore.py +47 -0
- emdash_core/agent/toolkits/plan.py +55 -0
- emdash_core/agent/tools/__init__.py +141 -0
- emdash_core/agent/tools/analytics.py +436 -0
- emdash_core/agent/tools/base.py +131 -0
- emdash_core/agent/tools/coding.py +484 -0
- emdash_core/agent/tools/github_mcp.py +592 -0
- emdash_core/agent/tools/history.py +13 -0
- emdash_core/agent/tools/modes.py +153 -0
- emdash_core/agent/tools/plan.py +206 -0
- emdash_core/agent/tools/plan_write.py +135 -0
- emdash_core/agent/tools/search.py +412 -0
- emdash_core/agent/tools/spec.py +341 -0
- emdash_core/agent/tools/task.py +262 -0
- emdash_core/agent/tools/task_output.py +204 -0
- emdash_core/agent/tools/tasks.py +454 -0
- emdash_core/agent/tools/traversal.py +588 -0
- emdash_core/agent/tools/web.py +179 -0
- emdash_core/analytics/__init__.py +5 -0
- emdash_core/analytics/engine.py +1286 -0
- emdash_core/api/__init__.py +5 -0
- emdash_core/api/agent.py +308 -0
- emdash_core/api/agents.py +154 -0
- emdash_core/api/analyze.py +264 -0
- emdash_core/api/auth.py +173 -0
- emdash_core/api/context.py +77 -0
- emdash_core/api/db.py +121 -0
- emdash_core/api/embed.py +131 -0
- emdash_core/api/feature.py +143 -0
- emdash_core/api/health.py +93 -0
- emdash_core/api/index.py +162 -0
- emdash_core/api/plan.py +110 -0
- emdash_core/api/projectmd.py +210 -0
- emdash_core/api/query.py +320 -0
- emdash_core/api/research.py +122 -0
- emdash_core/api/review.py +161 -0
- emdash_core/api/router.py +76 -0
- emdash_core/api/rules.py +116 -0
- emdash_core/api/search.py +119 -0
- emdash_core/api/spec.py +99 -0
- emdash_core/api/swarm.py +223 -0
- emdash_core/api/tasks.py +109 -0
- emdash_core/api/team.py +120 -0
- emdash_core/auth/__init__.py +17 -0
- emdash_core/auth/github.py +389 -0
- emdash_core/config.py +74 -0
- emdash_core/context/__init__.py +52 -0
- emdash_core/context/models.py +50 -0
- emdash_core/context/providers/__init__.py +11 -0
- emdash_core/context/providers/base.py +74 -0
- emdash_core/context/providers/explored_areas.py +183 -0
- emdash_core/context/providers/touched_areas.py +360 -0
- emdash_core/context/registry.py +73 -0
- emdash_core/context/reranker.py +199 -0
- emdash_core/context/service.py +260 -0
- emdash_core/context/session.py +352 -0
- emdash_core/core/__init__.py +104 -0
- emdash_core/core/config.py +454 -0
- emdash_core/core/exceptions.py +55 -0
- emdash_core/core/models.py +265 -0
- emdash_core/core/review_config.py +57 -0
- emdash_core/db/__init__.py +67 -0
- emdash_core/db/auth.py +134 -0
- emdash_core/db/models.py +91 -0
- emdash_core/db/provider.py +222 -0
- emdash_core/db/providers/__init__.py +5 -0
- emdash_core/db/providers/supabase.py +452 -0
- emdash_core/embeddings/__init__.py +24 -0
- emdash_core/embeddings/indexer.py +534 -0
- emdash_core/embeddings/models.py +192 -0
- emdash_core/embeddings/providers/__init__.py +7 -0
- emdash_core/embeddings/providers/base.py +112 -0
- emdash_core/embeddings/providers/fireworks.py +141 -0
- emdash_core/embeddings/providers/openai.py +104 -0
- emdash_core/embeddings/registry.py +146 -0
- emdash_core/embeddings/service.py +215 -0
- emdash_core/graph/__init__.py +26 -0
- emdash_core/graph/builder.py +134 -0
- emdash_core/graph/connection.py +692 -0
- emdash_core/graph/schema.py +416 -0
- emdash_core/graph/writer.py +667 -0
- emdash_core/ingestion/__init__.py +7 -0
- emdash_core/ingestion/change_detector.py +150 -0
- emdash_core/ingestion/git/__init__.py +5 -0
- emdash_core/ingestion/git/commit_analyzer.py +196 -0
- emdash_core/ingestion/github/__init__.py +6 -0
- emdash_core/ingestion/github/pr_fetcher.py +296 -0
- emdash_core/ingestion/github/task_extractor.py +100 -0
- emdash_core/ingestion/orchestrator.py +540 -0
- emdash_core/ingestion/parsers/__init__.py +10 -0
- emdash_core/ingestion/parsers/base_parser.py +66 -0
- emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
- emdash_core/ingestion/parsers/class_extractor.py +154 -0
- emdash_core/ingestion/parsers/function_extractor.py +202 -0
- emdash_core/ingestion/parsers/import_analyzer.py +119 -0
- emdash_core/ingestion/parsers/python_parser.py +123 -0
- emdash_core/ingestion/parsers/registry.py +72 -0
- emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
- emdash_core/ingestion/parsers/typescript_parser.py +278 -0
- emdash_core/ingestion/repository.py +346 -0
- emdash_core/models/__init__.py +38 -0
- emdash_core/models/agent.py +68 -0
- emdash_core/models/index.py +77 -0
- emdash_core/models/query.py +113 -0
- emdash_core/planning/__init__.py +7 -0
- emdash_core/planning/agent_api.py +413 -0
- emdash_core/planning/context_builder.py +265 -0
- emdash_core/planning/feature_context.py +232 -0
- emdash_core/planning/feature_expander.py +646 -0
- emdash_core/planning/llm_explainer.py +198 -0
- emdash_core/planning/similarity.py +509 -0
- emdash_core/planning/team_focus.py +821 -0
- emdash_core/server.py +153 -0
- emdash_core/sse/__init__.py +5 -0
- emdash_core/sse/stream.py +196 -0
- emdash_core/swarm/__init__.py +17 -0
- emdash_core/swarm/merge_agent.py +383 -0
- emdash_core/swarm/session_manager.py +274 -0
- emdash_core/swarm/swarm_runner.py +226 -0
- emdash_core/swarm/task_definition.py +137 -0
- emdash_core/swarm/worker_spawner.py +319 -0
- emdash_core/swarm/worktree_manager.py +278 -0
- emdash_core/templates/__init__.py +10 -0
- emdash_core/templates/defaults/agent-builder.md.template +82 -0
- emdash_core/templates/defaults/focus.md.template +115 -0
- emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
- emdash_core/templates/defaults/pr-review.md.template +80 -0
- emdash_core/templates/defaults/project.md.template +85 -0
- emdash_core/templates/defaults/research_critic.md.template +112 -0
- emdash_core/templates/defaults/research_planner.md.template +85 -0
- emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
- emdash_core/templates/defaults/reviewer.md.template +81 -0
- emdash_core/templates/defaults/spec.md.template +41 -0
- emdash_core/templates/defaults/tasks.md.template +78 -0
- emdash_core/templates/loader.py +296 -0
- emdash_core/utils/__init__.py +45 -0
- emdash_core/utils/git.py +84 -0
- emdash_core/utils/image.py +502 -0
- emdash_core/utils/logger.py +51 -0
- emdash_core-0.1.7.dist-info/METADATA +35 -0
- emdash_core-0.1.7.dist-info/RECORD +187 -0
- emdash_core-0.1.7.dist-info/WHEEL +4 -0
- 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 = ""
|