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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
  import base64
5
+ import time
5
6
  from typing import Optional, Union
6
7
 
7
8
  from openai import OpenAI
@@ -30,6 +31,9 @@ PROVIDER_CONFIG = {
30
31
  # Providers that support the reasoning parameter via extra_body
31
32
  REASONING_SUPPORTED_PROVIDERS = {"openai"}
32
33
 
34
+ # Providers that support extended thinking
35
+ THINKING_SUPPORTED_PROVIDERS = {"anthropic"}
36
+
33
37
 
34
38
  class OpenAIProvider(LLMProvider):
35
39
  """
@@ -66,9 +70,9 @@ class OpenAIProvider(LLMProvider):
66
70
  self._context_limit = 128000
67
71
  self._provider = self._infer_provider(model)
68
72
 
69
- # Override provider if OPENAI_BASE_URL is set (custom OpenAI-compatible API)
70
- if os.environ.get("OPENAI_BASE_URL"):
71
- self._provider = "openai"
73
+ # Note: We no longer override provider based on OPENAI_BASE_URL
74
+ # Each provider (fireworks, anthropic) uses its own base_url
75
+ # OPENAI_BASE_URL only applies to "openai" provider
72
76
 
73
77
  # Create OpenAI client with provider-specific configuration
74
78
  config = PROVIDER_CONFIG.get(self._provider, PROVIDER_CONFIG["openai"])
@@ -131,10 +135,26 @@ class OpenAIProvider(LLMProvider):
131
135
  )
132
136
 
133
137
  self._reasoning_override = self._parse_bool_env("EMDASH_LLM_REASONING")
138
+ self._thinking_override = self._parse_bool_env("EMDASH_LLM_THINKING")
139
+ self._thinking_budget = int(os.environ.get("EMDASH_THINKING_BUDGET", "10000"))
140
+ # Reasoning effort for Fireworks thinking models: none, low, medium, high
141
+ self._reasoning_effort = os.environ.get("EMDASH_REASONING_EFFORT", "medium")
142
+
143
+ # Use OPENAI_BASE_URL env var only for OpenAI provider, otherwise use provider config
144
+ if self._provider == "openai":
145
+ base_url = os.environ.get("OPENAI_BASE_URL") or config["base_url"]
146
+ else:
147
+ base_url = config["base_url"]
148
+
149
+ # Configure timeout from environment (default 300 seconds / 5 minutes)
150
+ # LLM calls can take a while with large contexts, so we use a generous default
151
+ timeout_seconds = int(os.environ.get("EMDASH_LLM_TIMEOUT", "300"))
152
+ self._timeout = timeout_seconds
134
153
 
135
154
  self.client = OpenAI(
136
155
  api_key=api_key,
137
- base_url=config["base_url"],
156
+ base_url=base_url,
157
+ timeout=timeout_seconds,
138
158
  )
139
159
 
140
160
  @staticmethod
@@ -170,13 +190,10 @@ class OpenAIProvider(LLMProvider):
170
190
  def _infer_provider(self, model: str) -> str:
171
191
  """Infer provider from model string.
172
192
 
173
- If OPENAI_BASE_URL is set, always returns 'openai' to use the custom
174
- OpenAI-compatible API endpoint with OPENAI_API_KEY.
193
+ Returns the appropriate provider based on model name.
194
+ OPENAI_BASE_URL only affects the openai provider's base URL,
195
+ not provider selection.
175
196
  """
176
- # If custom base URL is set, use openai provider (uses OPENAI_API_KEY)
177
- if os.environ.get("OPENAI_BASE_URL"):
178
- return "openai"
179
-
180
197
  model_lower = model.lower()
181
198
  if "claude" in model_lower or "anthropic" in model_lower:
182
199
  return "anthropic"
@@ -191,6 +208,7 @@ class OpenAIProvider(LLMProvider):
191
208
  tools: Optional[list[dict]] = None,
192
209
  system: Optional[str] = None,
193
210
  reasoning: bool = False,
211
+ thinking: bool = False,
194
212
  images: Optional[list[ImageContent]] = None,
195
213
  ) -> LLMResponse:
196
214
  """
@@ -201,6 +219,7 @@ class OpenAIProvider(LLMProvider):
201
219
  tools: Optional list of tool schemas (OpenAI format)
202
220
  system: Optional system prompt
203
221
  reasoning: Enable reasoning mode (for models that support it)
222
+ thinking: Enable extended thinking (for Anthropic models)
204
223
  images: Optional list of images for vision-capable models
205
224
 
206
225
  Returns:
@@ -212,6 +231,8 @@ class OpenAIProvider(LLMProvider):
212
231
 
213
232
  if self._reasoning_override is not None:
214
233
  reasoning = self._reasoning_override
234
+ if self._thinking_override is not None:
235
+ thinking = self._thinking_override
215
236
 
216
237
  # Build completion kwargs
217
238
  kwargs = {
@@ -222,6 +243,7 @@ class OpenAIProvider(LLMProvider):
222
243
  # Add tools if provided
223
244
  if tools:
224
245
  kwargs["tools"] = tools
246
+ kwargs["tool_choice"] = "auto"
225
247
 
226
248
  # Add reasoning support via extra_body for providers that support it
227
249
  # Skip reasoning for custom base URLs (they may not support it)
@@ -229,6 +251,33 @@ class OpenAIProvider(LLMProvider):
229
251
  if reasoning and self._provider in REASONING_SUPPORTED_PROVIDERS and not is_custom_api:
230
252
  kwargs["extra_body"] = {"reasoning": {"enabled": True}}
231
253
 
254
+ # Add extended thinking for Anthropic models
255
+ # This uses Anthropic's native thinking parameter
256
+ if thinking and self._provider in THINKING_SUPPORTED_PROVIDERS and not is_custom_api:
257
+ extra_body = kwargs.get("extra_body", {})
258
+ extra_body["thinking"] = {
259
+ "type": "enabled",
260
+ "budget_tokens": self._thinking_budget,
261
+ }
262
+ kwargs["extra_body"] = extra_body
263
+ log.info(
264
+ "Extended thinking enabled provider={} model={} budget={}",
265
+ self._provider,
266
+ self.model,
267
+ self._thinking_budget,
268
+ )
269
+
270
+ # Add reasoning_effort for Fireworks thinking models
271
+ # This controls the depth of reasoning: none, low, medium, high
272
+ if thinking and self._provider == "fireworks" and self._reasoning_effort != "none":
273
+ kwargs["reasoning_effort"] = self._reasoning_effort
274
+ log.info(
275
+ "Reasoning effort enabled provider={} model={} effort={}",
276
+ self._provider,
277
+ self.model,
278
+ self._reasoning_effort,
279
+ )
280
+
232
281
  # Add images if provided (vision support)
233
282
  if images:
234
283
  log.info(
@@ -287,21 +336,32 @@ class OpenAIProvider(LLMProvider):
287
336
  )
288
337
 
289
338
  # Call OpenAI SDK
339
+ start_time = time.time()
290
340
  try:
291
341
  response = self.client.chat.completions.create(**kwargs)
292
342
  except Exception as exc: # pragma: no cover - defensive logging
343
+ elapsed = time.time() - start_time
293
344
  status = getattr(exc, "status_code", None)
294
345
  code = getattr(exc, "code", None)
295
346
  log.exception(
296
- "LLM request failed provider={} model={} status={} code={} error={}",
347
+ "LLM request failed provider={} model={} status={} code={} elapsed={:.1f}s error={}",
297
348
  self._provider,
298
349
  self.model,
299
350
  status,
300
351
  code,
352
+ elapsed,
301
353
  exc,
302
354
  )
303
355
  raise
304
356
 
357
+ elapsed = time.time() - start_time
358
+ log.info(
359
+ "LLM request completed provider={} model={} elapsed={:.1f}s",
360
+ self._provider,
361
+ self.model,
362
+ elapsed,
363
+ )
364
+
305
365
  return self._to_llm_response(response)
306
366
 
307
367
  def _to_llm_response(self, response) -> LLMResponse:
@@ -322,8 +382,42 @@ class OpenAIProvider(LLMProvider):
322
382
  choice = response.choices[0]
323
383
  message = choice.message
324
384
 
325
- # Extract content
326
- content = message.content
385
+ # Extract content and thinking
386
+ content = None
387
+ thinking = None
388
+
389
+ # Check if content is a list of content blocks (Anthropic extended thinking)
390
+ raw_content = message.content
391
+ if isinstance(raw_content, list):
392
+ # Content blocks format (Anthropic with extended thinking)
393
+ text_parts = []
394
+ thinking_parts = []
395
+ for block in raw_content:
396
+ if hasattr(block, "type"):
397
+ if block.type == "thinking":
398
+ thinking_parts.append(getattr(block, "thinking", ""))
399
+ elif block.type == "text":
400
+ text_parts.append(getattr(block, "text", ""))
401
+ elif isinstance(block, dict):
402
+ if block.get("type") == "thinking":
403
+ thinking_parts.append(block.get("thinking", ""))
404
+ elif block.get("type") == "text":
405
+ text_parts.append(block.get("text", ""))
406
+ content = "\n".join(text_parts) if text_parts else None
407
+ thinking = "\n".join(thinking_parts) if thinking_parts else None
408
+ else:
409
+ # Simple string content
410
+ content = raw_content
411
+
412
+ # Check for reasoning_content field (Fireworks/OpenAI thinking models)
413
+ # This is separate from Anthropic's content blocks format
414
+ if not thinking and hasattr(message, "reasoning_content") and message.reasoning_content:
415
+ thinking = message.reasoning_content
416
+ log.debug(
417
+ "Reasoning content extracted from message.reasoning_content provider={} len={}",
418
+ self._provider,
419
+ len(thinking),
420
+ )
327
421
 
328
422
  # Extract tool calls
329
423
  tool_calls = []
@@ -338,17 +432,39 @@ class OpenAIProvider(LLMProvider):
338
432
  # Extract token usage if available
339
433
  input_tokens = 0
340
434
  output_tokens = 0
435
+ thinking_tokens = 0
341
436
  if hasattr(response, "usage") and response.usage:
342
437
  input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
343
438
  output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
439
+ # Try to get reasoning/thinking tokens from the API response
440
+ # Different providers use different field names
441
+ thinking_tokens = (
442
+ getattr(response.usage, "reasoning_tokens", 0)
443
+ or getattr(response.usage, "thinking_tokens", 0)
444
+ or 0
445
+ )
446
+ # If no explicit thinking tokens but we have thinking content, estimate
447
+ if not thinking_tokens and thinking:
448
+ thinking_tokens = len(thinking) // 4 # Rough estimate
449
+
450
+ if thinking:
451
+ log.info(
452
+ "Extended thinking captured provider={} model={} thinking_len={} thinking_tokens={}",
453
+ self._provider,
454
+ self.model,
455
+ len(thinking),
456
+ thinking_tokens,
457
+ )
344
458
 
345
459
  return LLMResponse(
346
460
  content=content,
461
+ thinking=thinking,
347
462
  tool_calls=tool_calls,
348
463
  raw=response,
349
464
  stop_reason=choice.finish_reason,
350
465
  input_tokens=input_tokens,
351
466
  output_tokens=output_tokens,
467
+ thinking_tokens=thinking_tokens,
352
468
  )
353
469
 
354
470
  def get_context_limit(self) -> int:
@@ -373,6 +489,14 @@ class OpenAIProvider(LLMProvider):
373
489
  # For unknown models, assume no vision support
374
490
  return False
375
491
 
492
+ def supports_thinking(self) -> bool:
493
+ """Check if this model supports extended thinking."""
494
+ if self.chat_model:
495
+ return self.chat_model.spec.supports_thinking
496
+
497
+ # For unknown models, check if provider supports thinking
498
+ return self._provider in THINKING_SUPPORTED_PROVIDERS
499
+
376
500
  def _format_image_for_api(self, image: ImageContent) -> dict:
377
501
  """Format an image for OpenAI/Anthropic API.
378
502
 
@@ -0,0 +1,49 @@
1
+ """Agent runner module for LLM-powered exploration.
2
+
3
+ This module provides the AgentRunner class and related utilities for running
4
+ LLM agents with tool access for code exploration.
5
+
6
+ The module is organized as follows:
7
+ - agent_runner.py: Main AgentRunner class
8
+ - context.py: Context estimation, compaction, and management
9
+ - plan.py: Plan approval/rejection functionality
10
+ - utils.py: JSON encoding and utility functions
11
+ """
12
+
13
+ from .agent_runner import AgentRunner
14
+ from .sdk_runner import SDKAgentRunner, is_claude_model
15
+ from .factory import get_runner, create_hybrid_runner
16
+ from .utils import SafeJSONEncoder, summarize_tool_result
17
+ from .context import (
18
+ estimate_context_tokens,
19
+ get_context_breakdown,
20
+ maybe_compact_context,
21
+ compact_messages_with_llm,
22
+ format_messages_for_summary,
23
+ get_reranked_context,
24
+ emit_context_frame,
25
+ )
26
+ from .plan import PlanMixin
27
+
28
+ __all__ = [
29
+ # Main classes
30
+ "AgentRunner",
31
+ "SDKAgentRunner",
32
+ # Factory functions
33
+ "get_runner",
34
+ "create_hybrid_runner",
35
+ "is_claude_model",
36
+ # Utils
37
+ "SafeJSONEncoder",
38
+ "summarize_tool_result",
39
+ # Context functions
40
+ "estimate_context_tokens",
41
+ "get_context_breakdown",
42
+ "maybe_compact_context",
43
+ "compact_messages_with_llm",
44
+ "format_messages_for_summary",
45
+ "get_reranked_context",
46
+ "emit_context_frame",
47
+ # Plan management
48
+ "PlanMixin",
49
+ ]