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
@@ -1,10 +1,9 @@
1
1
  """Agent mode management tools.
2
2
 
3
- Provides tools for switching between different agent modes
4
- (exploration, planning, coding, etc).
3
+ Provides tools for entering and exiting modes, following
4
+ Claude Code's approach of explicit mode transitions.
5
5
  """
6
6
 
7
- from dataclasses import dataclass
8
7
  from enum import Enum
9
8
  from typing import Optional
10
9
 
@@ -13,26 +12,26 @@ from .base import BaseTool, ToolResult, ToolCategory
13
12
 
14
13
  class AgentMode(Enum):
15
14
  """Available agent modes."""
15
+ PLAN = "plan"
16
+ CODE = "code"
16
17
 
17
- EXPLORATION = "exploration"
18
- PLANNING = "planning"
19
- CODING = "coding"
20
- REVIEW = "review"
21
- DEBUG = "debug"
18
+
19
+ # Modes that can be entered via enter_mode tool
20
+ SUPPORTED_MODES = ["plan"] # Extensible list
22
21
 
23
22
 
24
- @dataclass
25
23
  class ModeState:
26
24
  """Singleton state for agent mode."""
27
25
 
28
- current_mode: AgentMode = AgentMode.EXPLORATION
29
- mode_context: dict = None
30
-
31
26
  _instance: Optional["ModeState"] = None
32
27
 
33
- def __post_init__(self):
34
- if self.mode_context is None:
35
- self.mode_context = {}
28
+ def __init__(self):
29
+ self.current_mode: AgentMode = AgentMode.CODE
30
+ self.plan_content: Optional[str] = None # Stores the current plan
31
+ self.plan_submitted: bool = False # Track if exit_plan was called this cycle
32
+ self.plan_mode_requested: bool = False # Track if enter_plan_mode was called
33
+ self.plan_mode_reason: Optional[str] = None # Reason for plan mode request
34
+ self.plan_file_path: Optional[str] = None # Path to the plan file (set when entering plan mode)
36
35
 
37
36
  @classmethod
38
37
  def get_instance(cls) -> "ModeState":
@@ -46,19 +45,63 @@ class ModeState:
46
45
  """Reset the singleton instance."""
47
46
  cls._instance = None
48
47
 
48
+ def reset_cycle(self) -> None:
49
+ """Reset per-cycle state (called on new user message)."""
50
+ self.plan_submitted = False
51
+ self.plan_mode_requested = False
52
+ self.plan_mode_reason = None
53
+
54
+ def approve_plan_mode(self) -> None:
55
+ """Approve plan mode entry (called when user approves)."""
56
+ self.current_mode = AgentMode.PLAN
57
+ self.plan_content = None
58
+ self.plan_mode_requested = False
59
+ self.plan_mode_reason = None
60
+ # plan_file_path should already be set by the caller
61
+
62
+ def reject_plan_mode(self) -> None:
63
+ """Reject plan mode entry (called when user rejects)."""
64
+ self.plan_mode_requested = False
65
+ self.plan_mode_reason = None
66
+ self.plan_file_path = None
67
+
68
+ def set_plan_file_path(self, path: str) -> None:
69
+ """Set the plan file path (called when entering plan mode)."""
70
+ self.plan_file_path = path
71
+
72
+ def get_plan_file_path(self) -> Optional[str]:
73
+ """Get the current plan file path."""
74
+ return self.plan_file_path
75
+
49
76
 
