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.
- emdash_core/__init__.py +6 -1
- emdash_core/agent/__init__.py +4 -0
- emdash_core/agent/events.py +52 -1
- emdash_core/agent/inprocess_subagent.py +123 -10
- emdash_core/agent/prompts/__init__.py +6 -0
- emdash_core/agent/prompts/main_agent.py +53 -3
- emdash_core/agent/prompts/plan_mode.py +255 -0
- emdash_core/agent/prompts/subagents.py +84 -16
- emdash_core/agent/prompts/workflow.py +270 -56
- emdash_core/agent/providers/base.py +4 -0
- emdash_core/agent/providers/factory.py +2 -2
- emdash_core/agent/providers/models.py +7 -0
- emdash_core/agent/providers/openai_provider.py +137 -13
- emdash_core/agent/runner/__init__.py +49 -0
- emdash_core/agent/runner/agent_runner.py +753 -0
- emdash_core/agent/runner/context.py +451 -0
- emdash_core/agent/runner/factory.py +108 -0
- emdash_core/agent/runner/plan.py +217 -0
- emdash_core/agent/runner/sdk_runner.py +324 -0
- emdash_core/agent/runner/utils.py +67 -0
- emdash_core/agent/skills.py +358 -0
- emdash_core/agent/toolkit.py +85 -5
- emdash_core/agent/toolkits/plan.py +9 -11
- emdash_core/agent/tools/__init__.py +3 -2
- emdash_core/agent/tools/coding.py +48 -4
- emdash_core/agent/tools/modes.py +207 -55
- emdash_core/agent/tools/search.py +4 -0
- emdash_core/agent/tools/skill.py +193 -0
- emdash_core/agent/tools/spec.py +61 -94
- emdash_core/agent/tools/task.py +41 -2
- emdash_core/agent/tools/tasks.py +15 -78
- emdash_core/api/agent.py +562 -8
- emdash_core/api/index.py +1 -1
- emdash_core/api/projectmd.py +4 -2
- emdash_core/api/router.py +2 -0
- emdash_core/api/skills.py +241 -0
- emdash_core/checkpoint/__init__.py +40 -0
- emdash_core/checkpoint/cli.py +175 -0
- emdash_core/checkpoint/git_operations.py +250 -0
- emdash_core/checkpoint/manager.py +231 -0
- emdash_core/checkpoint/models.py +107 -0
- emdash_core/checkpoint/storage.py +201 -0
- emdash_core/config.py +1 -1
- emdash_core/core/config.py +18 -2
- emdash_core/graph/schema.py +5 -5
- emdash_core/ingestion/orchestrator.py +19 -10
- emdash_core/models/agent.py +1 -1
- emdash_core/server.py +42 -0
- emdash_core/skills/frontend-design/SKILL.md +56 -0
- emdash_core/sse/stream.py +5 -0
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -2
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/RECORD +54 -37
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +1 -0
- emdash_core/agent/runner.py +0 -601
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Skill loader from .emdash/skills/*/SKILL.md files.
|
|
2
|
+
|
|
3
|
+
Skills are markdown-based instruction files that teach the agent how to
|
|
4
|
+
perform specific, repeatable tasks. They can be automatically applied
|
|
5
|
+
when relevant or explicitly invoked via /skill_name.
|
|
6
|
+
|
|
7
|
+
Similar to Claude Code's skills system:
|
|
8
|
+
https://docs.anthropic.com/en/docs/claude-code/skills
|
|
9
|
+
|
|
10
|
+
Skills are loaded from two locations:
|
|
11
|
+
1. Built-in skills bundled with emdash_core (always available)
|
|
12
|
+
2. User repo skills in .emdash/skills/ (can override built-in)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from ..utils.logger import log
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_builtin_skills_dir() -> Path:
|
|
23
|
+
"""Get the directory containing built-in skills bundled with emdash_core."""
|
|
24
|
+
return Path(__file__).parent.parent / "skills"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Skill:
|
|
29
|
+
"""A skill configuration loaded from SKILL.md.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
name: Unique skill identifier (from directory name or frontmatter)
|
|
33
|
+
description: Brief description of when to use this skill
|
|
34
|
+
instructions: The main prompt/instructions content
|
|
35
|
+
tools: List of tools this skill needs access to
|
|
36
|
+
user_invocable: Whether skill can be invoked with /name
|
|
37
|
+
file_path: Source file path
|
|
38
|
+
_builtin: Whether this is a built-in skill bundled with emdash_core
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
description: str = ""
|
|
43
|
+
instructions: str = ""
|
|
44
|
+
tools: list[str] = field(default_factory=list)
|
|
45
|
+
user_invocable: bool = False
|
|
46
|
+
file_path: Optional[Path] = None
|
|
47
|
+
_builtin: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SkillRegistry:
|
|
51
|
+
"""Registry for managing loaded skills.
|
|
52
|
+
|
|
53
|
+
Singleton that maintains the list of available skills
|
|
54
|
+
and provides lookup functionality.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_instance: Optional["SkillRegistry"] = None
|
|
58
|
+
_skills: dict[str, Skill]
|
|
59
|
+
_skills_dir: Optional[Path]
|
|
60
|
+
|
|
61
|
+
def __new__(cls):
|
|
62
|
+
if cls._instance is None:
|
|
63
|
+
cls._instance = super().__new__(cls)
|
|
64
|
+
cls._instance._skills = {}
|
|
65
|
+
cls._instance._skills_dir = None
|
|
66
|
+
return cls._instance
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def get_instance(cls) -> "SkillRegistry":
|
|
70
|
+
"""Get the singleton instance."""
|
|
71
|
+
return cls()
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def reset(cls) -> None:
|
|
75
|
+
"""Reset the singleton instance."""
|
|
76
|
+
if cls._instance is not None:
|
|
77
|
+
cls._instance._skills = {}
|
|
78
|
+
cls._instance._skills_dir = None
|
|
79
|
+
|
|
80
|
+
def load_skills(self, skills_dir: Optional[Path] = None) -> dict[str, Skill]:
|
|
81
|
+
"""Load skills from built-in and user repo directories.
|
|
82
|
+
|
|
83
|
+
Skills are loaded from two locations (in order):
|
|
84
|
+
1. Built-in skills bundled with emdash_core (always available)
|
|
85
|
+
2. User repo skills in .emdash/skills/ (can override built-in)
|
|
86
|
+
|
|
87
|
+
Each skill is a directory containing a SKILL.md file:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
.emdash/skills/
|
|
91
|
+
├── commit/
|
|
92
|
+
│ └── SKILL.md
|
|
93
|
+
└── review-pr/
|
|
94
|
+
└── SKILL.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
SKILL.md format:
|
|
98
|
+
```markdown
|
|
99
|
+
---
|
|
100
|
+
name: commit
|
|
101
|
+
description: Generate commit messages following conventions
|
|
102
|
+
user_invocable: true
|
|
103
|
+
tools: [execute_command, read_file]
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
# Commit Message Generation
|
|
107
|
+
|
|
108
|
+
Instructions for the skill...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
skills_dir: Directory containing user skill subdirectories.
|
|
113
|
+
Defaults to .emdash/skills/ in cwd.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Dict mapping skill name to Skill
|
|
117
|
+
"""
|
|
118
|
+
if skills_dir is None:
|
|
119
|
+
skills_dir = Path.cwd() / ".emdash" / "skills"
|
|
120
|
+
|
|
121
|
+
self._skills_dir = skills_dir
|
|
122
|
+
|
|
123
|
+
skills = {}
|
|
124
|
+
|
|
125
|
+
# First, load built-in skills bundled with emdash_core
|
|
126
|
+
builtin_dir = _get_builtin_skills_dir()
|
|
127
|
+
if builtin_dir.exists():
|
|
128
|
+
builtin_skills = self._load_skills_from_dir(builtin_dir, is_builtin=True)
|
|
129
|
+
skills.update(builtin_skills)
|
|
130
|
+
|
|
131
|
+
# Then, load user repo skills (can override built-in)
|
|
132
|
+
if skills_dir.exists():
|
|
133
|
+
user_skills = self._load_skills_from_dir(skills_dir, is_builtin=False)
|
|
134
|
+
skills.update(user_skills)
|
|
135
|
+
|
|
136
|
+
if skills:
|
|
137
|
+
log.info(f"Loaded {len(skills)} skills ({len([s for s in self._skills.values() if getattr(s, '_builtin', False)])} built-in)")
|
|
138
|
+
|
|
139
|
+
return skills
|
|
140
|
+
|
|
141
|
+
def _load_skills_from_dir(self, skills_dir: Path, is_builtin: bool = False) -> dict[str, Skill]:
|
|
142
|
+
"""Load skills from a specific directory.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
skills_dir: Directory containing skill subdirectories
|
|
146
|
+
is_builtin: Whether these are built-in skills
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping skill name to Skill
|
|
150
|
+
"""
|
|
151
|
+
skills = {}
|
|
152
|
+
|
|
153
|
+
# Look for SKILL.md in subdirectories
|
|
154
|
+
for skill_dir in skills_dir.iterdir():
|
|
155
|
+
if not skill_dir.is_dir():
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
skill_file = skill_dir / "SKILL.md"
|
|
159
|
+
if not skill_file.exists():
|
|
160
|
+
# Also try lowercase
|
|
161
|
+
skill_file = skill_dir / "skill.md"
|
|
162
|
+
if not skill_file.exists():
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
skill = _parse_skill_file(skill_file, skill_dir.name)
|
|
167
|
+
if skill:
|
|
168
|
+
skill._builtin = is_builtin # Mark as built-in or user-defined
|
|
169
|
+
skills[skill.name] = skill
|
|
170
|
+
self._skills[skill.name] = skill
|
|
171
|
+
source = "built-in" if is_builtin else "user"
|
|
172
|
+
log.debug(f"Loaded {source} skill: {skill.name}")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
log.warning(f"Failed to load skill from {skill_file}: {e}")
|
|
175
|
+
|
|
176
|
+
return skills
|
|
177
|
+
|
|
178
|
+
def get_skill(self, name: str) -> Optional[Skill]:
|
|
179
|
+
"""Get a specific skill by name.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
name: Skill name
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Skill or None if not found
|
|
186
|
+
"""
|
|
187
|
+
return self._skills.get(name)
|
|
188
|
+
|
|
189
|
+
def list_skills(self) -> list[str]:
|
|
190
|
+
"""List available skill names.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List of skill names
|
|
194
|
+
"""
|
|
195
|
+
return list(self._skills.keys())
|
|
196
|
+
|
|
197
|
+
def get_all_skills(self) -> dict[str, Skill]:
|
|
198
|
+
"""Get all loaded skills.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Dict mapping skill name to Skill
|
|
202
|
+
"""
|
|
203
|
+
return self._skills.copy()
|
|
204
|
+
|
|
205
|
+
def get_user_invocable_skills(self) -> list[Skill]:
|
|
206
|
+
"""Get skills that can be invoked with /name.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of user-invocable skills
|
|
210
|
+
"""
|
|
211
|
+
return [s for s in self._skills.values() if s.user_invocable]
|
|
212
|
+
|
|
213
|
+
def get_skills_for_prompt(self) -> str:
|
|
214
|
+
"""Generate skills section for system prompt.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Formatted string describing available skills
|
|
218
|
+
"""
|
|
219
|
+
if not self._skills:
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
lines = ["## Available Skills\n"]
|
|
223
|
+
lines.append("The following skills are available. Use them when the task matches their description:\n")
|
|
224
|
+
|
|
225
|
+
for skill in self._skills.values():
|
|
226
|
+
invocable = " (user-invocable: /{})".format(skill.name) if skill.user_invocable else ""
|
|
227
|
+
lines.append(f"- **{skill.name}**: {skill.description}{invocable}")
|
|
228
|
+
|
|
229
|
+
lines.append("")
|
|
230
|
+
lines.append("To activate a skill, use the `skill` tool with the skill name.")
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_skill_file(file_path: Path, default_name: str) -> Optional[Skill]:
|
|
237
|
+
"""Parse a single SKILL.md file.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
file_path: Path to the SKILL.md file
|
|
241
|
+
default_name: Default name from directory (used if not in frontmatter)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Skill or None if parsing fails
|
|
245
|
+
"""
|
|
246
|
+
content = file_path.read_text()
|
|
247
|
+
|
|
248
|
+
# Extract frontmatter
|
|
249
|
+
frontmatter = {}
|
|
250
|
+
body = content
|
|
251
|
+
|
|
252
|
+
if content.startswith("---"):
|
|
253
|
+
parts = content.split("---", 2)
|
|
254
|
+
if len(parts) >= 3:
|
|
255
|
+
frontmatter = _parse_frontmatter(parts[1])
|
|
256
|
+
body = parts[2].strip()
|
|
257
|
+
|
|
258
|
+
# Get name from frontmatter or use directory name
|
|
259
|
+
name = frontmatter.get("name", default_name)
|
|
260
|
+
|
|
261
|
+
# Validate name format (lowercase, hyphens, max 64 chars)
|
|
262
|
+
if len(name) > 64:
|
|
263
|
+
log.warning(f"Skill name '{name}' exceeds 64 characters, truncating")
|
|
264
|
+
name = name[:64]
|
|
265
|
+
|
|
266
|
+
return Skill(
|
|
267
|
+
name=name,
|
|
268
|
+
description=frontmatter.get("description", ""),
|
|
269
|
+
instructions=body,
|
|
270
|
+
tools=frontmatter.get("tools", []),
|
|
271
|
+
user_invocable=frontmatter.get("user_invocable", False),
|
|
272
|
+
file_path=file_path,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_frontmatter(frontmatter_str: str) -> dict:
|
|
277
|
+
"""Parse YAML-like frontmatter.
|
|
278
|
+
|
|
279
|
+
Simple parser for key: value pairs.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
frontmatter_str: Frontmatter string
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Dict of parsed values
|
|
286
|
+
"""
|
|
287
|
+
result = {}
|
|
288
|
+
|
|
289
|
+
for line in frontmatter_str.strip().split("\n"):
|
|
290
|
+
if ":" not in line:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
key, value = line.split(":", 1)
|
|
294
|
+
key = key.strip()
|
|
295
|
+
value = value.strip()
|
|
296
|
+
|
|
297
|
+
# Parse boolean values
|
|
298
|
+
if value.lower() == "true":
|
|
299
|
+
result[key] = True
|
|
300
|
+
elif value.lower() == "false":
|
|
301
|
+
result[key] = False
|
|
302
|
+
# Parse list values
|
|
303
|
+
elif value.startswith("[") and value.endswith("]"):
|
|
304
|
+
items = value[1:-1].split(",")
|
|
305
|
+
result[key] = [item.strip().strip("'\"") for item in items if item.strip()]
|
|
306
|
+
else:
|
|
307
|
+
result[key] = value.strip("'\"")
|
|
308
|
+
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Convenience functions
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def load_skills(skills_dir: Optional[Path] = None) -> dict[str, Skill]:
|
|
316
|
+
"""Load skills from directory.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
skills_dir: Optional skills directory
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Dict mapping skill name to Skill
|
|
323
|
+
"""
|
|
324
|
+
registry = SkillRegistry.get_instance()
|
|
325
|
+
return registry.load_skills(skills_dir)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_skill(name: str) -> Optional[Skill]:
|
|
329
|
+
"""Get a specific skill by name.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
name: Skill name
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Skill or None if not found
|
|
336
|
+
"""
|
|
337
|
+
registry = SkillRegistry.get_instance()
|
|
338
|
+
return registry.get_skill(name)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def list_skills() -> list[str]:
|
|
342
|
+
"""List available skill names.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
List of skill names
|
|
346
|
+
"""
|
|
347
|
+
registry = SkillRegistry.get_instance()
|
|
348
|
+
return registry.list_skills()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_user_invocable_skills() -> list[Skill]:
|
|
352
|
+
"""Get skills that can be invoked with /name.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of user-invocable skills
|
|
356
|
+
"""
|
|
357
|
+
registry = SkillRegistry.get_instance()
|
|
358
|
+
return registry.get_user_invocable_skills()
|
emdash_core/agent/toolkit.py
CHANGED
|
@@ -41,6 +41,7 @@ class AgentToolkit:
|
|
|
41
41
|
repo_root: Optional[Path] = None,
|
|
42
42
|
plan_mode: bool = False,
|
|
43
43
|
save_spec_path: Optional[Path] = None,
|
|
44
|
+
plan_file_path: Optional[str] = None,
|
|
44
45
|
):
|
|
45
46
|
"""Initialize the agent toolkit.
|
|
46
47
|
|
|
@@ -53,6 +54,7 @@ class AgentToolkit:
|
|
|
53
54
|
If None, uses repo_root from config or current working directory.
|
|
54
55
|
plan_mode: Whether to include spec planning tools and restrict to read-only.
|
|
55
56
|
save_spec_path: If provided, specs will be saved to this path.
|
|
57
|
+
plan_file_path: Path to the plan file (only writable file in plan mode).
|
|
56
58
|
"""
|
|
57
59
|
self.connection = connection or get_connection()
|
|
58
60
|
self.session = AgentSession() if enable_session else None
|
|
@@ -61,6 +63,7 @@ class AgentToolkit:
|
|
|
61
63
|
self._mcp_config_path = mcp_config_path
|
|
62
64
|
self.plan_mode = plan_mode
|
|
63
65
|
self.save_spec_path = save_spec_path
|
|
66
|
+
self.plan_file_path = plan_file_path
|
|
64
67
|
|
|
65
68
|
# Get repo_root from config if not explicitly provided
|
|
66
69
|
if repo_root is None:
|
|
@@ -70,8 +73,12 @@ class AgentToolkit:
|
|
|
70
73
|
repo_root = Path(config.repo_root)
|
|
71
74
|
self._repo_root = repo_root or Path.cwd()
|
|
72
75
|
|
|
73
|
-
# Configure spec state if plan mode
|
|
76
|
+
# Configure mode state and spec state if plan mode
|
|
74
77
|
if plan_mode:
|
|
78
|
+
from .tools.modes import ModeState, AgentMode
|
|
79
|
+
mode_state = ModeState.get_instance()
|
|
80
|
+
mode_state.current_mode = AgentMode.PLAN
|
|
81
|
+
|
|
75
82
|
from .tools.spec import SpecState
|
|
76
83
|
spec_state = SpecState.get_instance()
|
|
77
84
|
spec_state.configure(save_path=save_spec_path)
|
|
@@ -103,12 +110,25 @@ class AgentToolkit:
|
|
|
103
110
|
self.register_tool(GlobTool(self.connection))
|
|
104
111
|
self.register_tool(WebTool(self.connection))
|
|
105
112
|
|
|
113
|
+
# Register skill tools and load skills
|
|
114
|
+
self._register_skill_tools()
|
|
115
|
+
|
|
106
116
|
# Register read-only file tools (always available)
|
|
107
117
|
self.register_tool(ReadFileTool(self._repo_root, self.connection))
|
|
108
118
|
self.register_tool(ListFilesTool(self._repo_root, self.connection))
|
|
109
119
|
|
|
110
|
-
# Register write tools
|
|
111
|
-
if
|
|
120
|
+
# Register write tools
|
|
121
|
+
if self.plan_mode:
|
|
122
|
+
# In plan mode: only allow writing to the plan file
|
|
123
|
+
if self.plan_file_path:
|
|
124
|
+
from .tools.coding import WriteToFileTool
|
|
125
|
+
self.register_tool(WriteToFileTool(
|
|
126
|
+
self._repo_root,
|
|
127
|
+
self.connection,
|
|
128
|
+
allowed_paths=[self.plan_file_path],
|
|
129
|
+
))
|
|
130
|
+
else:
|
|
131
|
+
# In code mode: full write access
|
|
112
132
|
from .tools.coding import (
|
|
113
133
|
WriteToFileTool,
|
|
114
134
|
ApplyDiffTool,
|
|
@@ -123,8 +143,15 @@ class AgentToolkit:
|
|
|
123
143
|
# Register sub-agent tools for spawning lightweight agents
|
|
124
144
|
self._register_subagent_tools()
|
|
125
145
|
|
|
126
|
-
# Register
|
|
127
|
-
|
|
146
|
+
# Register mode tools
|
|
147
|
+
self._register_mode_tools()
|
|
148
|
+
|
|
149
|
+
# Register task management tools
|
|
150
|
+
# In plan mode: only register ask_followup_question for clarifications
|
|
151
|
+
# In code mode: register all task tools
|
|
152
|
+
if self.plan_mode:
|
|
153
|
+
self._register_plan_mode_task_tools()
|
|
154
|
+
else:
|
|
128
155
|
self._register_task_tools()
|
|
129
156
|
|
|
130
157
|
# Register spec planning tools (only in plan mode)
|
|
@@ -153,6 +180,36 @@ class AgentToolkit:
|
|
|
153
180
|
self.register_tool(TaskTool(repo_root=self._repo_root, connection=self.connection))
|
|
154
181
|
self.register_tool(TaskOutputTool(repo_root=self._repo_root, connection=self.connection))
|
|
155
182
|
|
|
183
|
+
def _register_mode_tools(self) -> None:
|
|
184
|
+
"""Register mode switching tools.
|
|
185
|
+
|
|
186
|
+
- enter_plan_mode: Available in code mode to request entering plan mode
|
|
187
|
+
- exit_plan: Available in both modes to submit plan for approval
|
|
188
|
+
- get_mode: Always available to check current mode
|
|
189
|
+
"""
|
|
190
|
+
from .tools.modes import EnterPlanModeTool, ExitPlanModeTool, GetModeTool
|
|
191
|
+
|
|
192
|
+
# get_mode is always available
|
|
193
|
+
self.register_tool(GetModeTool())
|
|
194
|
+
|
|
195
|
+
# exit_plan is available in both modes:
|
|
196
|
+
# - In plan mode: submit plan written to plan file
|
|
197
|
+
# - In code mode: submit plan received from Plan subagent
|
|
198
|
+
self.register_tool(ExitPlanModeTool())
|
|
199
|
+
|
|
200
|
+
if not self.plan_mode:
|
|
201
|
+
# In code mode: can also request to enter plan mode
|
|
202
|
+
self.register_tool(EnterPlanModeTool())
|
|
203
|
+
|
|
204
|
+
def _register_plan_mode_task_tools(self) -> None:
|
|
205
|
+
"""Register subset of task tools for plan mode.
|
|
206
|
+
|
|
207
|
+
In plan mode, the agent can ask clarifying questions but
|
|
208
|
+
doesn't need completion/todo tools since exit_plan handles that.
|
|
209
|
+
"""
|
|
210
|
+
from .tools.tasks import AskFollowupQuestionTool
|
|
211
|
+
self.register_tool(AskFollowupQuestionTool())
|
|
212
|
+
|
|
156
213
|
def _register_task_tools(self) -> None:
|
|
157
214
|
"""Register task management tools.
|
|
158
215
|
|
|
@@ -187,6 +244,29 @@ class AgentToolkit:
|
|
|
187
244
|
self.register_tool(GetSpecTool())
|
|
188
245
|
self.register_tool(UpdateSpecTool())
|
|
189
246
|
|
|
247
|
+
def _register_skill_tools(self) -> None:
|
|
248
|
+
"""Register skill tools and load skills from .emdash/skills/.
|
|
249
|
+
|
|
250
|
+
Skills are markdown-based instruction files that teach the agent
|
|
251
|
+
how to perform specific, repeatable tasks. Similar to Claude Code's
|
|
252
|
+
skills system.
|
|
253
|
+
"""
|
|
254
|
+
from .tools.skill import SkillTool, ListSkillsTool
|
|
255
|
+
from .skills import SkillRegistry
|
|
256
|
+
|
|
257
|
+
# Load skills from .emdash/skills/
|
|
258
|
+
skills_dir = self._repo_root / ".emdash" / "skills"
|
|
259
|
+
registry = SkillRegistry.get_instance()
|
|
260
|
+
registry.load_skills(skills_dir)
|
|
261
|
+
|
|
262
|
+
# Register skill tools
|
|
263
|
+
self.register_tool(SkillTool(self.connection))
|
|
264
|
+
self.register_tool(ListSkillsTool(self.connection))
|
|
265
|
+
|
|
266
|
+
skills_count = len(registry.list_skills())
|
|
267
|
+
if skills_count > 0:
|
|
268
|
+
log.info(f"Registered skill tools with {skills_count} skills available")
|
|
269
|
+
|
|
190
270
|
def _register_mcp_tools(self) -> None:
|
|
191
271
|
"""Register GitHub MCP tools if available.
|
|
192
272
|
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
"""Plan toolkit - exploration tools
|
|
1
|
+
"""Plan toolkit - read-only exploration tools for planning.
|
|
2
|
+
|
|
3
|
+
The Plan subagent explores the codebase and returns a plan as text.
|
|
4
|
+
The main agent (in plan mode) writes the plan to .emdash/<feature>.md.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from pathlib import Path
|
|
4
8
|
|
|
5
9
|
from .base import BaseToolkit
|
|
6
10
|
from ..tools.coding import ReadFileTool, ListFilesTool
|
|
7
11
|
from ..tools.search import SemanticSearchTool, GrepTool, GlobTool
|
|
8
|
-
from ..tools.plan_write import WritePlanTool
|
|
9
12
|
from ...utils.logger import log
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class PlanToolkit(BaseToolkit):
|
|
13
|
-
"""
|
|
16
|
+
"""Read-only toolkit for Plan subagent.
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
The Plan subagent explores the codebase and returns a structured plan.
|
|
19
|
+
It does NOT write files - the main agent handles that.
|
|
17
20
|
|
|
18
21
|
Tools available:
|
|
19
22
|
- read_file: Read file contents
|
|
@@ -21,7 +24,6 @@ class PlanToolkit(BaseToolkit):
|
|
|
21
24
|
- glob: Find files by pattern
|
|
22
25
|
- grep: Search file contents
|
|
23
26
|
- semantic_search: AI-powered code search
|
|
24
|
-
- write_plan: Write implementation plans (restricted to .emdash/plans/)
|
|
25
27
|
"""
|
|
26
28
|
|
|
27
29
|
TOOLS = [
|
|
@@ -30,11 +32,10 @@ class PlanToolkit(BaseToolkit):
|
|
|
30
32
|
"glob",
|
|
31
33
|
"grep",
|
|
32
34
|
"semantic_search",
|
|
33
|
-
"write_plan",
|
|
34
35
|
]
|
|
35
36
|
|
|
36
37
|
def _register_tools(self) -> None:
|
|
37
|
-
"""Register exploration
|
|
38
|
+
"""Register read-only exploration tools."""
|
|
38
39
|
# All read-only exploration tools
|
|
39
40
|
self.register_tool(ReadFileTool(repo_root=self.repo_root))
|
|
40
41
|
self.register_tool(ListFilesTool(repo_root=self.repo_root))
|
|
@@ -49,7 +50,4 @@ class PlanToolkit(BaseToolkit):
|
|
|
49
50
|
except Exception as e:
|
|
50
51
|
log.debug(f"Semantic search not available: {e}")
|
|
51
52
|
|
|
52
|
-
# Special: can only write to .emdash/plans/*.md
|
|
53
|
-
self.register_tool(WritePlanTool(repo_root=self.repo_root))
|
|
54
|
-
|
|
55
53
|
log.debug(f"PlanToolkit registered {len(self._tools)} tools")
|
|
@@ -43,7 +43,7 @@ from .tasks import (
|
|
|
43
43
|
from .plan import PlanExplorationTool
|
|
44
44
|
|
|
45
45
|
# Mode tools
|
|
46
|
-
from .modes import AgentMode, ModeState,
|
|
46
|
+
from .modes import AgentMode, ModeState, EnterPlanModeTool, ExitPlanModeTool, GetModeTool
|
|
47
47
|
|
|
48
48
|
# Spec tools
|
|
49
49
|
from .spec import SubmitSpecTool, GetSpecTool, UpdateSpecTool
|
|
@@ -111,7 +111,8 @@ __all__ = [
|
|
|
111
111
|
# Mode
|
|
112
112
|
"AgentMode",
|
|
113
113
|
"ModeState",
|
|
114
|
-
"
|
|
114
|
+
"EnterPlanModeTool",
|
|
115
|
+
"ExitPlanModeTool",
|
|
115
116
|
"GetModeTool",
|
|
116
117
|
# Spec
|
|
117
118
|
"SubmitSpecTool",
|
|
@@ -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
|
-
|
|
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,
|