emdash-core 0.1.7__py3-none-any.whl → 0.1.33__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. emdash_core/__init__.py +6 -1
  2. emdash_core/agent/__init__.py +4 -0
  3. emdash_core/agent/events.py +52 -1
  4. emdash_core/agent/inprocess_subagent.py +123 -10
  5. emdash_core/agent/prompts/__init__.py +6 -0
  6. emdash_core/agent/prompts/main_agent.py +53 -3
  7. emdash_core/agent/prompts/plan_mode.py +255 -0
  8. emdash_core/agent/prompts/subagents.py +84 -16
  9. emdash_core/agent/prompts/workflow.py +270 -56
  10. emdash_core/agent/providers/base.py +4 -0
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/models.py +7 -0
  13. emdash_core/agent/providers/openai_provider.py +137 -13
  14. emdash_core/agent/runner/__init__.py +49 -0
  15. emdash_core/agent/runner/agent_runner.py +753 -0
  16. emdash_core/agent/runner/context.py +451 -0
  17. emdash_core/agent/runner/factory.py +108 -0
  18. emdash_core/agent/runner/plan.py +217 -0
  19. emdash_core/agent/runner/sdk_runner.py +324 -0
  20. emdash_core/agent/runner/utils.py +67 -0
  21. emdash_core/agent/skills.py +358 -0
  22. emdash_core/agent/toolkit.py +85 -5
  23. emdash_core/agent/toolkits/plan.py +9 -11
  24. emdash_core/agent/tools/__init__.py +3 -2
  25. emdash_core/agent/tools/coding.py +48 -4
  26. emdash_core/agent/tools/modes.py +207 -55
  27. emdash_core/agent/tools/search.py +4 -0
  28. emdash_core/agent/tools/skill.py +193 -0
  29. emdash_core/agent/tools/spec.py +61 -94
  30. emdash_core/agent/tools/task.py +41 -2
  31. emdash_core/agent/tools/tasks.py +15 -78
  32. emdash_core/api/agent.py +562 -8
  33. emdash_core/api/index.py +1 -1
  34. emdash_core/api/projectmd.py +4 -2
  35. emdash_core/api/router.py +2 -0
  36. emdash_core/api/skills.py +241 -0
  37. emdash_core/checkpoint/__init__.py +40 -0
  38. emdash_core/checkpoint/cli.py +175 -0
  39. emdash_core/checkpoint/git_operations.py +250 -0
  40. emdash_core/checkpoint/manager.py +231 -0
  41. emdash_core/checkpoint/models.py +107 -0
  42. emdash_core/checkpoint/storage.py +201 -0
  43. emdash_core/config.py +1 -1
  44. emdash_core/core/config.py +18 -2
  45. emdash_core/graph/schema.py +5 -5
  46. emdash_core/ingestion/orchestrator.py +19 -10
  47. emdash_core/models/agent.py +1 -1
  48. emdash_core/server.py +42 -0
  49. emdash_core/skills/frontend-design/SKILL.md +56 -0
  50. emdash_core/sse/stream.py +5 -0
  51. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -2
  52. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/RECORD +54 -37
  53. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +1 -0
  54. emdash_core/agent/runner.py +0 -601
  55. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