50
- class SwitchModeTool(BaseTool):
51
- """Tool for switching between agent modes."""
77
+ class EnterPlanModeTool(BaseTool):
78
+ """Tool for requesting to enter plan mode - REQUIRES USER CONSENT.
52
79
 
53
- name = "switch_mode"
54
- description = """Switch the agent to a different operating mode.
80
+ This follows Claude Code's pattern where entering plan mode is a proposal
81
+ that requires user approval, not an automatic switch.
82
+ """
55
83
 
56
- Modes:
57
- - exploration: General codebase exploration and understanding
58
- - planning: Feature planning and specification writing
59
- - coding: Active code editing and implementation
60
- - review: Code review and feedback
61
- - debug: Debugging and issue investigation"""
84
+ name = "enter_plan_mode"
85
+ description = """Request to enter plan mode for implementation planning.
86
+
87
+ This tool REQUIRES USER APPROVAL before plan mode is activated.
88
+
89
+ Use this proactively when you're about to start a non-trivial implementation task.
90
+ Getting user sign-off on your approach before writing code prevents wasted effort.
91
+
92
+ When to use:
93
+ - New feature implementation requiring architectural decisions
94
+ - Multiple valid approaches exist (user should choose)
95
+ - Multi-file changes expected (more than 2-3 files)
96
+ - Unclear requirements that need exploration first
97
+
98
+ When NOT to use:
99
+ - Single-line or few-line fixes
100
+ - Trivial tasks with obvious implementation
101
+ - Pure research/exploration (just explore directly)
102
+ - Tasks with very specific, detailed instructions already provided
103
+
104
+ The user will see your reason and can approve or reject entering plan mode."""
62
105
  category = ToolCategory.PLANNING
63
106
 
64
107
  def __init__(self, connection=None):
@@ -67,39 +110,47 @@ Modes:
67
110
 
68
111
  def execute(
69
112
  self,
70
- mode: str,
71
- context: Optional[str] = None,
113
+ reason: str = "",
114
+ **kwargs,
72
115
  ) -> ToolResult:
73
- """Switch to a new mode.
116
+ """Request to enter plan mode (requires user approval).
74
117
 
75
118
  Args:
76
- mode: Target mode name
77
- context: Optional context for the new mode
119
+ reason: Why you want to enter plan mode (shown to user)
78
120
 
79
121
  Returns:
80
- ToolResult indicating success
122
+ ToolResult requesting user approval
81
123
  """
82
- try:
83
- new_mode = AgentMode(mode.lower())
84
- except ValueError:
85
- valid_modes = [m.value for m in AgentMode]
124
+ state = ModeState.get_instance()
125
+
126
+ if state.current_mode == AgentMode.PLAN:
86
127
  return ToolResult.error_result(
87
- f"Invalid mode: {mode}",
88
- suggestions=[f"Valid modes: {', '.join(valid_modes)}"],
128
+ "Already in plan mode",
129
+ suggestions=["Use exit_plan to submit your plan for approval"],
89
130
  )
90
131
 
91
- state = ModeState.get_instance()
92
- old_mode = state.current_mode
93
- state.current_mode = new_mode
132
+ # Check if already requested this cycle
133
+ if state.plan_mode_requested:
134
+ return ToolResult.error_result(
135
+ "Plan mode already requested. Wait for user response.",
136
+ suggestions=["Do not call enter_plan_mode again until user responds."],
137
+ )
94
138
 
95
- if context:
96
- state.mode_context[new_mode.value] = context
139
+ if not reason or not reason.strip():
140
+ return ToolResult.error_result(
141
+ "Reason is required",
142
+ suggestions=["Explain why you need plan mode (helps user decide)"],
143
+ )
144
+
145
+ # Mark as requested (not entered - user must approve)
146
+ state.plan_mode_requested = True
147
+ state.plan_mode_reason = reason.strip()
97
148
 
98
149
  return ToolResult.success_result(
99
150
  data={
100
- "previous_mode": old_mode.value,
101
- "current_mode": new_mode.value,
102
- "context": context,
151
+ "status": "plan_mode_requested",
152
+ "reason": reason.strip(),
153
+ "message": "Plan mode requested. Waiting for user approval.",
103
154
  },
104
155
  )
105
156
 
@@ -107,17 +158,118 @@ Modes:
107
158
  """Get OpenAI function schema."""
108
159
  return self._make_schema(
109
160
  properties={
110
- "mode": {
161
+ "reason": {
111
162
  "type": "string",
112
- "enum": [m.value for m in AgentMode],
113
- "description": "Target mode to switch to",
163
+ "description": "Why you want to enter plan mode (explain the task complexity)",
114
164
  },
115
- "context": {
165
+ },
166
+ required=["reason"],
167
+ )
168
+
169
+
170
+ class ExitPlanModeTool(BaseTool):
171
+ """Tool for submitting a plan for user approval."""
172
+
173
+ name = "exit_plan"
174
+ description = """Submit an implementation plan for user approval.
175
+
176
+ Use this tool to present a plan to the user for approval. The plan can come from:
177
+ 1. A Plan sub-agent you spawned via task(subagent_type="Plan", ...)
178
+ 2. Your own planning (if in plan mode)
179
+
180
+ Pass the plan content as the 'plan' parameter.
181
+
182
+ The user will either:
183
+ - Approve: You can proceed with implementation
184
+ - Reject: You'll receive feedback and can revise"""
185
+ category = ToolCategory.PLANNING
186
+
187
+ def __init__(self, connection=None):
188
+ """Initialize without requiring connection."""
189
+ self.connection = connection
190
+
191
+ def execute(
192
+ self,
193
+ plan: Optional[str] = None,
194
+ **kwargs,
195
+ ) -> ToolResult:
196
+ """Submit plan for user approval.
197
+
198
+ Args:
199
+ plan: The plan content (required in code mode, optional in plan mode).
200
+
201
+ Returns:
202
+ ToolResult triggering user approval flow
203
+ """
204
+ state = ModeState.get_instance()
205
+
206
+ # Prevent multiple exit_plan calls per cycle
207
+ if state.plan_submitted:
208
+ return ToolResult.error_result(
209
+ "Plan already submitted. Wait for user approval.",
210
+ suggestions=["Do not call exit_plan again until user responds."],
211
+ )
212
+
213
+ # Get plan content from parameter or file
214
+ plan_content = plan
215
+
216
+ # In plan mode, try to read from plan file if not provided
217
+ if state.current_mode == AgentMode.PLAN:
218
+ if not plan_content or not plan_content.strip():
219
+ plan_file_path = state.get_plan_file_path()
220
+ if plan_file_path:
221
+ try:
222
+ from pathlib import Path
223
+ plan_path = Path(plan_file_path)
224
+ if plan_path.exists():
225
+ plan_content = plan_path.read_text()
226
+ except Exception as e:
227
+ return ToolResult.error_result(
228
+ f"Failed to read plan file: {e}",
229
+ suggestions=[f"Write your plan to {plan_file_path} first"],
230
+ )
231
+
232
+ # In code mode, plan content is required (from Plan subagent)
233
+ if state.current_mode == AgentMode.CODE:
234
+ if not plan_content or not plan_content.strip():
235
+ return ToolResult.error_result(
236
+ "Plan content is required when submitting from code mode",
237
+ suggestions=[
238
+ "Pass the plan from your Plan sub-agent as the 'plan' parameter",
239
+ "Example: exit_plan(plan=<plan_from_subagent>)",
240
+ ],
241
+ )
242
+
243
+ if not plan_content or not plan_content.strip():
244
+ plan_file_path = state.get_plan_file_path() or "the plan file"
245
+ return ToolResult.error_result(
246
+ "Plan content is required",
247
+ suggestions=[
248
+ f"Write your plan to {plan_file_path} using write_to_file, then call exit_plan",
249
+ "Or pass the plan directly as a parameter",
250
+ ],
251
+ )
252
+
253
+ # Store plan content for reference and mark as submitted
254
+ state.plan_content = plan_content.strip()
255
+ state.plan_submitted = True
256
+
257
+ return ToolResult.success_result({
258
+ "status": "plan_submitted",
259
+ "plan": plan_content.strip(),
260
+ "message": "Plan submitted for user approval. Waiting for user response.",
261
+ })
262
+
263
+ def get_schema(self) -> dict:
264
+ """Get OpenAI function schema."""
265
+ return self._make_schema(
266
+ properties={
267
+ "plan": {
116
268
  "type": "string",
117
- "description": "Optional context for the new mode",
269
+ "description": "Optional: The implementation plan as markdown. If not provided, reads from the plan file.",
118
270
  },
119
271
  },
120
- required=["mode"],
272
+ required=[],
121
273
  )
122
274
 
123
275
 
@@ -125,14 +277,14 @@ class GetModeTool(BaseTool):
125
277
  """Tool for getting current agent mode."""
126
278
 
127
279
  name = "get_mode"
128
- description = "Get the current agent operating mode and its context."
280
+ description = "Get the current agent operating mode (plan or code)."
129
281
  category = ToolCategory.PLANNING
130
282
 
131
283
  def __init__(self, connection=None):
