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
@@ -64,6 +64,8 @@ Returns the file content as text."""
64
64
  path: str,
65
65
  start_line: Optional[int] = None,
66
66
  end_line: Optional[int] = None,
67
+ offset: Optional[int] = None,
68
+ limit: Optional[int] = None,
67
69
  ) -> ToolResult:
68
70
  """Read a file.
69
71
 
@@ -71,6 +73,8 @@ Returns the file content as text."""
71
73
  path: Path to the file
72
74
  start_line: Optional starting line (1-indexed)
73
75
  end_line: Optional ending line (1-indexed)
76
+ offset: Alternative to start_line - line number to start from (1-indexed)
77
+ limit: Alternative to end_line - number of lines to read
74
78
 
75
79
  Returns:
76
80
  ToolResult with file content
@@ -89,8 +93,14 @@ Returns the file content as text."""
89
93
  content = full_path.read_text()
90
94
  lines = content.split("\n")
91
95
 
92
- # Handle line ranges
93
- if start_line or end_line:
96
+ # Handle line ranges - support both start_line/end_line and offset/limit
97
+ # offset/limit take precedence if provided
98
+ if offset is not None or limit is not None:
99
+ start_idx = (offset - 1) if offset else 0
100
+ end_idx = start_idx + limit if limit else len(lines)
101
+ lines = lines[start_idx:end_idx]
102
+ content = "\n".join(lines)
103
+ elif start_line or end_line:
94
104
  start_idx = (start_line - 1) if start_line else 0
95
105
  end_idx = end_line if end_line else len(lines)
96
106
  lines = lines[start_idx:end_idx]
@@ -123,6 +133,14 @@ Returns the file content as text."""
123
133
  "type": "integer",
124
134
  "description": "Ending line number (1-indexed)",
125
135
  },
136
+ "offset": {
137
+ "type": "integer",
138
+ "description": "Line number to start reading from (1-indexed). Alternative to start_line.",
139
+ },
140
+ "limit": {
141
+ "type": "integer",
142
+ "description": "Number of lines to read. Alternative to end_line.",
143
+ },
126
144
  },
127
145
  required=["path"],
128
146
  )
@@ -135,6 +153,18 @@ class WriteToFileTool(CodingTool):
135
153
  description = """Write content to a file.
136
154
  Creates the file if it doesn't exist, or overwrites if it does."""
137
155
 
