emdash-core 0.1.25__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 (32) hide show
  1. emdash_core/agent/__init__.py +4 -0
  2. emdash_core/agent/events.py +42 -20
  3. emdash_core/agent/inprocess_subagent.py +123 -10
  4. emdash_core/agent/prompts/__init__.py +4 -3
  5. emdash_core/agent/prompts/main_agent.py +32 -2
  6. emdash_core/agent/prompts/plan_mode.py +236 -107
  7. emdash_core/agent/prompts/subagents.py +79 -15
  8. emdash_core/agent/prompts/workflow.py +145 -26
  9. emdash_core/agent/providers/factory.py +2 -2
  10. emdash_core/agent/providers/openai_provider.py +67 -15
  11. emdash_core/agent/runner/__init__.py +49 -0
  12. emdash_core/agent/runner/agent_runner.py +753 -0
  13. emdash_core/agent/runner/context.py +451 -0
  14. emdash_core/agent/runner/factory.py +108 -0
  15. emdash_core/agent/runner/plan.py +217 -0
  16. emdash_core/agent/runner/sdk_runner.py +324 -0
  17. emdash_core/agent/runner/utils.py +67 -0
  18. emdash_core/agent/skills.py +47 -8
  19. emdash_core/agent/toolkit.py +46 -14
  20. emdash_core/agent/toolkits/plan.py +9 -11
  21. emdash_core/agent/tools/__init__.py +2 -2
  22. emdash_core/agent/tools/coding.py +48 -4
  23. emdash_core/agent/tools/modes.py +151 -143
  24. emdash_core/agent/tools/task.py +41 -2
  25. emdash_core/api/agent.py +555 -1
  26. emdash_core/skills/frontend-design/SKILL.md +56 -0
  27. emdash_core/sse/stream.py +4 -0
  28. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -1
  29. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/RECORD +31 -24
  30. emdash_core/agent/runner.py +0 -1123
  31. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
  32. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +0 -0
@@ -4,7 +4,6 @@ Provides tools for entering and exiting modes, following
4
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
 
@@ -21,15 +20,19 @@ class AgentMode(Enum):
21
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.CODE
29
- plan_content: Optional[str] = None # Stores the current plan
30
-
31
26
  _instance: Optional["ModeState"] = None
32
27
 
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)
35
+
33
36
  @classmethod
34
37
  def get_instance(cls) -> "ModeState":
35
38
  """Get the singleton instance."""
@@ -42,17 +45,63 @@ class ModeState:
42
45
  """Reset the singleton instance."""
43
46
  cls._instance = None
44
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
+
76
+
77
+ class EnterPlanModeTool(BaseTool):
78
+ """Tool for requesting to enter plan mode - REQUIRES USER CONSENT.
79
+
80
+ This follows Claude Code's pattern where entering plan mode is a proposal
81
+ that requires user approval, not an automatic switch.
82
+ """
83
+
84
+ name = "enter_plan_mode"
85
+ description = """Request to enter plan mode for implementation planning.
45
86
 
46
- class EnterModeTool(BaseTool):
47
- """Tool for entering a different mode from code mode."""
87
+ This tool REQUIRES USER APPROVAL before plan mode is activated.
48
88
 
49
- name = "enter_mode"
50
- description = """Enter a different operating mode.
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.
51
91
 
52
- Currently supported modes:
53
- - plan: Enter plan mode to explore the codebase and design an implementation plan.
54
- In plan mode you can ONLY use read-only tools (no file modifications).
55
- Use exit_plan when your plan is ready for user approval."""
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."""
56
105
  category = ToolCategory.PLANNING
57
106
 
58
107
  def __init__(self, connection=None):
@@ -61,92 +110,78 @@ Currently supported modes:
61
110
 
62
111
  def execute(
63
112
  self,
64
- mode: str,
65
113
  reason: str = "",
66
114
  **kwargs,
67
115
  ) -> ToolResult:
68
- """Enter a new mode.
116
+ """Request to enter plan mode (requires user approval).
69
117
 
70
118
  Args:
71
- mode: Mode to enter (currently only "plan" supported)
72
- reason: Why you're entering this mode (helps context)
119
+ reason: Why you want to enter plan mode (shown to user)
73
120
 
74
121
  Returns:
75
- ToolResult indicating mode switch
122
+ ToolResult requesting user approval
76
123
  """
77
- mode_lower = mode.lower()
124
+ state = ModeState.get_instance()
78
125
 
79
- if mode_lower not in SUPPORTED_MODES:
126
+ if state.current_mode == AgentMode.PLAN:
80
127
  return ToolResult.error_result(
81
- f"Unsupported mode: {mode}",
82
- suggestions=[f"Supported modes: {', '.join(SUPPORTED_MODES)}"],
128
+ "Already in plan mode",
129
+ suggestions=["Use exit_plan to submit your plan for approval"],
83
130
  )
84
131
 
85
- state = ModeState.get_instance()
86
-
87
- if mode_lower == "plan":
88
- if state.current_mode == AgentMode.PLAN:
89
- return ToolResult.error_result(
90
- "Already in plan mode",
91
- suggestions=["Use exit_plan to submit your plan for approval"],
92
- )
93
-
94
- state.current_mode = AgentMode.PLAN
95
- state.plan_content = None # Reset plan content
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
+ )
96
138
 
97
- return ToolResult.success_result(
98
- data={
99
- "status": "entered_plan_mode",
100
- "mode": "plan",
101
- "reason": reason,
102
- "message": "You are now in plan mode. Explore the codebase and design your plan. Use exit_plan when ready.",
103
- },
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)"],
104
143
  )
105
144
 
106
- # Placeholder for future modes
107
- return ToolResult.error_result(f"Mode '{mode}' not yet implemented")
145
+ # Mark as requested (not entered - user must approve)
146
+ state.plan_mode_requested = True
147
+ state.plan_mode_reason = reason.strip()
148
+
149
+ return ToolResult.success_result(
150
+ data={
151
+ "status": "plan_mode_requested",
152
+ "reason": reason.strip(),
153
+ "message": "Plan mode requested. Waiting for user approval.",
154
+ },
155
+ )
108
156
 
109
157
  def get_schema(self) -> dict:
110
158
  """Get OpenAI function schema."""
111
159
  return self._make_schema(
112
160
  properties={
113
- "mode": {
114
- "type": "string",
115
- "enum": SUPPORTED_MODES,
116
- "description": "Mode to enter",
117
- },
118
161
  "reason": {
119
162
  "type": "string",
120
- "description": "Brief reason for entering this mode",
163
+ "description": "Why you want to enter plan mode (explain the task complexity)",
121
164
  },
122
165
  },
123
- required=["mode"],
166
+ required=["reason"],
124
167
  )
125
168
 
126
169
 
127
170
  class ExitPlanModeTool(BaseTool):
128
- """Tool for exiting plan mode and submitting plan for approval."""
171
+ """Tool for submitting a plan for user approval."""
129
172
 
130
173
  name = "exit_plan"
131
- description = """Exit plan mode and submit your plan for user approval.
132
-
133
- Scale detail based on task complexity:
134
- - Simple task: title, summary, files_to_modify (steps optional)
135
- - Complex task: all fields including phases, risks, open questions
174
+ description = """Submit an implementation plan for user approval.
136
175
 
137
- Required fields:
138
- - title: Clear, concise plan title
139
- - summary: What will be implemented and why
140
- - files_to_modify: List of file changes with paths, line numbers, and descriptions
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)
141
179
 
142
- Optional fields (include only if needed - each must "earn its place"):
143
- - implementation_steps: For complex tasks needing ordered steps
144
- - risks: Non-trivial risks only
145
- - testing_strategy: Beyond obvious test cases
180
+ Pass the plan content as the 'plan' parameter.
146
181
 
147
182
  The user will either:
148
- - Approve: You'll return to code mode to implement the plan
149
- - Reject: You'll receive feedback and can revise in plan mode"""
183
+ - Approve: You can proceed with implementation
184
+ - Reject: You'll receive feedback and can revise"""
150
185
  category = ToolCategory.PLANNING
151
186
 
152
187
  def __init__(self, connection=None):
@@ -155,60 +190,73 @@ The user will either:
155
190
 
156
191
  def execute(
157
192
  self,
158
- title: str,
159
- summary: str,
160
- files_to_modify: list[dict] = None,
161
- implementation_steps: list[str] = None,
162
- risks: list[str] = None,
163
- testing_strategy: str = None,
193
+ plan: Optional[str] = None,
164
194
  **kwargs,
165
195
  ) -> ToolResult:
166
- """Exit plan mode and submit plan for approval.
196
+ """Submit plan for user approval.
167
197
 
168
198
  Args:
169
- title: Title of the plan
170
- summary: Summary of what will be implemented and why
171
- files_to_modify: List of file changes, each with:
172
- - path: File path (e.g., "src/auth.py")
173
- - lines: Line range (e.g., "45-60" or "new file")
174
- - changes: Description of what changes
175
- implementation_steps: Ordered list of detailed implementation steps
176
- risks: List of potential risks or considerations
177
- testing_strategy: Description of how changes will be tested
199
+ plan: The plan content (required in code mode, optional in plan mode).
178
200
 