132
284
  """Initialize without requiring connection."""
133
285
  self.connection = connection
134
286
 
135
- def execute(self) -> ToolResult:
287
+ def execute(self, **kwargs) -> ToolResult:
136
288
  """Get current mode.
137
289
 
138
290
  Returns:
@@ -143,8 +295,8 @@ class GetModeTool(BaseTool):
143
295
  return ToolResult.success_result(
144
296
  data={
145
297
  "current_mode": state.current_mode.value,
146
- "context": state.mode_context.get(state.current_mode.value),
147
- "available_modes": [m.value for m in AgentMode],
298
+ "has_plan": state.plan_content is not None,
299
+ "available_modes": SUPPORTED_MODES,
148
300
  },
149
301
  )
150
302
 
@@ -22,6 +22,7 @@ Useful for finding code related to concepts like "authentication", "database que
22
22
  entity_types: Optional[list[str]] = None,
23
23
  limit: int = 10,
24
24
  min_score: float = 0.5,
25
+ **kwargs, # Ignore unexpected params from LLM
25
26
  ) -> ToolResult:
26
27
  """Execute semantic search.
27
28
 
@@ -121,6 +122,7 @@ More precise than semantic search when you know part of the name."""
121
122
  query: str,
122
123
  entity_types: Optional[list[str]] = None,
123
124
  limit: int = 10,
125
+ **kwargs, # Ignore unexpected params from LLM
124
126
  ) -> ToolResult:
125
127
  """Execute text search.
126
128
 
@@ -215,6 +217,7 @@ Useful for finding code patterns, string literals, or specific implementations."
215
217
  file_pattern: Optional[str] = None,
216
218
  max_results: int = 50,
217
219
  context_lines: int = 2,
220
+ **kwargs, # Ignore unexpected params from LLM
218
221
  ) -> ToolResult:
219
222
  """Execute grep search.
220
223
 
@@ -324,6 +327,7 @@ Common patterns:
324
327
  self,
325
328
  pattern: str,
326
329
  max_results: int = 100,
330
+ **kwargs, # Ignore unexpected params from LLM
327
331
  ) -> ToolResult:
328
332
  """Execute glob search for files.
329
333
 