156
+ def __init__(self, repo_root: Path, connection=None, allowed_paths: list[str] | None = None):
157
+ """Initialize with optional path restrictions.
158
+
159
+ Args:
160
+ repo_root: Root directory of the repository
161
+ connection: Optional connection (not used for file ops)
162
+ allowed_paths: If provided, only these paths can be written to.
163
+ Used in plan mode to restrict writes to the plan file.
164
+ """
165
+ super().__init__(repo_root, connection)
166
+ self.allowed_paths = allowed_paths
167
+
138
168
  def execute(
139
169
  self,
140
170
  path: str,
@@ -153,6 +183,19 @@ Creates the file if it doesn't exist, or overwrites if it does."""
153
183
  if not valid:
154
184
  return ToolResult.error_result(error)
155
185
 
186
+ # Check allowed paths restriction (used in plan mode)
187
+ if self.allowed_paths is not None:
188
+ path_str = str(full_path)
189
+ is_allowed = any(
190
+ path_str == allowed or path_str.endswith(allowed.lstrip("/"))
191
+ for allowed in self.allowed_paths
192
+ )
193
+ if not is_allowed:
194
+ return ToolResult.error_result(
195
+ f"In plan mode, you can only write to: {', '.join(self.allowed_paths)}",
196
+ suggestions=["Write your plan to the designated plan file"],
197
+ )
198
+
156
199
  try:
157
200
  # Create parent directories
158
201
  full_path.parent.mkdir(parents=True, exist_ok=True)
@@ -218,8 +261,9 @@ The diff should be in standard unified diff format."""
218
261
 
219
262
  try:
220
263
  # Try to apply with patch command
264
+ # --batch: suppress questions, --forward: skip already-applied patches
221
265
  result = subprocess.run(
222
- ["patch", "-p0", "--forward"],
266
+ ["patch", "-p0", "--forward", "--batch"],
223
267
  input=diff,
224
268
  capture_output=True,
225
269
  text=True,
@@ -230,7 +274,7 @@ The diff should be in standard unified diff format."""
230
274
  if result.returncode != 0:
231
275
  # Try with -p1
232
276
  result = subprocess.run(
233
- ["patch", "-p1", "--forward"],
277
+ ["patch", "-p1", "--forward", "--batch"],
234
278
  input=diff,
235
279
  capture_output=True,
236
280
  text=True,
@@ -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
@@ -85,13 +87,22 @@ Multiple sub-agents can be launched in parallel."""
85
87
  suggestions=["Provide a clear task description in 'prompt'"],
86
88
  )
87
89
 
88
- available_types = list_agent_types()
90
+ available_types = list_agent_types(self.repo_root)
91
+ log.info(f"TaskTool: repo_root={self.repo_root}, available_types={available_types}")
89
92
  if subagent_type not in available_types:
90
93
  return ToolResult.error_result(
91
94
  f"Unknown agent type: {subagent_type}",
92
- suggestions=[f"Available types: {available_types}"],
95
+ suggestions=[
96
+ f"Available types: {available_types}",
97
+ f"Searched in: {self.repo_root / '.emdash' / 'agents'}",
98
+ ],
93
99
  )
94
100
 
101
+ # Log current mode for debugging
102
+ from .modes import ModeState
103
+ mode_state = ModeState.get_instance()
104
+ log.info(f"TaskTool: current_mode={mode_state.current_mode}, subagent_type={subagent_type}")
105
+
95
106
  log.info(
96
107
  "Spawning sub-agent type={} model={} prompt={}",
97
108
  subagent_type,
@@ -99,16 +110,26 @@ Multiple sub-agents can be launched in parallel."""
99
110
  prompt[:50] + "..." if len(prompt) > 50 else prompt,
100
111
  )
101
112
 
113
+ # Emit subagent start event for UI visibility
114
+ if self.emitter:
115
+ from ..events import EventType
116
+ self.emitter.emit(EventType.SUBAGENT_START, {
117
+ "agent_type": subagent_type,
118
+ "prompt": prompt[:100] + "..." if len(prompt) > 100 else prompt,
119
+ "description": description,
120
+ })
121
+
102
122
  if run_in_background:
103
- return self._run_background(subagent_type, prompt, max_turns)
123
+ return self._run_background(subagent_type, prompt, max_turns, thoroughness)
104
124
  else:
105
- return self._run_sync(subagent_type, prompt, max_turns)
125
+ return self._run_sync(subagent_type, prompt, max_turns, thoroughness)
106
126
 
107
127
  def _run_sync(
108
128
  self,
109
129
  subagent_type: str,
110
130
  prompt: str,
111
131
  max_turns: int,
132
+ thoroughness: str = "medium",
112
133
  ) -> ToolResult:
113
134
  """Run sub-agent synchronously in the same process.
114
135
 
@@ -116,6 +137,7 @@ Multiple sub-agents can be launched in parallel."""
116
137
  subagent_type: Agent type
117
138
  prompt: Task prompt
118
139
  max_turns: Maximum API round-trips
140
+ thoroughness: Search thoroughness level
119
141
 
120
142
  Returns:
121
143
  ToolResult with agent results
@@ -127,8 +149,20 @@ Multiple sub-agents can be launched in parallel."""
127
149
  repo_root=self.repo_root,
128
150
  emitter=self.emitter,
129
151
  max_turns=max_turns,
152
+ thoroughness=thoroughness,
130
153
  )
131
154
 
155
+ # Emit subagent end event
156
+ if self.emitter:
157
+ from ..events import EventType
158
+ self.emitter.emit(EventType.SUBAGENT_END, {
159
+ "agent_type": subagent_type,
160
+ "success": result.success,
161
+ "iterations": result.iterations,
162
+ "files_explored": len(result.files_explored),
163
+ "execution_time": result.execution_time,
164
+ })
165
+
132
166
  if result.success:
133
167
  return ToolResult.success_result(
134
168
  data=result.to_dict(),
@@ -149,6 +183,7 @@ Multiple sub-agents can be launched in parallel."""
149
183
  subagent_type: str,
150
184
  prompt: str,
151
185
  max_turns: int,
186
+ thoroughness: str = "medium",
152
187
  ) -> ToolResult:
153
188
  """Run sub-agent in background using a thread.
154
189
 
@@ -156,6 +191,7 @@ Multiple sub-agents can be launched in parallel."""
156
191
  subagent_type: Agent type
157
192
  prompt: Task prompt
158
193
  max_turns: Maximum API round-trips
194
+ thoroughness: Search thoroughness level
159
195
 
160
196
  Returns:
161
197
  ToolResult with task info
@@ -175,6 +211,7 @@ Multiple sub-agents can be launched in parallel."""
175
211
  repo_root=self.repo_root,
176
212
  emitter=self.emitter,
177
213
  max_turns=max_turns,
214
+ thoroughness=thoroughness,
178
215
  )
179
216
 
180
217
  # Store future for later retrieval (attach to class for now)
@@ -221,6 +258,9 @@ Multiple sub-agents can be launched in parallel."""
221
258
 
222
259
  def get_schema(self) -> dict:
223
260
  """Get OpenAI function schema."""
261
+ # Get available agent types dynamically (includes custom agents)
262
+ available_types = list_agent_types(self.repo_root)
263
+
224
264
  return self._make_schema(
225
265
  properties={
226
266
  "description": {
@@ -233,8 +273,8 @@ Multiple sub-agents can be launched in parallel."""
233
273
  },
234
274
  "subagent_type": {
235
275
  "type": "string",
236
- "enum": ["Explore", "Plan"],
237
- "description": "Type of specialized agent",
276
+ "enum": available_types,
277
+ "description": f"Type of specialized agent. Available: {', '.join(available_types)}",
238
278
  "default": "Explore",
239
279
  },
240
280
  "model_tier": {
@@ -257,6 +297,12 @@ Multiple sub-agents can be launched in parallel."""
257
297
  "type": "string",
258
298
  "description": "Agent ID to resume from previous execution",
259
299
  },
300
+ "thoroughness": {
301
+ "type": "string",
302
+ "enum": ["quick", "medium", "thorough"],
303
+ "description": "Search thoroughness: quick (basic searches), medium (moderate exploration), thorough (comprehensive analysis)",
304
+ "default": "medium",
305
+ },
260
306
  },
261
307
  required=["prompt"],
262
308
  )