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.
- emdash_core/agent/__init__.py +4 -0
- emdash_core/agent/agents.py +84 -23
- emdash_core/agent/events.py +42 -20
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +166 -18
- emdash_core/agent/prompts/__init__.py +4 -3
- emdash_core/agent/prompts/main_agent.py +67 -2
- emdash_core/agent/prompts/plan_mode.py +236 -107
- emdash_core/agent/prompts/subagents.py +103 -23
- emdash_core/agent/prompts/workflow.py +159 -26
- emdash_core/agent/providers/factory.py +2 -2
- emdash_core/agent/providers/openai_provider.py +67 -15
- emdash_core/agent/runner/__init__.py +49 -0
- emdash_core/agent/runner/agent_runner.py +765 -0
- emdash_core/agent/runner/context.py +470 -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 +47 -8
- emdash_core/agent/toolkit.py +46 -14
- emdash_core/agent/toolkits/__init__.py +117 -18
- emdash_core/agent/toolkits/base.py +87 -2
- emdash_core/agent/toolkits/explore.py +18 -0
- emdash_core/agent/toolkits/plan.py +27 -11
- emdash_core/agent/tools/__init__.py +2 -2
- emdash_core/agent/tools/coding.py +48 -4
- emdash_core/agent/tools/modes.py +151 -143
- emdash_core/agent/tools/task.py +52 -6
- emdash_core/api/agent.py +706 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +4 -0
- emdash_core/skills/frontend-design/SKILL.md +56 -0
- emdash_core/sse/stream.py +4 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/METADATA +4 -1
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/RECORD +38 -30
- emdash_core/agent/runner.py +0 -1123
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Hook system for running commands on agent events.
|
|
2
|
+
|
|
3
|
+
Hooks allow users to run shell commands when specific events occur
|
|
4
|
+
during agent execution. Hooks are configured per-project in
|
|
5
|
+
.emdash/hooks.json and run asynchronously (non-blocking).
|
|
6
|
+
|
|
7
|
+
Example .emdash/hooks.json:
|
|
8
|
+
{
|
|
9
|
+
"hooks": [
|
|
10
|
+
{
|
|
11
|
+
"id": "notify-done",
|
|
12
|
+
"event": "session_end",
|
|
13
|
+
"command": "notify-send 'Agent finished'",
|
|
14
|
+
"enabled": true
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field, asdict
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import threading
|
|
28
|
+
|
|
29
|
+
from .events import AgentEvent, EventHandler, EventType
|
|
30
|
+
from ..utils.logger import log
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HookEventType(str, Enum):
|
|
34
|
+
"""Event types that can trigger hooks.
|
|
35
|
+
|
|
36
|
+
This is a subset of EventType exposed for hook configuration.
|
|
37
|
+
"""
|
|
38
|
+
TOOL_START = "tool_start"
|
|
39
|
+
TOOL_RESULT = "tool_result"
|
|
40
|
+
SESSION_START = "session_start"
|
|
41
|
+
SESSION_END = "session_end"
|
|
42
|
+
RESPONSE = "response"
|
|
43
|
+
ERROR = "error"
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_event_type(cls, event_type: EventType) -> "HookEventType | None":
|
|
47
|
+
"""Convert an EventType to HookEventType if mappable."""
|
|
48
|
+
mapping = {
|
|
49
|
+
EventType.TOOL_START: cls.TOOL_START,
|
|
50
|
+
EventType.TOOL_RESULT: cls.TOOL_RESULT,
|
|
51
|
+
EventType.SESSION_START: cls.SESSION_START,
|
|
52
|
+
EventType.SESSION_END: cls.SESSION_END,
|
|
53
|
+
EventType.RESPONSE: cls.RESPONSE,
|
|
54
|
+
EventType.ERROR: cls.ERROR,
|
|
55
|
+
}
|
|
56
|
+
return mapping.get(event_type)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class HookEventData:
|
|
61
|
+
"""Data passed to hook commands via stdin as JSON.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
event: The event type that triggered the hook
|
|
65
|
+
timestamp: ISO format timestamp of when the event occurred
|
|
66
|
+
session_id: The session ID (if available)
|
|
67
|
+
|
|
68
|
+
# Tool-specific fields (for tool_start, tool_result)
|
|
69
|
+
tool_name: Name of the tool being executed
|
|
70
|
+
tool_args: Arguments passed to the tool (tool_start only)
|
|
71
|
+
tool_result: Result summary from the tool (tool_result only)
|
|
72
|
+
tool_success: Whether the tool succeeded (tool_result only)
|
|
73
|
+
tool_error: Error message if tool failed (tool_result only)
|
|
74
|
+
|
|
75
|
+
# Response fields (for response event)
|
|
76
|
+
response_text: The response content
|
|
77
|
+
|
|
78
|
+
# Session fields
|
|
79
|
+
goal: The goal/query for the session (session_start only)
|
|
80
|
+
success: Whether the session completed successfully (session_end only)
|
|
81
|
+
|
|
82
|
+
# Error fields
|
|
83
|
+
error_message: Error message (error event only)
|
|
84
|
+
error_details: Additional error details (error event only)
|
|
85
|
+
"""
|
|
86
|
+
event: str
|
|
87
|
+
timestamp: str
|
|
88
|
+
session_id: str | None = None
|
|
89
|
+
|
|
90
|
+
# Tool fields
|
|
91
|
+
tool_name: str | None = None
|
|
92
|
+
tool_args: dict[str, Any] | None = None
|
|
93
|
+
tool_result: str | None = None
|
|
94
|
+
tool_success: bool | None = None
|
|
95
|
+
tool_error: str | None = None
|
|
96
|
+
|
|
97
|
+
# Response fields
|
|
98
|
+
response_text: str | None = None
|
|
99
|
+
|
|
100
|
+
# Session fields
|
|
101
|
+
goal: str | None = None
|
|
102
|
+
success: bool | None = None
|
|
103
|
+
|
|
104
|
+
# Error fields
|
|
105
|
+
error_message: str | None = None
|
|
106
|
+
error_details: str | None = None
|
|
107
|
+
|
|
108
|
+
def to_json(self) -> str:
|
|
109
|
+
"""Convert to JSON string, excluding None values."""
|
|
110
|
+
data = {k: v for k, v in asdict(self).items() if v is not None}
|
|
111
|
+
return json.dumps(data)
|
|
112
|
+
|
|
113
|
+
def to_env_vars(self) -> dict[str, str]:
|
|
114
|
+
"""Convert to environment variables for quick access.
|
|
115
|
+
|
|
116
|
+
Returns a dict of EMDASH_* prefixed env vars.
|
|
117
|
+
"""
|
|
118
|
+
env = {
|
|
119
|
+
"EMDASH_EVENT": self.event,
|
|
120
|
+
"EMDASH_TIMESTAMP": self.timestamp,
|
|
121
|
+
}
|
|
122
|
+
if self.session_id:
|
|
123
|
+
env["EMDASH_SESSION_ID"] = self.session_id
|
|
124
|
+
if self.tool_name:
|
|
125
|
+
env["EMDASH_TOOL_NAME"] = self.tool_name
|
|
126
|
+
if self.tool_success is not None:
|
|
127
|
+
env["EMDASH_TOOL_SUCCESS"] = str(self.tool_success).lower()
|
|
128
|
+
if self.goal:
|
|
129
|
+
env["EMDASH_GOAL"] = self.goal
|
|
130
|
+
if self.success is not None:
|
|
131
|
+
env["EMDASH_SUCCESS"] = str(self.success).lower()
|
|
132
|
+
if self.error_message:
|
|
133
|
+
env["EMDASH_ERROR"] = self.error_message
|
|
134
|
+
return env
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class HookConfig:
|
|
139
|
+
"""Configuration for a single hook.
|
|
140
|
+
|
|
141
|
+
Attributes:
|
|
142
|
+
id: Unique identifier for the hook
|
|
143
|
+
event: Event type that triggers this hook
|
|
144
|
+
command: Shell command to execute
|
|
145
|
+
enabled: Whether the hook is active
|
|
146
|
+
"""
|
|
147
|
+
id: str
|
|
148
|
+
event: HookEventType
|
|
149
|
+
command: str
|
|
150
|
+
enabled: bool = True
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> dict[str, Any]:
|
|
153
|
+
"""Convert to dictionary for JSON serialization."""
|
|
154
|
+
return {
|
|
155
|
+
"id": self.id,
|
|
156
|
+
"event": self.event.value,
|
|
157
|
+
"command": self.command,
|
|
158
|
+
"enabled": self.enabled,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_dict(cls, data: dict[str, Any]) -> "HookConfig":
|
|
163
|
+
"""Create from dictionary."""
|
|
164
|
+
return cls(
|
|
165
|
+
id=data["id"],
|
|
166
|
+
event=HookEventType(data["event"]),
|
|
167
|
+
command=data["command"],
|
|
168
|
+
enabled=data.get("enabled", True),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class HooksFile:
|
|
174
|
+
"""The .emdash/hooks.json file structure.
|
|
175
|
+
|
|
176
|
+
Attributes:
|
|
177
|
+
hooks: List of hook configurations
|
|
178
|
+
"""
|
|
179
|
+
hooks: list[HookConfig] = field(default_factory=list)
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, Any]:
|
|
182
|
+
"""Convert to dictionary for JSON serialization."""
|
|
183
|
+
return {
|
|
184
|
+
"hooks": [h.to_dict() for h in self.hooks],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_dict(cls, data: dict[str, Any]) -> "HooksFile":
|
|
189
|
+
"""Create from dictionary."""
|
|
190
|
+
hooks = [HookConfig.from_dict(h) for h in data.get("hooks", [])]
|
|
191
|
+
return cls(hooks=hooks)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class HookManager:
|
|
195
|
+
"""Manages hook loading, execution, and configuration.
|
|
196
|
+
|
|
197
|
+
Hooks are loaded from .emdash/hooks.json and executed asynchronously
|
|
198
|
+
when matching events occur.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, repo_root: Path | None = None):
|
|
202
|
+
"""Initialize the hook manager.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
repo_root: Root directory of the repository.
|
|
206
|
+
Defaults to current working directory.
|
|
207
|
+
"""
|
|
208
|
+
self._repo_root = repo_root or Path.cwd()
|
|
209
|
+
self._hooks_file = self._repo_root / ".emdash" / "hooks.json"
|
|
210
|
+
self._hooks: list[HookConfig] = []
|
|
211
|
+
self._session_id: str | None = None
|
|
212
|
+
self._load_hooks()
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def hooks_file_path(self) -> Path:
|
|
216
|
+
"""Get the path to the hooks file."""
|
|
217
|
+
return self._hooks_file
|
|
218
|
+
|
|
219
|
+
def set_session_id(self, session_id: str | None) -> None:
|
|
220
|
+
"""Set the current session ID for event data."""
|
|
221
|
+
self._session_id = session_id
|
|
222
|
+
|
|
223
|
+
def _load_hooks(self) -> None:
|
|
224
|
+
"""Load hooks from .emdash/hooks.json."""
|
|
225
|
+
if not self._hooks_file.exists():
|
|
226
|
+
self._hooks = []
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
data = json.loads(self._hooks_file.read_text())
|
|
231
|
+
hooks_file = HooksFile.from_dict(data)
|
|
232
|
+
self._hooks = hooks_file.hooks
|
|
233
|
+
log.debug(f"Loaded {len(self._hooks)} hooks from {self._hooks_file}")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
log.warning(f"Failed to load hooks: {e}")
|
|
236
|
+
self._hooks = []
|
|
237
|
+
|
|
238
|
+
def reload(self) -> None:
|
|
239
|
+
"""Reload hooks from disk."""
|
|
240
|
+
self._load_hooks()
|
|
241
|
+
|
|
242
|
+
def get_hooks(self) -> list[HookConfig]:
|
|
243
|
+
"""Get all configured hooks."""
|
|
244
|
+
return self._hooks.copy()
|
|
245
|
+
|
|
246
|
+
def get_enabled_hooks(self, event: HookEventType) -> list[HookConfig]:
|
|
247
|
+
"""Get enabled hooks for a specific event type."""
|
|
248
|
+
return [h for h in self._hooks if h.enabled and h.event == event]
|
|
249
|
+
|
|
250
|
+
def add_hook(self, hook: HookConfig) -> None:
|
|
251
|
+
"""Add a new hook and save to disk."""
|
|
252
|
+
# Check for duplicate ID
|
|
253
|
+
if any(h.id == hook.id for h in self._hooks):
|
|
254
|
+
raise ValueError(f"Hook with id '{hook.id}' already exists")
|
|
255
|
+
|
|
256
|
+
self._hooks.append(hook)
|
|
257
|
+
self._save_hooks()
|
|
258
|
+
|
|
259
|
+
def remove_hook(self, hook_id: str) -> bool:
|
|
260
|
+
"""Remove a hook by ID. Returns True if removed."""
|
|
261
|
+
for i, h in enumerate(self._hooks):
|
|
262
|
+
if h.id == hook_id:
|
|
263
|
+
self._hooks.pop(i)
|
|
264
|
+
self._save_hooks()
|
|
265
|
+
return True
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def toggle_hook(self, hook_id: str) -> bool | None:
|
|
269
|
+
"""Toggle a hook's enabled state. Returns new state or None if not found."""
|
|
270
|
+
for h in self._hooks:
|
|
271
|
+
if h.id == hook_id:
|
|
272
|
+
h.enabled = not h.enabled
|
|
273
|
+
self._save_hooks()
|
|
274
|
+
return h.enabled
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _save_hooks(self) -> None:
|
|
278
|
+
"""Save hooks to .emdash/hooks.json."""
|
|
279
|
+
self._hooks_file.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
hooks_file = HooksFile(hooks=self._hooks)
|
|
281
|
+
self._hooks_file.write_text(
|
|
282
|
+
json.dumps(hooks_file.to_dict(), indent=2) + "\n"
|
|
283
|
+
)
|
|
284
|
+
log.debug(f"Saved {len(self._hooks)} hooks to {self._hooks_file}")
|
|
285
|
+
|
|
286
|
+
def _build_event_data(self, event: AgentEvent, hook_event: HookEventType) -> HookEventData:
|
|
287
|
+
"""Build HookEventData from an AgentEvent."""
|
|
288
|
+
data = HookEventData(
|
|
289
|
+
event=hook_event.value,
|
|
290
|
+
timestamp=event.timestamp.isoformat(),
|
|
291
|
+
session_id=self._session_id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Populate event-specific fields
|
|
295
|
+
if hook_event == HookEventType.TOOL_START:
|
|
296
|
+
data.tool_name = event.data.get("name")
|
|
297
|
+
data.tool_args = event.data.get("args")
|
|
298
|
+
|
|
299
|
+
elif hook_event == HookEventType.TOOL_RESULT:
|
|
300
|
+
data.tool_name = event.data.get("name")
|
|
301
|
+
data.tool_success = event.data.get("success")
|
|
302
|
+
data.tool_result = event.data.get("summary")
|
|
303
|
+
if not data.tool_success:
|
|
304
|
+
data.tool_error = event.data.get("data", {}).get("error")
|
|
305
|
+
|
|
306
|
+
elif hook_event == HookEventType.SESSION_START:
|
|
307
|
+
data.goal = event.data.get("goal")
|
|
308
|
+
|
|
309
|
+
elif hook_event == HookEventType.SESSION_END:
|
|
310
|
+
data.success = event.data.get("success")
|
|
311
|
+
|
|
312
|
+
elif hook_event == HookEventType.RESPONSE:
|
|
313
|
+
data.response_text = event.data.get("content")
|
|
314
|
+
|
|
315
|
+
elif hook_event == HookEventType.ERROR:
|
|
316
|
+
data.error_message = event.data.get("message")
|
|
317
|
+
data.error_details = event.data.get("details")
|
|
318
|
+
|
|
319
|
+
return data
|
|
320
|
+
|
|
321
|
+
def _execute_hook_async(self, hook: HookConfig, event_data: HookEventData) -> None:
|
|
322
|
+
"""Execute a hook command asynchronously (fire and forget)."""
|
|
323
|
+
def run():
|
|
324
|
+
try:
|
|
325
|
+
env = os.environ.copy()
|
|
326
|
+
env.update(event_data.to_env_vars())
|
|
327
|
+
|
|
328
|
+
process = subprocess.Popen(
|
|
329
|
+
hook.command,
|
|
330
|
+
shell=True,
|
|
331
|
+
stdin=subprocess.PIPE,
|
|
332
|
+
stdout=subprocess.PIPE,
|
|
333
|
+
stderr=subprocess.PIPE,
|
|
334
|
+
env=env,
|
|
335
|
+
cwd=str(self._repo_root),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Send JSON data to stdin
|
|
339
|
+
json_data = event_data.to_json()
|
|
340
|
+
assert process.stdin is not None
|
|
341
|
+
process.stdin.write(json_data.encode())
|
|
342
|
+
process.stdin.close()
|
|
343
|
+
|
|
344
|
+
# Don't wait for completion - fire and forget
|
|
345
|
+
# But log if there's an error
|
|
346
|
+
def log_completion():
|
|
347
|
+
_, stderr = process.communicate(timeout=30)
|
|
348
|
+
if process.returncode != 0:
|
|
349
|
+
log.warning(
|
|
350
|
+
f"Hook '{hook.id}' exited with code {process.returncode}: "
|
|
351
|
+
f"{stderr.decode()[:200]}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Run completion logging in another thread to not block
|
|
355
|
+
completion_thread = threading.Thread(target=log_completion, daemon=True)
|
|
356
|
+
completion_thread.start()
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
log.warning(f"Failed to execute hook '{hook.id}': {e}")
|
|
360
|
+
|
|
361
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
362
|
+
thread.start()
|
|
363
|
+
|
|
364
|
+
def trigger(self, event: AgentEvent) -> None:
|
|
365
|
+
"""Trigger hooks for an event.
|
|
366
|
+
|
|
367
|
+
Called by the event system when events occur.
|
|
368
|
+
"""
|
|
369
|
+
hook_event = HookEventType.from_event_type(event.type)
|
|
370
|
+
if hook_event is None:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
hooks = self.get_enabled_hooks(hook_event)
|
|
374
|
+
if not hooks:
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
event_data = self._build_event_data(event, hook_event)
|
|
378
|
+
|
|
379
|
+
for hook in hooks:
|
|
380
|
+
log.debug(f"Triggering hook '{hook.id}' for event '{hook_event.value}'")
|
|
381
|
+
self._execute_hook_async(hook, event_data)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class HookHandler(EventHandler):
|
|
385
|
+
"""Event handler that triggers hooks.
|
|
386
|
+
|
|
387
|
+
Add this handler to an AgentEventEmitter to enable hooks.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def __init__(self, manager: HookManager):
|
|
391
|
+
"""Initialize with a hook manager.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
manager: The HookManager to use for triggering hooks
|
|
395
|
+
"""
|
|
396
|
+
self._manager = manager
|
|
397
|
+
|
|
398
|
+
def handle(self, event: AgentEvent) -> None:
|
|
399
|
+
"""Handle an event by triggering matching hooks."""
|
|
400
|
+
self._manager.trigger(event)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# Convenience functions
|
|
404
|
+
|
|
405
|
+
_default_manager: HookManager | None = None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def get_hook_manager(repo_root: Path | None = None) -> HookManager:
|
|
409
|
+
"""Get or create the default hook manager."""
|
|
410
|
+
global _default_manager
|
|
411
|
+
if _default_manager is None:
|
|
412
|
+
_default_manager = HookManager(repo_root)
|
|
413
|
+
return _default_manager
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def reset_hook_manager() -> None:
|
|
417
|
+
"""Reset the default hook manager (for testing)."""
|
|
418
|
+
global _default_manager
|
|
419
|
+
_default_manager = None
|