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
@@ -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