emdash-core 0.1.33__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.
@@ -2,16 +2,74 @@
2
2
 
3
3
  Allows users to define custom agent configurations with
4
4
  specialized system prompts and tool selections.
5
+
6
+ Example agent file:
7
+ ```markdown
8
+ ---
9
+ description: GitHub integration agent
10
+ model: claude-sonnet-4-20250514
11
+ tools: [grep, glob, read_file]
12
+ mcp_servers:
13
+ github:
14
+ command: github-mcp-server
15
+ args: []
16
+ env:
17
+ GITHUB_TOKEN: ${GITHUB_TOKEN}
18
+ enabled: true
19
+ filesystem:
20
+ command: npx
21
+ args: [-y, "@anthropic/mcp-server-filesystem", "/tmp"]
22
+ enabled: false # Disabled - won't be started
23
+ ---
24
+
25
+ # System Prompt
26
+
27
+ You are a GitHub integration specialist...
28
+ ```
5
29
  """
6
30
 
7
31
  from dataclasses import dataclass, field
8
32
  from pathlib import Path
9
- from typing import Optional
33
+ from typing import Any, Optional
10
34
  import re
11
35
 
36
+ import yaml
37
+
12
38
  from ..utils.logger import log
13
39
 
14
40
 
41
+ @dataclass
42
+ class AgentMCPServerConfig:
43
+ """MCP server configuration for a custom agent.
44
+
45
+ Attributes:
46
+ name: Server name (key in mcp_servers dict)
47
+ command: Command to run the server
48
+ args: Arguments to pass to the command
49
+ env: Environment variables (supports ${VAR} syntax)
50
+ enabled: Whether this server is enabled (default: True)
51
+ timeout: Timeout in seconds for tool calls
52
+ """
53
+ name: str
54
+ command: str
55
+ args: list[str] = field(default_factory=list)
56
+ env: dict[str, str] = field(default_factory=dict)
57
+ enabled: bool = True
58
+ timeout: int = 30
59
+
60
+ @classmethod
61
+ def from_dict(cls, name: str, data: dict[str, Any]) -> "AgentMCPServerConfig":
62
+ """Create from dictionary parsed from YAML."""
63
+ return cls(
64
+ name=name,
65
+ command=data.get("command", ""),
66
+ args=data.get("args", []),
67
+ env=data.get("env", {}),
68
+ enabled=data.get("enabled", True),
69
+ timeout=data.get("timeout", 30),
70
+ )
71
+
72
+
15
73
  @dataclass
16
74
  class CustomAgent:
17
75
  """A custom agent configuration loaded from markdown.
@@ -19,16 +77,20 @@ class CustomAgent:
19
77
  Attributes:
20
78
  name: Agent name (from filename)
21
79
  description: Brief description
80
+ model: Model to use for this agent (optional, uses default if not set)
22
81
  system_prompt: Custom system prompt
23
82
  tools: List of tools to enable
83
+ mcp_servers: MCP server configurations for this agent
24
84
  examples: Example interactions
25
85
  file_path: Source file path
26
86
  """
27
87
 
28
88
  name: str
29
89
  description: str = ""
90
+ model: Optional[str] = None
30
91
  system_prompt: str = ""
31
92
  tools: list[str] = field(default_factory=list)
93
+ mcp_servers: list[AgentMCPServerConfig] = field(default_factory=list)
32
94
  examples: list[dict] = field(default_factory=list)
33
95
  file_path: Optional[Path] = None
34
96
 
@@ -121,46 +183,45 @@ def _parse_agent_file(file_path: Path) -> Optional[CustomAgent]:
121
183
  if system_prompt.startswith("# System Prompt"):
122
184
  system_prompt = system_prompt[len("# System Prompt") :].strip()
123
185
 
186
+ # Parse MCP servers from frontmatter
187
+ mcp_servers = []
188
+ mcp_servers_data = frontmatter.get("mcp_servers", {})
189
+ if isinstance(mcp_servers_data, dict):
190
+ for server_name, server_config in mcp_servers_data.items():
191
+ if isinstance(server_config, dict):
192
+ mcp_servers.append(
193
+ AgentMCPServerConfig.from_dict(server_name, server_config)
194
+ )
195
+
124
196
  return CustomAgent(
125
197
  name=file_path.stem,
126
198
  description=frontmatter.get("description", ""),
199
+ model=frontmatter.get("model"),
127
200
  system_prompt=system_prompt,
128
201
  tools=frontmatter.get("tools", []),
202
+ mcp_servers=mcp_servers,
129
203
  examples=examples,
130
204
  file_path=file_path,
131
205
  )
132
206
 
133
207
 
134
208
  def _parse_frontmatter(frontmatter_str: str) -> dict:
135
- """Parse YAML-like frontmatter.
209
+ """Parse YAML frontmatter.
136
210
 
137
- Simple parser for key: value pairs.
211
+ Uses PyYAML for proper nested structure parsing.
138
212
 
139
213
  Args:
140
- frontmatter_str: Frontmatter string
214
+ frontmatter_str: Frontmatter string (YAML format)
141
215
 
142
216
  Returns:
143
217
  Dict of parsed values
144
218
  """