@@ -0,0 +1,217 @@
1
+ """Plan management mixin for the agent runner.
2
+
3
+ This module provides plan approval/rejection functionality as a mixin class.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from ..toolkit import AgentToolkit
11
+
12
+
13
+ class PlanMixin:
14
+ """Mixin class providing plan management methods for AgentRunner.
15
+
16
+ This mixin expects the following attributes on the class:
17
+ - _pending_plan: Optional[dict] - stores pending plan
18
+ - toolkit: AgentToolkit - the agent's toolkit
19
+ - emitter: AgentEventEmitter - event emitter
20
+ - system_prompt: str - current system prompt
21
+
22
+ And the following methods:
23
+ - run(message: str) -> str - to continue execution
24
+ """
25
+
26
+ _pending_plan: Optional[dict]
27
+
28
+ def _get_plan_file_path(self) -> str:
29
+ """Get the plan file path based on repo root.
30
+
31
+ Returns:
32
+ Path to the plan file (e.g., .emdash/plan.md)
33
+ """
34
+ repo_root = self.toolkit._repo_root
35
+ return str(repo_root / ".emdash" / "plan.md")
36
+
37
+ def has_pending_plan(self) -> bool:
38
+ """Check if there's a plan awaiting approval.
39
+
40
+ Returns:
41
+ True if a plan has been submitted and is awaiting approval.
42
+ """
43
+ return self._pending_plan is not None
44
+
45
+ def get_pending_plan(self) -> Optional[dict]:
46
+ """Get the pending plan if one exists.
47
+
48
+ Returns:
49
+ The pending plan dict, or None if no plan is pending.
50
+ """
51
+ return self._pending_plan
52
+
53
+ def approve_plan(self) -> str:
54
+ """Approve the pending plan and transition back to code mode.
55
+
56
+ This method should be called after the user approves a submitted plan.
57
+ It transitions the agent from plan mode back to code mode, allowing
58
+ it to implement the approved plan.
59
+
60
+ Returns:
61
+ The agent's response after transitioning to code mode.
62
+ """
63
+ if not self._pending_plan:
64
+ return "No pending plan to approve."
65
+
66
+ plan_content = self._pending_plan.get("plan", "")
67
+ plan_file_path = None
68
+
69
+ # Try to get plan file path for reference in the approval message
70
+ from ..tools.modes import ModeState, AgentMode
71
+ state = ModeState.get_instance()
72
+ plan_file_path = state.get_plan_file_path()
73
+
74
+ self._pending_plan = None # Clear pending plan
75
+
76
+ # Reset ModeState singleton to code mode
77
+ state.current_mode = AgentMode.CODE
78
+ state.plan_content = plan_content
79
+ state.plan_file_path = None # Clear plan file path
80
+
81
+ # Import AgentToolkit here to avoid circular imports
82
+ from ..toolkit import AgentToolkit
83
+ from ..prompts import build_system_prompt
84
+
85
+ # Rebuild toolkit with plan_mode=False (code mode)
86
+ self.toolkit = AgentToolkit(
87
+ connection=self.toolkit.connection,
88
+ repo_root=self.toolkit._repo_root,
89
+ plan_mode=False,
90
+ )
91
+ self.toolkit.set_emitter(self.emitter)
92
+
93
+ # Update system prompt back to code mode
94
+ self.system_prompt = build_system_prompt(self.toolkit)
95
+
96
+ # Resume execution with approval message
97
+ plan_reference = f"(Plan file: {plan_file_path})" if plan_file_path else ""
98
+ approval_message = f"""Your plan has been APPROVED. {plan_reference}
99
+
100
+ You are now in code mode. Implement the following plan:
101
+
102
+ {plan_content}
103
+
104
+ Proceed with implementation step by step using the available tools."""
105
+
106
+ return self.run(approval_message)
107
+
108
+ def reject_plan(self, feedback: str = "") -> str:
109
+ """Reject the pending plan and provide feedback.
110
+
111
+ The agent remains in plan mode to revise the plan based on feedback.
112
+
113
+ Args:
114
+ feedback: Optional feedback explaining why the plan was rejected.
115
+
116
+ Returns:
117
+ The agent's response after receiving the rejection.
118
+ """
119
+ if not self._pending_plan:
120
+ return "No pending plan to reject."
121
+
122
+ plan_title = self._pending_plan.get("title", "Untitled")
123
+ self._pending_plan = None # Clear pending plan (but stay in plan mode)
124
+
125
+ rejection_message = f"""Your plan "{plan_title}" was REJECTED.
126
+
127
+ {f"Feedback: {feedback}" if feedback else "Please revise the plan."}
128
+
129
+ You are still in plan mode. Please address the feedback and submit a revised plan using exit_plan."""
130
+
131
+ return self.run(rejection_message)
132
+
133
+ def approve_plan_mode(self) -> str:
134
+ """Approve entering plan mode.
135
+
136
+ This method should be called after the user approves a plan mode request
137
+ (triggered by enter_plan_mode tool). It transitions the agent into plan mode.
138
+
139
+ Returns:
140
+ The agent's response after entering plan mode.
141
+ """
142
+ from ..tools.modes import ModeState
143
+ state = ModeState.get_instance()
144
+
145
+ if not state.plan_mode_requested:
146
+ return "No pending plan mode request."
147
+
148
+ reason = state.plan_mode_reason or ""
149
+
150
+ # Set the plan file path before approving
151
+ plan_file_path = self._get_plan_file_path()
152
+ state.set_plan_file_path(plan_file_path)
153
+
154
+ # Actually enter plan mode
155
+ state.approve_plan_mode()
156
+
157
+ # Import here to avoid circular imports
158
+ from ..toolkit import AgentToolkit
159
+ from ..prompts import build_system_prompt
160
+
161
+ # Rebuild toolkit with plan_mode=True and plan_file_path
162
+ self.toolkit = AgentToolkit(
163
+ connection=self.toolkit.connection,
164
+ repo_root=self.toolkit._repo_root,
165
+ plan_mode=True,
166
+ plan_file_path=plan_file_path,
167
+ )
168
+ self.toolkit.set_emitter(self.emitter)
169
+
170
+ # Main agent uses normal prompt - it delegates to Plan subagent
171
+ self.system_prompt = build_system_prompt(self.toolkit)
172
+
173
+ # Resume execution - tell main agent to spawn Plan subagent
174
+ approval_message = f"""Your request to enter plan mode has been APPROVED.
175
+
176
+ Reason: {reason}
177
+
178
+ You are now in plan mode. Follow these steps:
179
+
180
+ 1. **Spawn Plan subagent NOW**:
181
+ `task(subagent_type="Plan", prompt="<your planning request>")`
182
+
183
+ 2. **After the Plan subagent returns**, take its response (the plan content) and:
184
+ a) Write it to `{plan_file_path}` using `write_to_file(path="{plan_file_path}", content=<plan>)`
185
+ b) Call `exit_plan()` to present the plan for user approval
186
+
187
+ Start by spawning the Plan subagent."""
188
+
189
+ return self.run(approval_message)
190
+
191
+ def reject_plan_mode(self, feedback: str = "") -> str:
192
+ """Reject entering plan mode.
193
+
194
+ The agent remains in code mode and continues with the task.
195
+
196
+ Args:
197
+ feedback: Optional feedback explaining why plan mode was rejected.
198
+
199
+ Returns:
200
+ The agent's response after rejection.
201
+ """
202
+ from ..tools.modes import ModeState
203
+ state = ModeState.get_instance()
204
+
205
+ if not state.plan_mode_requested:
206
+ return "No pending plan mode request."
207
+
208
+ # Reset the request
209
+ state.reject_plan_mode()
210
+
211
+ rejection_message = f"""Your request to enter plan mode was REJECTED.
212
+
213
+ {f"Feedback: {feedback}" if feedback else "The user prefers to proceed without detailed planning."}
214
+
215
+ You are still in code mode. Please proceed with the task directly, or ask for clarification if needed."""
216
+
217
+ return self.run(rejection_message)
@@ -0,0 +1,324 @@
1
+ """SDK Agent Runner - Uses Anthropic Agent SDK for Claude models.
2
+
3
+ This module provides an alternative runner that uses the official Anthropic
4
+ Agent SDK (claude-agent-sdk) for Claude models. This enables native support
5
+ for Skills, MCP tools, and extended thinking.
6
+
7
+ For non-Claude models (Fireworks, OpenAI, etc.), use the standard AgentRunner.
8
+ """
9
+
10
+ from pathlib import Path
11
+ from typing import AsyncIterator, Optional, TYPE_CHECKING
12
+ import json
13
+
14
+ from ...utils.logger import log
15
+
16
+ if TYPE_CHECKING:
17
+ from ..events import AgentEventEmitter
18
+
19
+
20
+ class SDKAgentRunner:
21
+ """Agent runner using Anthropic Agent SDK directly.
22
+
23
+ This runner uses the official claude-agent-sdk package which bundles
24
+ Claude Code CLI. It provides native support for:
25
+ - Skills (.claude/skills/)
26
+ - MCP servers (in-process and external)
27
+ - Extended thinking
28
+ - Built-in tools (Read, Write, Bash, Glob, Grep)
29
+
30
+ Example:
31
+ runner = SDKAgentRunner(
32
+ model="claude-sonnet-4-20250514",
33
+ cwd="/path/to/project",
34
+ )
35
+
36
+ async for event in runner.run("Find authentication code"):
37
+ print(event)
38
+ """
39
+
40
+ # Default model for SDK (must be a Claude model)
41
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
42
+
43
+ def __init__(
44
+ self,
45
+ model: str = DEFAULT_MODEL,
46
+ cwd: Optional[str] = None,
47
+ emitter: Optional["AgentEventEmitter"] = None,
48
+ system_prompt: Optional[str] = None,
49
+ plan_mode: bool = False,
50
+ ):
51
+ """Initialize the SDK runner.
52
+
53
+ Args:
54
+ model: Claude model to use (e.g., "claude-sonnet-4-20250514")
55
+ cwd: Working directory for the agent
56
+ emitter: Event emitter for streaming events to UI
57
+ system_prompt: Custom system prompt
58
+ plan_mode: If True, restrict to read-only tools
59
+ """
60
+ self.model = model
61
+ self.cwd = cwd or str(Path.cwd())
62
+ self.emitter = emitter
63
+ self.system_prompt = system_prompt
64
+ self.plan_mode = plan_mode
65
+ self._emdash_server = None # Lazy init
66
+
67
+ def _get_emdash_mcp_server(self):
68
+ """Get or create in-process MCP server for emdash-specific tools."""
69
+ if self._emdash_server is not None:
70
+ return self._emdash_server
71
+
72
+ from claude_agent_sdk import tool, create_sdk_mcp_server
73
+
74
+ @tool(
75
+ "semantic_search",
76
+ "Search code using natural language. Returns relevant functions, classes, and files.",
77
+ {"query": str, "limit": int, "entity_types": list}
78
+ )
79
+ async def semantic_search(args):
80
+ """Run semantic search on the codebase."""
81
+ try:
82
+ from ...graph.connection import get_connection
83
+ from ..tools.search import SemanticSearchTool
84
+
85
+ conn = get_connection()
86
+ search_tool = SemanticSearchTool(conn)
87
+ result = search_tool.execute(
88
+ query=args["query"],
89
+ limit=args.get("limit", 10),
90
+ entity_types=args.get("entity_types"),
91
+ )
92
+
93
+ if result.success:
94
+ # Format result for LLM
95
+ result_text = json.dumps(result.to_dict(), indent=2)
96
+ return {
97
+ "content": [
98
+ {"type": "text", "text": result_text}
99
+ ]
100
+ }
101
+ else:
102
+ return {
103
+ "content": [
104
+ {"type": "text", "text": f"Search failed: {result.error}"}
105
+ ],
106
+ "isError": True,
107
+ }
108
+ except Exception as e:
109
+ log.exception("Semantic search failed")
110
+ return {
111
+ "content": [
112
+ {"type": "text", "text": f"Search error: {str(e)}"}
113
+ ],
114
+ "isError": True,
115
+ }
116
+
117
+ self._emdash_server = create_sdk_mcp_server(
118
+ name="emdash",
119
+ version="1.0.0",
120
+ tools=[semantic_search]
121
+ )
122
+ return self._emdash_server
123
+
124
+ def _get_options(self):
125
+ """Build SDK options with native features."""
126
+ from claude_agent_sdk import ClaudeAgentOptions
127
+
128
+ # Base allowed tools - SDK built-ins
129
+ allowed_tools = [
130
+ # Read-only tools (always available)
131
+ "Read", "Glob", "Grep", "Skill",
132
+ # emdash custom tools
133
+ "mcp__emdash__semantic_search",
134
+ ]
135
+
136
+ # Add write tools unless in plan mode
137
+ if not self.plan_mode:
138
+ allowed_tools.extend([
139
+ "Write", "Bash",
140
+ ])
141
+
142
+ # Build MCP servers config
143
+ mcp_servers = {
144
+ "emdash": self._get_emdash_mcp_server(), # In-process (semantic search)
145
+ }
146
+
147
+ # Add graph MCP if available
148
+ graph_mcp_path = Path(self.cwd) / ".emdash" / "mcp.json"
149
+ if graph_mcp_path.exists():
150
+ # External graph MCP server for code analysis
151
+ mcp_servers["graph"] = {
152
+ "type": "stdio",
153
+ "command": "python",
154
+ "args": ["-m", "emdash_graph_mcp.server"],
155
+ "env": {"REPO_ROOT": self.cwd},
156
+ }
157
+ allowed_tools.extend([
158
+ "mcp__graph__expand_node",
159
+ "mcp__graph__get_callers",
160
+ "mcp__graph__get_callees",
161
+ ])
162
+
163
+ return ClaudeAgentOptions(
164
+ model=self.model,
165
+ cwd=self.cwd,
166
+ system_prompt=self.system_prompt,
167
+
168
+ # Native Skills support - load from .claude/skills/
169
+ setting_sources=["user", "project"],
170
+
171
+ # MCP servers
172
+ mcp_servers=mcp_servers,
173
+
174
+ # Allowed tools
175
+ allowed_tools=allowed_tools,
176
+
177
+ # Permission mode
178
+ permission_mode="acceptEdits" if not self.plan_mode else "plan",
179
+ )
180
+
181
+ def _emit(self, event_type, data: dict):
182
+ """Emit event to emitter if available."""
183
+ if self.emitter:
184
+ from ..events import EventType
185
+ self.emitter.emit(getattr(EventType, event_type), data)
186
+
187
+ async def run(self, prompt: str) -> AsyncIterator[dict]:
188
+ """Execute agent with SDK.
189
+
190
+ Args:
191
+ prompt: User prompt/task
192
+
193
+ Yields:
194
+ Event dicts for UI streaming
195
+ """
196
+ from claude_agent_sdk import (
197
+ ClaudeSDKClient,
198
+ AssistantMessage,
199
+ TextBlock,
200
+ ToolUseBlock,
201
+ ToolResultBlock,
202
+ ResultMessage,
203
+ )
204
+
205
+ options = self._get_options()
206
+
207
+ # Emit session start
208
+ self._emit("SESSION_START", {
209
+ "model": self.model,
210
+ "agent_name": "Emdash Code (SDK)",
211
+ })
212
+
213
+ try:
214
+ async with ClaudeSDKClient(options=options) as client:
215
+ await client.query(prompt)
216
+
217
+ async for message in client.receive_response():
218
+ # Process message and yield events
219
+ if isinstance(message, AssistantMessage):
220
+ for block in message.content:
221
+ if isinstance(block, TextBlock):
222
+ self._emit("PARTIAL_RESPONSE", {"text": block.text})
223
+ yield {"type": "text", "content": block.text}
224
+
225
+ elif isinstance(block, ToolUseBlock):
226
+ self._emit("TOOL_START", {
227
+ "tool": block.name,
228
+ "input": block.input,
229
+ })
230
+ yield {
231
+ "type": "tool_use",
232
+ "name": block.name,
233
+ "input": block.input,
234
+ }
235
+
236
+ elif isinstance(block, ToolResultBlock):
237
+ self._emit("TOOL_RESULT", {
238
+ "tool": block.tool_use_id,
239
+ "result": str(block.content)[:500],
240
+ })
241
+ yield {
242
+ "type": "tool_result",
243
+ "tool_use_id": block.tool_use_id,
244
+ "content": block.content,
245
+ }
246
+
247
+ elif isinstance(message, ResultMessage):
248
+ yield {
249
+ "type": "result",
250
+ "duration_ms": getattr(message, "duration_ms", None),
251
+ "cost_usd": getattr(message, "total_cost_usd", None),
252
+ }
253
+
254
+ except Exception as e:
255
+ log.exception("SDK agent execution failed")
256
+ self._emit("ERROR", {"error": str(e)})
257
+ yield {"type": "error", "error": str(e)}
258
+
259
+ finally:
260
+ self._emit("SESSION_END", {})
261
+
262
+ async def chat(self, message: str) -> str:
263
+ """Simple chat method for compatibility.
264
+
265
+ Args:
266
+ message: User message
267
+
268
+ Returns:
269
+ Assistant response text
270
+ """
271
+ response_text = ""
272
+ async for event in self.run(message):
273
+ if event.get("type") == "text":
274
+ response_text += event.get("content", "")
275
+ return response_text
276
+
277
+
278
+ def is_claude_model(model: str) -> bool:
279
+ """Check if a model string refers to a Claude model.
280
+
281
+ Args:
282
+ model: Model string (e.g., "claude-sonnet-4", "haiku", "fireworks:...")
283
+
284
+ Returns:
285
+ True if this is a Claude model that can use the SDK
286
+ """
287
+ model_lower = model.lower()
288
+
289
+ # Check for non-Claude providers first (more specific)
290
+ non_claude_patterns = [
291
+ "fireworks:",
292
+ "openai:",
293
+ "gpt-",
294
+ "o1",
295
+ "o3",
296
+ "o4",
297
+ "accounts/fireworks",
298
+ "minimax",
299
+ "glm",
300
+ "gemini",
301
+ "llama",
302
+ "mistral",
303
+ "qwen",
304
+ ]
305
+
306
+ for pattern in non_claude_patterns:
307
+ if pattern in model_lower:
308
+ return False
309
+
310
+ # Check for Claude indicators
311
+ claude_indicators = [
312
+ "claude",
313
+ "anthropic",
314
+ "haiku",
315
+ "sonnet",
316
+ "opus",
317
+ ]
318
+
319
+ for indicator in claude_indicators:
320
+ if indicator in model_lower:
321
+ return True
322
+
323
+ # Default: assume not Claude (safer)
324
+ return False
@@ -0,0 +1,67 @@
1
+ """Utility classes and functions for the agent runner."""
2
+
3
+ import json
4
+ from datetime import datetime, date
5
+ from typing import Any
6
+
7
+
8
+ class SafeJSONEncoder(json.JSONEncoder):
9
+ """JSON encoder that handles Neo4j types and other non-serializable objects."""
10
+
11
+ def default(self, obj: Any) -> Any:
12
+ # Handle datetime objects
13
+ if isinstance(obj, (datetime, date)):
14
+ return obj.isoformat()
15
+
16
+ # Handle Neo4j DateTime
17
+ if hasattr(obj, 'isoformat'):
18
+ return obj.isoformat()
19
+
20
+ # Handle Neo4j Date, Time, etc.
21
+ if hasattr(obj, 'to_native'):
22
+ return str(obj.to_native())
23
+
24
+ # Handle sets
25
+ if isinstance(obj, set):
26
+ return list(obj)
27
+
28
+ # Handle bytes
29
+ if isinstance(obj, bytes):
30
+ return obj.decode('utf-8', errors='replace')
31
+
32
+ # Fallback to string representation
33
+ try:
34
+ return str(obj)
35
+ except Exception:
36
+ return f"<non-serializable: {type(obj).__name__}>"
37
+
38
+
39
+ def summarize_tool_result(result: Any) -> str:
40
+ """Create a brief summary of a tool result.
41
+
42
+ Args:
43
+ result: ToolResult object with success, error, and data attributes.
44
+
45
+ Returns:
46
+ Brief summary string.
47
+ """
48
+ if not result.success:
49
+ return f"Error: {result.error}"
50
+
51
+ if not result.data:
52
+ return "Empty result"
53
+
54
+ data = result.data
55
+
56
+ if "results" in data:
57
+ return f"{len(data['results'])} results"
58
+ elif "root_node" in data:
59
+ node = data["root_node"]
60
+ name = node.get("qualified_name") or node.get("file_path", "unknown")
61
+ return f"Expanded: {name}"
62
+ elif "callers" in data:
63
+ return f"{len(data['callers'])} callers"
64
+ elif "callees" in data:
65
+ return f"{len(data['callees'])} callees"
66
+
67
+ return "Completed"