@@ -0,0 +1,193 @@
1
+ """Skill invocation tool.
2
+
3
+ Allows the agent to activate and invoke skills during task execution.
4
+ Skills provide specialized instructions for repeatable tasks.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from .base import BaseTool, ToolResult, ToolCategory
10
+ from ..skills import SkillRegistry, Skill
11
+
12
+
13
+ class SkillTool(BaseTool):
14
+ """Tool for invoking skills.
15
+
16
+ Skills are markdown-based instruction files that teach the agent
17
+ how to perform specific, repeatable tasks. This tool allows
18
+ explicit skill invocation.
19
+ """
20
+
21
+ name = "skill"
22
+ description = """Invoke a skill for specialized task execution.
23
+
24
+ Skills provide focused instructions for common tasks like:
25
+ - commit: Generate commit messages following conventions
26
+ - review-pr: Review PRs with code standards
27
+ - security-review: Security-focused code review
28
+
29
+ When you invoke a skill, you receive its instructions which you should follow.
30
+ Use list_skills to see available skills."""
31
+ category = ToolCategory.PLANNING
32
+
33
+ def __init__(self, connection=None):
34
+ """Initialize without requiring connection."""
35
+ self.connection = connection
36
+
37
+ def execute(
38
+ self,
39
+ skill: str,
40
+ args: str = "",
41
+ **kwargs,
42
+ ) -> ToolResult:
43
+ """Invoke a skill.
44
+
45
+ Args:
46
+ skill: Name of the skill to invoke
47
+ args: Optional arguments to pass to the skill
48
+
49
+ Returns:
50
+ ToolResult with skill instructions
51
+ """
52
+ registry = SkillRegistry.get_instance()
53
+ skill_obj = registry.get_skill(skill)
54
+
55
+ if skill_obj is None:
56
+ available = registry.list_skills()
57
+ if available:
58
+ return ToolResult.error_result(
59
+ f"Skill '{skill}' not found",
60
+ suggestions=[f"Available skills: {', '.join(available)}"],
61
+ )
62
+ else:
63
+ return ToolResult.error_result(
64
+ f"Skill '{skill}' not found. No skills are currently loaded.",
65
+ suggestions=[
66
+ "Create skills in .emdash/skills/<skill-name>/SKILL.md",
67
+ "Skills are loaded from the .emdash/skills/ directory",
68
+ ],
69
+ )
70
+
71
+ # Build the skill activation response
72
+ response_parts = [
73
+ f"# Skill Activated: {skill_obj.name}",
74
+ "",
75
+ f"**Description**: {skill_obj.description}",
76
+ "",
77
+ ]
78
+
79
+ if args:
80
+ response_parts.extend([
81
+ f"**Arguments**: {args}",
82
+ "",
83
+ ])
84
+
85
+ if skill_obj.tools:
86
+ response_parts.extend([
87
+ f"**Required tools**: {', '.join(skill_obj.tools)}",
88
+ "",
89
+ ])
90
+
91
+ response_parts.extend([
92
+ "---",
93
+ "",
94
+ skill_obj.instructions,
95
+ ])
96
+
97
+ return ToolResult.success_result(
98
+ data={
99
+ "skill_name": skill_obj.name,
100
+ "description": skill_obj.description,
101
+ "instructions": skill_obj.instructions,
102
+ "tools": skill_obj.tools,
103
+ "args": args,
104
+ "message": "\n".join(response_parts),
105
+ },
106
+ )
107
+
108
+ def get_schema(self) -> dict:
109
+ """Get OpenAI function schema."""
110
+ registry = SkillRegistry.get_instance()
111
+ available_skills = registry.list_skills()
112
+
113
+ description = self.description
114
+ if available_skills:
115
+ description += f"\n\nCurrently available skills: {', '.join(available_skills)}"
116
+
117
+ return {
118
+ "type": "function",
119
+ "function": {
120
+ "name": self.name,
121
+ "description": description,
122
+ "parameters": {
123
+ "type": "object",
124
+ "properties": {
125
+ "skill": {
126
+ "type": "string",
127
+ "description": "Name of the skill to invoke",
128
+ },
129
+ "args": {
130
+ "type": "string",
131
+ "description": "Optional arguments for the skill (e.g., PR number, file path)",
132
+ },
133
+ },
134
+ "required": ["skill"],
135
+ },
136
+ },
137
+ }
138
+
139
+
140
+ class ListSkillsTool(BaseTool):
141
+ """Tool for listing available skills."""
142
+
143
+ name = "list_skills"
144
+ description = "List all available skills and their descriptions."
145
+ category = ToolCategory.PLANNING
146
+
147
+ def __init__(self, connection=None):
148
+ """Initialize without requiring connection."""
149
+ self.connection = connection
150
+
151
+ def execute(self, **kwargs) -> ToolResult:
152
+ """List available skills.
153
+
154
+ Returns:
155
+ ToolResult with list of skills
156
+ """
157
+ registry = SkillRegistry.get_instance()
158
+ skills = registry.get_all_skills()
159
+
160
+ if not skills:
161
+ return ToolResult.success_result(
162
+ data={
163
+ "skills": [],
164
+ "message": "No skills loaded. Create skills in .emdash/skills/<skill-name>/SKILL.md",
165
+ },
166
+ )
167
+
168
+ skills_list = []
169
+ for skill in skills.values():
170
+ skills_list.append({
171
+ "name": skill.name,
172
+ "description": skill.description,
173
+ "user_invocable": skill.user_invocable,
174
+ "tools": skill.tools,
175
+ })
176
+
177
+ # Build human-readable message
178
+ lines = ["# Available Skills", ""]
179
+ for s in skills_list:
180
+ invocable = f" (invoke with /{s['name']})" if s["user_invocable"] else ""
181
+ lines.append(f"- **{s['name']}**: {s['description']}{invocable}")
182
+
183
+ return ToolResult.success_result(
184
+ data={
185
+ "skills": skills_list,
186
+ "count": len(skills_list),
187
+ "message": "\n".join(lines),
188
+ },
189
+ )
190
+
191
+ def get_schema(self) -> dict:
192
+ """Get OpenAI function schema."""
193
+ return self._make_schema(properties={}, required=[])