145
- result = {}
146
-
147
- for line in frontmatter_str.strip().split("\n"):
148
- if ":" not in line:
149
- continue
150
-
151
- key, value = line.split(":", 1)
152
- key = key.strip()
153
- value = value.strip()
154
-
155
- # Parse list values
156
- if value.startswith("[") and value.endswith("]"):
157
- # Simple list parsing
158
- items = value[1:-1].split(",")
159
- result[key] = [item.strip().strip("'\"") for item in items if item.strip()]
160
- else:
161
- result[key] = value.strip("'\"")
162
-
163
- return result
219
+ try:
220
+ result = yaml.safe_load(frontmatter_str)
221
+ return result if isinstance(result, dict) else {}
222
+ except yaml.YAMLError as e:
223
+ log.warning(f"Failed to parse frontmatter as YAML: {e}")
224
+ return {}
164
225
 
165
226
 
166
227
  def _parse_examples(examples_str: str) -> list[dict]:
@@ -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
@@ -16,6 +16,12 @@ from .toolkits import get_toolkit
16
16
  from .subagent_prompts import get_subagent_prompt
17
17
  from .providers import get_provider
18
18
  from .providers.factory import DEFAULT_MODEL
19
+ from .context_manager import (
20
+ truncate_tool_output,
21
+ reduce_context_for_retry,
22
+ is_context_overflow_error,
23
+ )
24
+ from .runner.context import estimate_context_tokens
19
25
  from ..utils.logger import log
20
26
 
21
27
 
@@ -90,7 +96,7 @@ class InProcessSubAgent:
90
96
  self.provider = get_provider(model_name)
91
97
 
92
98
  # Get system prompt and inject thoroughness level
93
- base_prompt = get_subagent_prompt(subagent_type)
99
+ base_prompt = get_subagent_prompt(subagent_type, repo_root=repo_root)
94
100
  self.system_prompt = self._inject_thoroughness(base_prompt)
95
101
 
96
102
  # Tracking
@@ -234,12 +240,39 @@ Now, your task:
234
240
 
235
241
  log.debug(f"SubAgent {self.agent_id} turn {iterations}/{self.max_turns}")
236
242
 
237
- # Call LLM
238
- response = self.provider.chat(
239
- messages=messages,
240
- tools=self.toolkit.get_all_schemas(),
241
- system=self.system_prompt,
242
- )
243
+ # Check context size and compact if needed
244
+ context_tokens = estimate_context_tokens(messages, self.system_prompt)
245
+ context_limit = self.provider.get_context_limit()
246
+
247
+ if context_tokens > context_limit * 0.8:
248
+ log.info(
249
+ f"SubAgent {self.agent_id} context at {context_tokens:,}/{context_limit:,} "
250
+ f"({context_tokens/context_limit:.0%}), reducing..."
251
+ )
252
+ messages = reduce_context_for_retry(messages, keep_recent=6)
253
+
254
+ # Call LLM with retry on context overflow
255
+ response = None
256
+ max_retries = 2
257
+ for retry in range(max_retries + 1):
258
+ try:
259
+ response = self.provider.chat(
260
+ messages=messages,
261
+ tools=self.toolkit.get_all_schemas(),
262
+ system=self.system_prompt,
263
+ )
264
+ break # Success
265
+ except Exception as e:
266
+ if is_context_overflow_error(e) and retry < max_retries:
267
+ log.warning(
268
+ f"SubAgent {self.agent_id} context overflow on attempt {retry + 1}, reducing..."
269
+ )
270
+ messages = reduce_context_for_retry(messages, keep_recent=4 - retry)
271
+ else:
272
+ raise # Re-raise if not overflow or out of retries
273
+
274
+ if response is None:
275
+ raise RuntimeError("Failed to get response from LLM")
243
276
 
244
277
  # Add assistant response
245
278
  assistant_msg = self.provider.format_assistant_message(response)
@@ -283,10 +316,12 @@ Now, your task:
283
316
  summary=summary,
284
317
  )
285
318
 
286
- # Add tool result to messages
319
+ # Add tool result to messages (truncated to avoid context overflow)
320
+ tool_output = json.dumps(result.to_dict(), indent=2)
321
+ tool_output = truncate_tool_output(tool_output, max_tokens=15000)
287
322
  tool_result_msg = self.provider.format_tool_result(
288
323
  tool_call.id,
289
- json.dumps(result.to_dict(), indent=2),
324
+ tool_output,
290
325
  )
291
326
  if tool_result_msg:
292
327
  messages.append(tool_result_msg)