emdash-core 0.1.25__py3-none-any.whl → 0.1.37__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 (39) hide show
  1. emdash_core/agent/__init__.py +4 -0
  2. emdash_core/agent/agents.py +84 -23
  3. emdash_core/agent/events.py +42 -20
  4. emdash_core/agent/hooks.py +419 -0
  5. emdash_core/agent/inprocess_subagent.py +166 -18
  6. emdash_core/agent/prompts/__init__.py +4 -3
  7. emdash_core/agent/prompts/main_agent.py +67 -2
  8. emdash_core/agent/prompts/plan_mode.py +236 -107
  9. emdash_core/agent/prompts/subagents.py +103 -23
  10. emdash_core/agent/prompts/workflow.py +159 -26
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/openai_provider.py +67 -15
  13. emdash_core/agent/runner/__init__.py +49 -0
  14. emdash_core/agent/runner/agent_runner.py +765 -0
  15. emdash_core/agent/runner/context.py +470 -0
  16. emdash_core/agent/runner/factory.py +108 -0
  17. emdash_core/agent/runner/plan.py +217 -0
  18. emdash_core/agent/runner/sdk_runner.py +324 -0
  19. emdash_core/agent/runner/utils.py +67 -0
  20. emdash_core/agent/skills.py +47 -8
  21. emdash_core/agent/toolkit.py +46 -14
  22. emdash_core/agent/toolkits/__init__.py +117 -18
  23. emdash_core/agent/toolkits/base.py +87 -2
  24. emdash_core/agent/toolkits/explore.py +18 -0
  25. emdash_core/agent/toolkits/plan.py +27 -11
  26. emdash_core/agent/tools/__init__.py +2 -2
  27. emdash_core/agent/tools/coding.py +48 -4
  28. emdash_core/agent/tools/modes.py +151 -143
  29. emdash_core/agent/tools/task.py +52 -6
  30. emdash_core/api/agent.py +706 -1
  31. emdash_core/ingestion/repository.py +17 -198
  32. emdash_core/models/agent.py +4 -0
  33. emdash_core/skills/frontend-design/SKILL.md +56 -0
  34. emdash_core/sse/stream.py +4 -0
  35. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/METADATA +4 -1
  36. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/RECORD +38 -30
  37. emdash_core/agent/runner.py +0 -1123
  38. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/WHEEL +0 -0
  39. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,470 @@