179
201
  Returns:
180
202
  ToolResult triggering user approval flow
181
203
  """
182
204
  state = ModeState.get_instance()
183
205
 
184
- if state.current_mode != AgentMode.PLAN:
206
+ # Prevent multiple exit_plan calls per cycle
207
+ if state.plan_submitted:
185
208
  return ToolResult.error_result(
186
- "Not in plan mode",
187
- suggestions=["Use enter_mode with mode='plan' to enter plan mode first"],
209
+ "Plan already submitted. Wait for user approval.",
210
+ suggestions=["Do not call exit_plan again until user responds."],
188
211
  )
189
212
 
190
- if not title or not title.strip():
191
- return ToolResult.error_result("Title is required")
192
- if not summary or not summary.strip():
193
- return ToolResult.error_result("Summary is required")
194
- if not files_to_modify:
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"
195
245
  return ToolResult.error_result(
196
- "files_to_modify is required",
197
- suggestions=["Include at least one file with path, lines, and changes"],
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
+ ],
198
251
  )
199
- # implementation_steps optional for simple tasks where files_to_modify is self-explanatory
200
252
 
201
- # Store plan content for reference
202
- state.plan_content = summary
253
+ # Store plan content for reference and mark as submitted
254
+ state.plan_content = plan_content.strip()
255
+ state.plan_submitted = True
203
256
 
204
257
  return ToolResult.success_result({
205
258
  "status": "plan_submitted",
206
- "title": title.strip(),
207
- "summary": summary.strip(),
208
- "files_to_modify": files_to_modify,
209
- "implementation_steps": implementation_steps or [],
210
- "risks": risks or [],
211
- "testing_strategy": testing_strategy or "",
259
+ "plan": plan_content.strip(),
212
260
  "message": "Plan submitted for user approval. Waiting for user response.",
213
261
  })
214
262
 
@@ -216,52 +264,12 @@ The user will either:
216
264
  """Get OpenAI function schema."""
217
265
  return self._make_schema(
218
266
  properties={
219
- "title": {
220
- "type": "string",
221
- "description": "Clear, concise title of the plan",
222
- },
223
- "summary": {
224
- "type": "string",
225
- "description": "Detailed summary of what will be implemented and why",
226
- },
227
- "files_to_modify": {
228
- "type": "array",
229
- "items": {
230
- "type": "object",
231
- "properties": {
232
- "path": {
233
- "type": "string",
234
- "description": "File path (e.g., 'src/auth.py')",
235
- },
236
- "lines": {
237
- "type": "string",
238
- "description": "Line range (e.g., '45-60') or 'new file'",
239
- },
240
- "changes": {
241
- "type": "string",
242
- "description": "Description of what changes in this file",
243
- },
244
- },
245
- "required": ["path", "changes"],
246
- },
247
- "description": "List of files to modify with line numbers and change descriptions",
248
- },
249
- "implementation_steps": {
250
- "type": "array",
251
- "items": {"type": "string"},
252
- "description": "Detailed ordered list of implementation steps with sub-tasks",
253
- },
254
- "risks": {
255
- "type": "array",
256
- "items": {"type": "string"},
257
- "description": "Potential risks, breaking changes, or considerations",
258
- },
259
- "testing_strategy": {
267
+ "plan": {
260
268
  "type": "string",
261
- "description": "How the changes will be tested (unit tests, integration tests, etc.)",
269
+ "description": "Optional: The implementation plan as markdown. If not provided, reads from the plan file.",
262
270
  },
263
271
  },
264
- required=["title", "summary", "files_to_modify"],
272
+ required=[],
265
273
  )
266
274
 
267
275
 
@@ -62,6 +62,7 @@ Multiple sub-agents can be launched in parallel."""
62
62
  max_turns: int = 10,
63
63
  run_in_background: bool = False,
64
64
  resume: Optional[str] = None,
65
+ thoroughness: str = "medium",
65
66
  **kwargs,
66
67
  ) -> ToolResult:
67
68
  """Spawn a sub-agent to perform a task.
@@ -74,6 +75,7 @@ Multiple sub-agents can be launched in parallel."""
74
75
  max_turns: Maximum API round-trips
75
76
  run_in_background: Run asynchronously
76
77
  resume: Agent ID to resume from
78
+ thoroughness: Search thoroughness level (quick, medium, thorough)
77
79
 
78
80
  Returns:
79
81
  ToolResult with agent results or background task info
