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.
- emdash_core/agent/agents.py +84 -23
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +44 -9
- emdash_core/agent/prompts/main_agent.py +35 -0
- emdash_core/agent/prompts/subagents.py +24 -8
- emdash_core/agent/prompts/workflow.py +37 -23
- emdash_core/agent/runner/agent_runner.py +12 -0
- emdash_core/agent/runner/context.py +28 -9
- 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 +18 -0
- emdash_core/agent/tools/task.py +11 -4
- emdash_core/api/agent.py +154 -3
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +4 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.37.dist-info}/METADATA +3 -1
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.37.dist-info}/RECORD +20 -19
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.37.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.37.dist-info}/entry_points.txt +0 -0
emdash_core/agent/agents.py
CHANGED
|
@@ -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
|
|
209
|
+
"""Parse YAML frontmatter.
|
|
136
210
|
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
324
|
+
tool_output,
|
|
290
325
|
)
|
|
291
326
|
if tool_result_msg:
|
|
292
327
|
messages.append(tool_result_msg)
|