1
+ """Context management functions for the agent runner.
2
+
3
+ This module contains functions for estimating, compacting, and managing
4
+ conversation context during agent runs.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional, TYPE_CHECKING
9
+
10
+ from ...utils.logger import log
11
+
12
+ if TYPE_CHECKING:
13
+ from ..toolkit import AgentToolkit
14
+ from ..events import AgentEventEmitter
15
+
16
+
17
+ def estimate_context_tokens(messages: list[dict], system_prompt: Optional[str] = None) -> int:
18
+ """Estimate the current context window size in tokens.
19
+
20
+ Args:
21
+ messages: Conversation messages
22
+ system_prompt: Optional system prompt to include in estimation
23
+
24
+ Returns:
25
+ Estimated token count for the context
26
+ """
27
+ total_chars = 0
28
+
29
+ # Count characters in all messages
30
+ for msg in messages:
31
+ content = msg.get("content", "")
32
+ if isinstance(content, str):
33
+ total_chars += len(content)
34
+ elif isinstance(content, list):
35
+ # Handle multi-part messages (e.g., with images)
36
+ for part in content:
37
+ if isinstance(part, dict) and "text" in part:
38
+ total_chars += len(part["text"])
39
+
40
+ # Add role overhead (~4 tokens per message for role/structure)
41
+ total_chars += 16
42
+
43
+ # Also count system prompt
44
+ if system_prompt:
45
+ total_chars += len(system_prompt)
46
+
47
+ # Estimate: ~4 characters per token
48
+ return total_chars // 4
49
+
50
+
51
+ def get_context_breakdown(
52
+ messages: list[dict],
53
+ system_prompt: Optional[str] = None,
54
+ ) -> tuple[dict, list[dict]]:
55
+ """Get breakdown of context usage by message type.
56
+
57
+ Args:
58
+ messages: Conversation messages
59
+ system_prompt: Optional system prompt
60
+
61
+ Returns:
62
+ Tuple of (breakdown dict, list of largest messages)
63
+ """
64
+ breakdown = {
65
+ "system_prompt": len(system_prompt) // 4 if system_prompt else 0,
66
+ "user": 0,
67
+ "assistant": 0,
68
+ "tool_results": 0,
69
+ }
70
+
71
+ # Track individual message sizes for finding largest
72
+ message_sizes = []
73
+
74
+ for i, msg in enumerate(messages):
75
+ role = msg.get("role", "unknown")
76
+ content = msg.get("content", "")
77
+
78
+ # Calculate content size
79
+ if isinstance(content, str):
80
+ size = len(content)
81
+ elif isinstance(content, list):
82
+ size = sum(len(p.get("text", "")) for p in content if isinstance(p, dict))
83
+ else:
84
+ size = 0
85
+
86
+ tokens = size // 4
87
+
88
+ # Categorize
89
+ if role == "user":
90
+ breakdown["user"] += tokens
91
+ elif role == "assistant":
92
+ breakdown["assistant"] += tokens
93
+ elif role == "tool":
94
+ breakdown["tool_results"] += tokens
95
+
96
+ # Track for largest messages
97
+ if tokens > 100: # Only track substantial messages
98
+ # Try to get a label for this message
99
+ label = f"{role}[{i}]"
100
+ if role == "tool":
101
+ tool_call_id = msg.get("tool_call_id", "")
102
+ # Try to find the tool name from previous assistant message
103
+ for prev_msg in reversed(messages[:i]):
104
+ if prev_msg.get("role") == "assistant" and "tool_calls" in prev_msg:
105
+ for tc in prev_msg.get("tool_calls", []):
106
+ if tc.get("id") == tool_call_id:
107
+ label = tc.get("function", {}).get("name", "tool")
108
+ break
109
+ break
110
+
111
+ message_sizes.append({
112
+ "index": i,
113
+ "role": role,
114
+ "label": label,
115
+ "tokens": tokens,
116
+ "preview": content[:100] if isinstance(content, str) else str(content)[:100],
117
+ })
118
+
119
+ # Sort by size and get top 5
120
+ message_sizes.sort(key=lambda x: x["tokens"], reverse=True)
121
+ largest = message_sizes[:5]
122
+
123
+ return breakdown, largest
124
+
125
+
126
+ def maybe_compact_context(
127
+ messages: list[dict],
128
+ provider: object,
129
+ emitter: "AgentEventEmitter",
130
+ system_prompt: Optional[str] = None,
131
+ threshold: float = 0.8,
132
+ ) -> list[dict]:
133
+ """Proactively compact context if approaching limit.
134
+
135
+ Args:
136
+ messages: Current conversation messages
137
+ provider: LLM provider instance
138
+ emitter: Event emitter for notifications
139
+ system_prompt: System prompt for token estimation
140
+ threshold: Trigger compaction at this % of context limit (default 80%)
141
+
142
+ Returns:
143
+ Original or compacted messages
144
+ """
145
+ context_tokens = estimate_context_tokens(messages, system_prompt)
146
+ context_limit = provider.get_context_limit()
147
+
148
+ # Check if we need to compact
149
+ if context_tokens < context_limit * threshold:
150
+ return messages # No compaction needed
151
+
152
+ log.info(
153
+ f"Context at {context_tokens:,}/{context_limit:,} tokens "
154
+ f"({context_tokens/context_limit:.0%}), compacting..."
155
+ )
156
+
157
+ return compact_messages_with_llm(
158
+ messages, emitter, target_tokens=int(context_limit * 0.5)
159
+ )
160
+
161
+
162
+ def compact_messages_with_llm(
163
+ messages: list[dict],
164
+ emitter: "AgentEventEmitter",
165
+ target_tokens: int,
166
+ ) -> list[dict]:
167
+ """Use fast LLM to summarize middle messages.
168
+
169
+ Preserves:
170
+ - First message (original user request)
171
+ - Last 4 messages (recent context)
172
+ - Summarizes everything in between
173
+
174
+ Args:
175
+ messages: Current conversation messages
176
+ emitter: Event emitter for notifications
177
+ target_tokens: Target token count after compaction
178
+
179
+ Returns:
180
+ Compacted messages list
181
+ """
182
+ from ..subagent import get_model_for_tier
183
+ from ..providers import get_provider
184
+
185
+ if len(messages) <= 5:
186
+ return messages # Too few to compact
187
+
188
+ # Split messages
189
+ first_msg = messages[0]
190
+ recent_msgs = messages[-4:]
191
+ middle_msgs = messages[1:-4]
192
+
193
+ if not middle_msgs:
194
+ return messages
195
+
196
+ # Build summary prompt
197
+ middle_content = format_messages_for_summary(middle_msgs)
198
+
199
+ prompt = f"""Summarize this conversation history concisely.
200
+
201
+ PRESERVE (include verbatim if present):
202
+ - Code snippets and file paths
203
+ - Error messages
204
+ - Key decisions made
205
+ - Important tool results (file contents, search results)
206
+
207
+ CONDENSE:
208
+ - Repetitive searches
209
+ - Verbose tool outputs
210
+ - Intermediate reasoning
211
+
212
+ CONVERSATION HISTORY:
213
+ {middle_content}
214
+
215
+ OUTPUT FORMAT:
216
+ Provide a concise summary (max 2000 tokens) that captures the essential context needed to continue this task."""
217
+
218
+ # Use fast model for summarization
219
+ fast_model = get_model_for_tier("fast")
220
+ fast_provider = get_provider(fast_model)
221
+
222
+ try:
223
+ emitter.emit_thinking("Compacting context with fast model...")
224
+
225
+ response = fast_provider.chat(
226
+ messages=[{"role": "user", "content": prompt}],
227
+ system="You are a context summarizer. Be concise but preserve code and technical details.",
228
+ )
229
+
230
+ summary = response.content or ""
231
+
232
+ log.info(
233
+ f"Compacted {len(middle_msgs)} messages into summary "
234
+ f"({len(summary)} chars)"
235
+ )
236
+
237
+ # Build compacted messages
238
+ return [
239
+ first_msg,
240
+ {
241
+ "role": "assistant",
242
+ "content": f"[Context Summary]\n{summary}\n[End Summary]",
243
+ },
244
+ *recent_msgs,
245
+ ]
246
+ except Exception as e:
247
+ log.warning(f"LLM compaction failed: {e}, falling back to truncation")
248
+ return [first_msg] + recent_msgs
249
+
250
+
251
+ def format_messages_for_summary(messages: list[dict]) -> str:
252
+ """Format messages for summarization prompt.
253
+
254
+ Args:
255
+ messages: Messages to format
256
+
257
+ Returns:
258
+ Formatted string for summarization
259
+ """
260
+ parts = []
261
+ for msg in messages:
262
+ role = msg.get("role", "unknown")
263
+ content = msg.get("content", "")
264
+
265
+ # Handle tool calls in assistant messages
266
+ if role == "assistant" and "tool_calls" in msg:
267
+ tool_calls = msg.get("tool_calls", [])
268
+ tool_info = [
269
+ f"Called: {tc.get('function', {}).get('name', 'unknown')}"
270
+ for tc in tool_calls
271
+ ]
272
+ content = f"{content}\n[Tools: {', '.join(tool_info)}]" if content else f"[Tools: {', '.join(tool_info)}]"
273
+
274
+ # Truncate very long content
275
+ if len(content) > 4000:
276
+ content = content[:4000] + "\n[...truncated...]"
277
+
278
+ parts.append(f"[{role.upper()}]\n{content}")
279
+
280
+ return "\n\n---\n\n".join(parts)
281
+
282
+
283
+ def get_reranked_context(
284
+ toolkit: "AgentToolkit",
285
+ current_query: str,
286
+ ) -> dict:
287
+ """Get reranked context items based on the current query.
288
+
289
+ Args:
290
+ toolkit: Agent toolkit instance
291
+ current_query: Current query for relevance ranking
292
+
293
+ Returns:
294
+ Dict with item_count and items list
295
+ """
296
+ try:
297
+ from ...context.service import ContextService
298
+ from ...context.reranker import rerank_context_items
299
+
300
+ # Get exploration steps for context extraction
301
+ steps = toolkit.get_exploration_steps()
302
+ if not steps:
303
+ return {"item_count": 0, "items": [], "query": current_query, "debug": "no exploration steps"}
304
+
305
+ # Use context service to extract context items from exploration
306
+ service = ContextService(connection=toolkit.connection)
307
+ terminal_id = service.get_terminal_id()
308
+
309
+ # Update context with exploration steps
310
+ service.update_context(
311
+ terminal_id=terminal_id,
312
+ exploration_steps=steps,
313
+ )
314
+
315
+ # Get context items
316
+ items = service.get_context_items(terminal_id)
317
+ if not items:
318
+ return {"item_count": 0, "items": [], "query": current_query, "debug": f"no items from service ({len(steps)} steps)"}
319
+
320
+ # Get max tokens from env (default 2000)
321
+ max_tokens = int(os.getenv("CONTEXT_FRAME_MAX_TOKENS", "2000"))
322
+
323
+ # Rerank by query relevance
324
+ if current_query:
325
+ items = rerank_context_items(
326
+ items,
327
+ current_query,
328
+ top_k=50, # Get more candidates, then filter by tokens
329
+ )
330
+
331
+ # Convert to serializable format, limiting by token count
332
+ result_items = []
333
+ total_tokens = 0
334
+ for item in items:
335
+ item_dict = {
336
+ "name": item.qualified_name,
337
+ "type": item.entity_type,
338
+ "file": item.file_path,
339
+ "score": round(item.score, 3) if hasattr(item, 'score') else None,
340
+ "description": item.description[:200] if item.description else None,
341
+ "touch_count": item.touch_count,
342
+ "neighbors": item.neighbors[:5] if item.neighbors else [],
343
+ }
344
+ # Estimate tokens for this item (~4 chars per token)
345
+ item_chars = len(str(item_dict))
346
+ item_tokens = item_chars // 4
347
+
348
+ if total_tokens + item_tokens > max_tokens:
349
+ break
350
+
351
+ result_items.append(item_dict)
352
+ total_tokens += item_tokens
353
+
354
+ return {
355
+ "item_count": len(result_items),
356
+ "items": result_items,
357
+ "query": current_query,
358
+ "total_tokens": total_tokens,
359
+ }
360
+
361
+ except Exception as e:
362
+ log.warning(f"Failed to get reranked context: {e}")
363
+ return {"item_count": 0, "items": [], "query": current_query, "debug": str(e)}
364
+
365
+
366
+ def emit_context_frame(
367
+ toolkit: "AgentToolkit",
368
+ emitter: "AgentEventEmitter",
369
+ messages: list[dict],
370
+ system_prompt: Optional[str],
371
+ current_query: str,
372
+ total_input_tokens: int,
373
+ total_output_tokens: int,
374
+ ) -> None:
375
+ """Emit a context frame event with current exploration state.
376
+
377
+ Args:
378
+ toolkit: Agent toolkit instance
379
+ emitter: Event emitter
380
+ messages: Current conversation messages
381
+ system_prompt: System prompt for estimation
382
+ current_query: Current query for reranking
383
+ total_input_tokens: Total input tokens used
384
+ total_output_tokens: Total output tokens used
385
+ """
386
+ # Get exploration steps from toolkit session
387
+ steps = toolkit.get_exploration_steps()
388
+
389
+ # Estimate current context window tokens and get breakdown
390
+ context_tokens = 0
391
+ context_breakdown = {}
392
+ largest_messages = []
393
+ if messages:
394
+ context_tokens = estimate_context_tokens(messages, system_prompt)
395
+ context_breakdown, largest_messages = get_context_breakdown(messages, system_prompt)
396
+
397
+ # Summarize exploration by tool
398
+ tool_counts: dict[str, int] = {}
399
+ entities_found = 0
400
+ step_details: list[dict] = []
401
+
402
+ for step in steps:
403
+ tool_name = getattr(step, 'tool', 'unknown')
404
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
405
+
406
+ # Count entities from the step
407
+ step_entities = getattr(step, 'entities_found', [])
408
+ entities_found += len(step_entities)
409
+
410
+ # Collect step details
411
+ params = getattr(step, 'params', {})
412
+ summary = getattr(step, 'result_summary', '')
413
+
414
+ # Extract meaningful info based on tool type
415
+ detail = {
416
+ "tool": tool_name,
417
+ "summary": summary,
418
+ }
419
+
420
+ # Add relevant params based on tool
421
+ if tool_name == 'read_file' and 'file_path' in params:
422
+ detail["file"] = params['file_path']
423
+ elif tool_name == 'read_file' and 'path' in params:
424
+ detail["file"] = params['path']
425
+ elif tool_name in ('grep', 'semantic_search') and 'query' in params:
426
+ detail["query"] = params['query']
427
+ elif tool_name == 'glob' and 'pattern' in params:
428
+ detail["pattern"] = params['pattern']
429
+ elif tool_name == 'list_files' and 'path' in params:
430
+ detail["path"] = params['path']
431
+
432
+ # Add content preview if available
433
+ content_preview = getattr(step, 'content_preview', None)
434
+ if content_preview:
435
+ detail["content_preview"] = content_preview
436
+
437
+ # Add token count if available
438
+ token_count = getattr(step, 'token_count', 0)
439
+ if token_count > 0:
440
+ detail["tokens"] = token_count
441
+
442
+ # Add entities if any
443
+ if step_entities:
444
+ detail["entities"] = step_entities[:5] # Limit to 5
445
+
446
+ step_details.append(detail)
447
+
448
+ exploration_steps = [
449
+ {"tool": tool, "count": count}
450
+ for tool, count in tool_counts.items()
451
+ ]
452
+
453
+ # Build context frame data
454
+ adding = {
455
+ "exploration_steps": exploration_steps,
456
+ "entities_found": entities_found,
457
+ "step_count": len(steps),
458
+ "details": step_details[-20:], # Last 20 steps
459
+ "input_tokens": total_input_tokens,
460
+ "output_tokens": total_output_tokens,
461
+ "context_tokens": context_tokens, # Current context window size
462
+ "context_breakdown": context_breakdown, # Tokens by message type
463
+ "largest_messages": largest_messages, # Top 5 biggest messages
464
+ }
465
+
466
+ # Get reranked context items
467
+ reading = get_reranked_context(toolkit, current_query)
468
+
469
+ # Emit the context frame
470
+ emitter.emit_context_frame(adding=adding, reading=reading)
@@ -0,0 +1,108 @@
1
+ """Runner factory for hybrid SDK/Provider routing.
2
+
3
+ This module provides factory functions to create the appropriate runner
4
+ based on the model type:
5
+ - Claude models → SDKAgentRunner (uses Anthropic Agent SDK)
6
+ - Other models → AgentRunner (uses OpenAI-compatible API)
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Optional, Union, TYPE_CHECKING
11
+
12
+ from .sdk_runner import SDKAgentRunner, is_claude_model
13
+ from .agent_runner import AgentRunner
14
+ from ...utils.logger import log
15
+
16
+ if TYPE_CHECKING:
17
+ from ..events import AgentEventEmitter
18
+
19
+
20
+ def get_runner(
21
+ model: str,
22
+ cwd: Optional[str] = None,
23
+ emitter: Optional["AgentEventEmitter"] = None,
24
+ system_prompt: Optional[str] = None,
25
+ plan_mode: bool = False,
26
+ prefer_sdk: bool = True,
27
+ **kwargs,
28
+ ) -> Union[SDKAgentRunner, AgentRunner]:
29
+ """Get the appropriate runner for the model.
30
+
31
+ Routes to SDKAgentRunner for Claude models (uses Anthropic Agent SDK),
32
+ or AgentRunner for other models (uses OpenAI-compatible API).
33
+
34
+ Args:
35
+ model: Model string (e.g., "claude-sonnet-4", "fireworks:minimax-m2p1")
36
+ cwd: Working directory
37
+ emitter: Event emitter for streaming
38
+ system_prompt: Custom system prompt
39
+ plan_mode: If True, restrict to read-only tools
40
+ prefer_sdk: If True, prefer SDK for Claude models (default True)
41
+ **kwargs: Additional arguments passed to runner
42
+
43
+ Returns:
44
+ SDKAgentRunner or AgentRunner instance
45
+
46
+ Example:
47
+ # Claude model → uses SDK
48
+ runner = get_runner("claude-sonnet-4")
49
+
50
+ # Fireworks model → uses standard provider
51
+ runner = get_runner("fireworks:accounts/fireworks/models/minimax-m2p1")
52
+
53
+ # Force standard provider even for Claude
54
+ runner = get_runner("claude-sonnet-4", prefer_sdk=False)
55
+ """
56
+ use_sdk = prefer_sdk and is_claude_model(model)
57
+
58
+ if use_sdk:
59
+ log.info(f"Using SDKAgentRunner for Claude model: {model}")
60
+ return SDKAgentRunner(
61
+ model=model,
62
+ cwd=cwd,
63
+ emitter=emitter,
64
+ system_prompt=system_prompt,
65
+ plan_mode=plan_mode,
66
+ )
67
+ else:
68
+ log.info(f"Using AgentRunner for model: {model}")
69
+ # Import toolkit here to avoid circular imports
70
+ from ..toolkit import AgentToolkit
71
+
72
+ # Generate plan file path when in plan mode
73
+ plan_file_path = None
74
+ repo_root = Path(cwd) if cwd else Path.cwd()
75
+ if plan_mode:
76
+ plan_file_path = str(repo_root / ".emdash" / "plan.md")
77
+ # Ensure .emdash directory exists
78
+ (repo_root / ".emdash").mkdir(exist_ok=True)
79
+
80
+ toolkit = AgentToolkit(
81
+ repo_root=repo_root,
82
+ plan_mode=plan_mode,
83
+ plan_file_path=plan_file_path,
84
+ )
85
+
86
+ return AgentRunner(
87
+ toolkit=toolkit,
88
+ model=model,
89
+ emitter=emitter,
90
+ system_prompt=system_prompt,
91
+ **kwargs,
92
+ )
93
+
94
+
95
+ def create_hybrid_runner(
96
+ model: str,
97
+ **kwargs,
98
+ ) -> Union[SDKAgentRunner, AgentRunner]:
99
+ """Convenience alias for get_runner.
100
+
101
+ Args:
102
+ model: Model string
103
+ **kwargs: Passed to get_runner
104
+
105
+ Returns:
106
+ Appropriate runner instance
107
+ """
108
+ return get_runner(model, **kwargs)