@@ -92,6 +94,11 @@ Multiple sub-agents can be launched in parallel."""
92
94
  suggestions=[f"Available types: {available_types}"],
93
95
  )
94
96
 
97
+ # Log current mode for debugging
98
+ from .modes import ModeState
99
+ mode_state = ModeState.get_instance()
100
+ log.info(f"TaskTool: current_mode={mode_state.current_mode}, subagent_type={subagent_type}")
101
+
95
102
  log.info(
96
103
  "Spawning sub-agent type={} model={} prompt={}",
97
104
  subagent_type,
@@ -99,16 +106,26 @@ Multiple sub-agents can be launched in parallel."""
99
106
  prompt[:50] + "..." if len(prompt) > 50 else prompt,
100
107
  )
101
108
 
109
+ # Emit subagent start event for UI visibility
110
+ if self.emitter:
111
+ from ..events import EventType
112
+ self.emitter.emit(EventType.SUBAGENT_START, {
113
+ "agent_type": subagent_type,
114
+ "prompt": prompt[:100] + "..." if len(prompt) > 100 else prompt,
115
+ "description": description,
116
+ })
117
+
102
118
  if run_in_background:
103
- return self._run_background(subagent_type, prompt, max_turns)
119
+ return self._run_background(subagent_type, prompt, max_turns, thoroughness)
104
120
  else:
105
- return self._run_sync(subagent_type, prompt, max_turns)
121
+ return self._run_sync(subagent_type, prompt, max_turns, thoroughness)
106
122
 
107
123
  def _run_sync(
108
124
  self,
109
125
  subagent_type: str,
110
126
  prompt: str,
111
127
  max_turns: int,
128
+ thoroughness: str = "medium",
112
129
  ) -> ToolResult:
113
130
  """Run sub-agent synchronously in the same process.
114
131
 
@@ -116,6 +133,7 @@ Multiple sub-agents can be launched in parallel."""
116
133
  subagent_type: Agent type
117
134
  prompt: Task prompt
118
135
  max_turns: Maximum API round-trips
136
+ thoroughness: Search thoroughness level
119
137
 
120
138
  Returns:
121
139
  ToolResult with agent results
@@ -127,8 +145,20 @@ Multiple sub-agents can be launched in parallel."""
127
145
  repo_root=self.repo_root,
128
146
  emitter=self.emitter,
129
147
  max_turns=max_turns,
148
+ thoroughness=thoroughness,
130
149
  )
131
150
 
151
+ # Emit subagent end event
152
+ if self.emitter:
153
+ from ..events import EventType
154
+ self.emitter.emit(EventType.SUBAGENT_END, {
155
+ "agent_type": subagent_type,
156
+ "success": result.success,
157
+ "iterations": result.iterations,
158
+ "files_explored": len(result.files_explored),
159
+ "execution_time": result.execution_time,
160
+ })
161
+
132
162
  if result.success:
133
163
  return ToolResult.success_result(
134
164
  data=result.to_dict(),
@@ -149,6 +179,7 @@ Multiple sub-agents can be launched in parallel."""
149
179
  subagent_type: str,
150
180
  prompt: str,
151
181
  max_turns: int,
182
+ thoroughness: str = "medium",
152
183
  ) -> ToolResult:
153
184
  """Run sub-agent in background using a thread.
154
185
 
@@ -156,6 +187,7 @@ Multiple sub-agents can be launched in parallel."""
156
187
  subagent_type: Agent type
157
188
  prompt: Task prompt
158
189
  max_turns: Maximum API round-trips
190
+ thoroughness: Search thoroughness level
159
191
 
160
192
  Returns:
161
193
  ToolResult with task info
@@ -175,6 +207,7 @@ Multiple sub-agents can be launched in parallel."""
175
207
  repo_root=self.repo_root,
176
208
  emitter=self.emitter,
177
209
  max_turns=max_turns,
210
+ thoroughness=thoroughness,
178
211
  )
179
212
 
180
213
  # Store future for later retrieval (attach to class for now)
@@ -257,6 +290,12 @@ Multiple sub-agents can be launched in parallel."""
257
290
  "type": "string",
258
291
  "description": "Agent ID to resume from previous execution",
259
292
  },
293
+ "thoroughness": {
294
+ "type": "string",
295
+ "enum": ["quick", "medium", "thorough"],
296
+ "description": "Search thoroughness: quick (basic searches), medium (moderate exploration), thorough (comprehensive analysis)",
297
+ "default": "medium",
298
+ },
260
299
  },
261
300
  required=["prompt"],
262
301
  )