emdash-core 0.1.33__py3-none-any.whl → 0.1.60__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 +93 -23
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +114 -10
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +88 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +96 -8
- emdash_core/agent/prompts/workflow.py +215 -50
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +157 -19
- emdash_core/agent/runner/context.py +28 -9
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- 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/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +27 -23
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +451 -5
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +14 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.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,26 @@ 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
|
|
84
|
+
rules: List of rule names to apply (references .emdash/rules/)
|
|
85
|
+
skills: List of skill names to enable (references .emdash/skills/)
|
|
86
|
+
verifiers: List of verifier names to use (references .emdash/verifiers.json)
|
|
24
87
|
examples: Example interactions
|
|
25
88
|
file_path: Source file path
|
|
26
89
|
"""
|
|
27
90
|
|
|
28
91
|
name: str
|
|
29
92
|
description: str = ""
|
|
93
|
+
model: Optional[str] = None
|
|
30
94
|
system_prompt: str = ""
|
|
31
95
|
tools: list[str] = field(default_factory=list)
|
|
96
|
+
mcp_servers: list[AgentMCPServerConfig] = field(default_factory=list)
|
|
97
|
+
rules: list[str] = field(default_factory=list)
|
|
98
|
+
skills: list[str] = field(default_factory=list)
|
|
99
|
+
verifiers: list[str] = field(default_factory=list)
|
|
32
100
|
examples: list[dict] = field(default_factory=list)
|
|
33
101
|
file_path: Optional[Path] = None
|
|
34
102
|
|
|
@@ -121,46 +189,48 @@ def _parse_agent_file(file_path: Path) -> Optional[CustomAgent]:
|
|
|
121
189
|
if system_prompt.startswith("# System Prompt"):
|
|
122
190
|
system_prompt = system_prompt[len("# System Prompt") :].strip()
|
|
123
191
|
|
|
192
|
+
# Parse MCP servers from frontmatter
|
|
193
|
+
mcp_servers = []
|
|
194
|
+
mcp_servers_data = frontmatter.get("mcp_servers", {})
|
|
195
|
+
if isinstance(mcp_servers_data, dict):
|
|
196
|
+
for server_name, server_config in mcp_servers_data.items():
|
|
197
|
+
if isinstance(server_config, dict):
|
|
198
|
+
mcp_servers.append(
|
|
199
|
+
AgentMCPServerConfig.from_dict(server_name, server_config)
|
|
200
|
+
)
|
|
201
|
+
|
|
124
202
|
return CustomAgent(
|
|
125
203
|
name=file_path.stem,
|
|
126
204
|
description=frontmatter.get("description", ""),
|
|
205
|
+
model=frontmatter.get("model"),
|
|
127
206
|
system_prompt=system_prompt,
|
|
128
207
|
tools=frontmatter.get("tools", []),
|
|
208
|
+
mcp_servers=mcp_servers,
|
|
209
|
+
rules=frontmatter.get("rules", []),
|
|
210
|
+
skills=frontmatter.get("skills", []),
|
|
211
|
+
verifiers=frontmatter.get("verifiers", []),
|
|
129
212
|
examples=examples,
|
|
130
213
|
file_path=file_path,
|
|
131
214
|
)
|
|
132
215
|
|
|
133
216
|
|
|
134
217
|
def _parse_frontmatter(frontmatter_str: str) -> dict:
|
|
135
|
-
"""Parse YAML
|
|
218
|
+
"""Parse YAML frontmatter.
|
|
136
219
|
|
|
137
|
-
|
|
220
|
+
Uses PyYAML for proper nested structure parsing.
|
|
138
221
|
|
|
139
222
|
Args:
|
|
140
|
-
frontmatter_str: Frontmatter string
|
|
223
|
+
frontmatter_str: Frontmatter string (YAML format)
|
|
141
224
|
|
|
142
225
|
Returns:
|
|
143
226
|
Dict of parsed values
|
|
144
227
|
"""
|
|
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
|
|
228
|
+
try:
|
|
229
|
+
result = yaml.safe_load(frontmatter_str)
|
|
230
|
+
return result if isinstance(result, dict) else {}
|
|
231
|
+
except yaml.YAMLError as e:
|
|
232
|
+
log.warning(f"Failed to parse frontmatter as YAML: {e}")
|
|
233
|
+
return {}
|
|
164
234
|
|
|
165
235
|
|
|
166
236
|
def _parse_examples(examples_str: str) -> list[dict]:
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Background task management for shell commands and sub-agents.
|
|
2
|
+
|
|
3
|
+
This module provides a centralized manager for tracking background tasks,
|
|
4
|
+
checking for completions, and generating notifications to inject into
|
|
5
|
+
the agent's context.
|
|
6
|
+
|
|
7
|
+
Inspired by Claude Code's background task system with notification-based
|
|
8
|
+
completion handling.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from ..utils.logger import log
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TaskType(Enum):
|
|
25
|
+
"""Type of background task."""
|
|
26
|
+
SHELL = "shell"
|
|
27
|
+
SUBAGENT = "subagent"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskStatus(Enum):
|
|
31
|
+
"""Status of a background task."""
|
|
32
|
+
RUNNING = "running"
|
|
33
|
+
COMPLETED = "completed"
|
|
34
|
+
FAILED = "failed"
|
|
35
|
+
KILLED = "killed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BackgroundTask:
|
|
40
|
+
"""Represents a background task (shell command or sub-agent)."""
|
|
41
|
+
|
|
42
|
+
task_id: str
|
|
43
|
+
task_type: TaskType
|
|
44
|
+
description: str
|
|
45
|
+
started_at: float = field(default_factory=time.time)
|
|
46
|
+
|
|
47
|
+
# Shell-specific
|
|
48
|
+
process: Optional[subprocess.Popen] = None
|
|
49
|
+
command: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
# Sub-agent specific
|
|
52
|
+
future: Optional[Future] = None
|
|
53
|
+
agent_type: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
# Output capture
|
|
56
|
+
output_file: Optional[Path] = None
|
|
57
|
+
stdout: str = ""
|
|
58
|
+
stderr: str = ""
|
|
59
|
+
|
|
60
|
+
# Status
|
|
61
|
+
status: TaskStatus = TaskStatus.RUNNING
|
|
62
|
+
exit_code: Optional[int] = None
|
|
63
|
+
result: Optional[Any] = None
|
|
64
|
+
error: Optional[str] = None
|
|
65
|
+
completed_at: Optional[float] = None
|
|
66
|
+
|
|
67
|
+
# Whether the agent has been notified of completion
|
|
68
|
+
notified: bool = False
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict:
|
|
71
|
+
"""Convert to dictionary for serialization."""
|
|
72
|
+
return {
|
|
73
|
+
"task_id": self.task_id,
|
|
74
|
+
"task_type": self.task_type.value,
|
|
75
|
+
"description": self.description,
|
|
76
|
+
"status": self.status.value,
|
|
77
|
+
"exit_code": self.exit_code,
|
|
78
|
+
"started_at": self.started_at,
|
|
79
|
+
"completed_at": self.completed_at,
|
|
80
|
+
"command": self.command,
|
|
81
|
+
"agent_type": self.agent_type,
|
|
82
|
+
"stdout": self.stdout[-5000:] if self.stdout else "",
|
|
83
|
+
"stderr": self.stderr[-2000:] if self.stderr else "",
|
|
84
|
+
"error": self.error,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BackgroundTaskManager:
|
|
89
|
+
"""Manages background tasks with notification-based completion.
|
|
90
|
+
|
|
91
|
+
This is a singleton that tracks all background tasks (shell commands
|
|
92
|
+
and sub-agents), monitors their completion, and provides notifications
|
|
93
|
+
to inject into the agent's context.
|
|
94
|
+
|
|
95
|
+
Usage:
|
|
96
|
+
manager = BackgroundTaskManager.get_instance()
|
|
97
|
+
|
|
98
|
+
# Start a shell command
|
|
99
|
+
task_id = manager.start_shell("npm test", description="Run tests")
|
|
100
|
+
|
|
101
|
+
# Check for completed tasks to notify agent
|
|
102
|
+
notifications = manager.get_pending_notifications()
|
|
103
|
+
|
|
104
|
+
# Get task status
|
|
105
|
+
task = manager.get_task(task_id)
|
|
106
|
+
|
|
107
|
+
# Kill a task
|
|
108
|
+
manager.kill_task(task_id)
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
_instance: Optional["BackgroundTaskManager"] = None
|
|
112
|
+
_lock = threading.Lock()
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
"""Initialize the manager."""
|
|
116
|
+
self._tasks: dict[str, BackgroundTask] = {}
|
|
117
|
+
self._executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="bg-task-")
|
|
118
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
|
119
|
+
self._stop_monitor = threading.Event()
|
|
120
|
+
self._start_monitor()
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def get_instance(cls) -> "BackgroundTaskManager":
|
|
124
|
+
"""Get the singleton instance."""
|
|
125
|
+
if cls._instance is None:
|
|
126
|
+
with cls._lock:
|
|
127
|
+
if cls._instance is None:
|
|
128
|
+
cls._instance = cls()
|
|
129
|
+
return cls._instance
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def reset_instance(cls) -> None:
|
|
133
|
+
"""Reset the singleton (for testing)."""
|
|
134
|
+
with cls._lock:
|
|
135
|
+
if cls._instance is not None:
|
|
136
|
+
cls._instance.shutdown()
|
|
137
|
+
cls._instance = None
|
|
138
|
+
|
|
139
|
+
def _start_monitor(self) -> None:
|
|
140
|
+
"""Start the background monitor thread."""
|
|
141
|
+
if self._monitor_thread is not None and self._monitor_thread.is_alive():
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
self._stop_monitor.clear()
|
|
145
|
+
self._monitor_thread = threading.Thread(
|
|
146
|
+
target=self._monitor_loop,
|
|
147
|
+
name="bg-task-monitor",
|
|
148
|
+
daemon=True,
|
|
149
|
+
)
|
|
150
|
+
self._monitor_thread.start()
|
|
151
|
+
|
|
152
|
+
def _monitor_loop(self) -> None:
|
|
153
|
+
"""Monitor running tasks for completion."""
|
|
154
|
+
while not self._stop_monitor.is_set():
|
|
155
|
+
try:
|
|
156
|
+
self._check_tasks()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
log.warning(f"Error in background task monitor: {e}")
|
|
159
|
+
|
|
160
|
+
# Check every 500ms
|
|
161
|
+
self._stop_monitor.wait(0.5)
|
|
162
|
+
|
|
163
|
+
def _check_tasks(self) -> None:
|
|
164
|
+
"""Check all running tasks for completion."""
|
|
165
|
+
for task in list(self._tasks.values()):
|
|
166
|
+
if task.status != TaskStatus.RUNNING:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if task.task_type == TaskType.SHELL:
|
|
170
|
+
self._check_shell_task(task)
|
|
171
|
+
elif task.task_type == TaskType.SUBAGENT:
|
|
172
|
+
self._check_subagent_task(task)
|
|
173
|
+
|
|
174
|
+
def _check_shell_task(self, task: BackgroundTask) -> None:
|
|
175
|
+
"""Check if a shell task has completed."""
|
|
176
|
+
if task.process is None:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
poll = task.process.poll()
|
|
180
|
+
if poll is not None:
|
|
181
|
+
# Process completed
|
|
182
|
+
task.exit_code = poll
|
|
183
|
+
task.completed_at = time.time()
|
|
184
|
+
|
|
185
|
+
# Capture any remaining output
|
|
186
|
+
try:
|
|
187
|
+
stdout, stderr = task.process.communicate(timeout=1)
|
|
188
|
+
task.stdout += stdout
|
|
189
|
+
task.stderr += stderr
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
if poll == 0:
|
|
194
|
+
task.status = TaskStatus.COMPLETED
|
|
195
|
+
else:
|
|
196
|
+
task.status = TaskStatus.FAILED
|
|
197
|
+
task.error = f"Command exited with code {poll}"
|
|
198
|
+
|
|
199
|
+
log.info(f"Shell task {task.task_id} completed with exit code {poll}")
|
|
200
|
+
|
|
201
|
+
def _check_subagent_task(self, task: BackgroundTask) -> None:
|
|
202
|
+
"""Check if a sub-agent task has completed."""
|
|
203
|
+
if task.future is None:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if task.future.done():
|
|
207
|
+
task.completed_at = time.time()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
result = task.future.result(timeout=0)
|
|
211
|
+
task.result = result
|
|
212
|
+
task.status = TaskStatus.COMPLETED
|
|
213
|
+
task.exit_code = 0
|
|
214
|
+
log.info(f"Sub-agent task {task.task_id} completed successfully")
|
|
215
|
+
except Exception as e:
|
|
216
|
+
task.status = TaskStatus.FAILED
|
|
217
|
+
task.error = str(e)
|
|
218
|
+
task.exit_code = 1
|
|
219
|
+
log.warning(f"Sub-agent task {task.task_id} failed: {e}")
|
|
220
|
+
|
|
221
|
+
def start_shell(
|
|
222
|
+
self,
|
|
223
|
+
command: str,
|
|
224
|
+
description: str = "",
|
|
225
|
+
cwd: Optional[Path] = None,
|
|
226
|
+
timeout: Optional[int] = None,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Start a shell command in the background.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
command: Shell command to execute
|
|
232
|
+
description: Human-readable description
|
|
233
|
+
cwd: Working directory (defaults to current)
|
|
234
|
+
timeout: Optional timeout in seconds (not enforced, just metadata)
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Task ID for tracking
|
|
238
|
+
"""
|
|
239
|
+
task_id = f"shell_{uuid.uuid4().hex[:8]}"
|
|
240
|
+
|
|
241
|
+
log.info(f"Starting background shell task {task_id}: {command[:50]}...")
|
|
242
|
+
|
|
243
|
+
# Start process with pipes for output capture
|
|
244
|
+
process = subprocess.Popen(
|
|
245
|
+
command,
|
|
246
|
+
shell=True,
|
|
247
|
+
stdout=subprocess.PIPE,
|
|
248
|
+
stderr=subprocess.PIPE,
|
|
249
|
+
text=True,
|
|
250
|
+
cwd=cwd,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
task = BackgroundTask(
|
|
254
|
+
task_id=task_id,
|
|
255
|
+
task_type=TaskType.SHELL,
|
|
256
|
+
description=description or command[:50],
|
|
257
|
+
process=process,
|
|
258
|
+
command=command,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Start output reader threads
|
|
262
|
+
self._start_output_reader(task)
|
|
263
|
+
|
|
264
|
+
self._tasks[task_id] = task
|
|
265
|
+
return task_id
|
|
266
|
+
|
|
267
|
+
def _start_output_reader(self, task: BackgroundTask) -> None:
|
|
268
|
+
"""Start threads to read stdout/stderr without blocking."""
|
|
269
|
+
def read_stream(stream, attr_name):
|
|
270
|
+
try:
|
|
271
|
+
for line in stream:
|
|
272
|
+
current = getattr(task, attr_name)
|
|
273
|
+
setattr(task, attr_name, current + line)
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
if task.process and task.process.stdout:
|
|
278
|
+
threading.Thread(
|
|
279
|
+
target=read_stream,
|
|
280
|
+
args=(task.process.stdout, "stdout"),
|
|
281
|
+
daemon=True,
|
|
282
|
+
).start()
|
|
283
|
+
|
|
284
|
+
if task.process and task.process.stderr:
|
|
285
|
+
threading.Thread(
|
|
286
|
+
target=read_stream,
|
|
287
|
+
args=(task.process.stderr, "stderr"),
|
|
288
|
+
daemon=True,
|
|
289
|
+
).start()
|
|
290
|
+
|
|
291
|
+
def start_subagent(
|
|
292
|
+
self,
|
|
293
|
+
future: Future,
|
|
294
|
+
agent_type: str,
|
|
295
|
+
description: str = "",
|
|
296
|
+
) -> str:
|
|
297
|
+
"""Register a sub-agent task for tracking.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
future: Future from async sub-agent execution
|
|
301
|
+
agent_type: Type of sub-agent (Explore, Plan, etc.)
|
|
302
|
+
description: Human-readable description
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Task ID for tracking
|
|
306
|
+
"""
|
|
307
|
+
task_id = f"agent_{uuid.uuid4().hex[:8]}"
|
|
308
|
+
|
|
309
|
+
log.info(f"Registering background sub-agent {task_id}: {agent_type}")
|
|
310
|
+
|
|
311
|
+
task = BackgroundTask(
|
|
312
|
+
task_id=task_id,
|
|
313
|
+
task_type=TaskType.SUBAGENT,
|
|
314
|
+
description=description,
|
|
315
|
+
future=future,
|
|
316
|
+
agent_type=agent_type,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
self._tasks[task_id] = task
|
|
320
|
+
return task_id
|
|
321
|
+
|
|
322
|
+
def get_task(self, task_id: str) -> Optional[BackgroundTask]:
|
|
323
|
+
"""Get a task by ID."""
|
|
324
|
+
return self._tasks.get(task_id)
|
|
325
|
+
|
|
326
|
+
def get_all_tasks(self) -> list[BackgroundTask]:
|
|
327
|
+
"""Get all tasks."""
|
|
328
|
+
return list(self._tasks.values())
|
|
329
|
+
|
|
330
|
+
def get_running_tasks(self) -> list[BackgroundTask]:
|
|
331
|
+
"""Get all currently running tasks."""
|
|
332
|
+
return [t for t in self._tasks.values() if t.status == TaskStatus.RUNNING]
|
|
333
|
+
|
|
334
|
+
def get_pending_notifications(self) -> list[BackgroundTask]:
|
|
335
|
+
"""Get completed tasks that haven't been notified yet.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
List of tasks that completed since last check.
|
|
339
|
+
Marks them as notified so they won't be returned again.
|
|
340
|
+
"""
|
|
341
|
+
notifications = []
|
|
342
|
+
|
|
343
|
+
for task in self._tasks.values():
|
|
344
|
+
if task.status != TaskStatus.RUNNING and not task.notified:
|
|
345
|
+
notifications.append(task)
|
|
346
|
+
task.notified = True
|
|
347
|
+
|
|
348
|
+
return notifications
|
|
349
|
+
|
|
350
|
+
def format_notification(self, task: BackgroundTask) -> str:
|
|
351
|
+
"""Format a task completion as a notification message.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
task: Completed task
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Formatted notification string for injection into context
|
|
358
|
+
"""
|
|
359
|
+
status_str = "completed successfully" if task.status == TaskStatus.COMPLETED else "failed"
|
|
360
|
+
|
|
361
|
+
if task.task_type == TaskType.SHELL:
|
|
362
|
+
msg = f"[Background shell task {task.task_id} {status_str}]"
|
|
363
|
+
msg += f"\nCommand: {task.command}"
|
|
364
|
+
msg += f"\nExit code: {task.exit_code}"
|
|
365
|
+
|
|
366
|
+
if task.stdout:
|
|
367
|
+
# Truncate long output
|
|
368
|
+
stdout = task.stdout[-3000:] if len(task.stdout) > 3000 else task.stdout
|
|
369
|
+
if len(task.stdout) > 3000:
|
|
370
|
+
stdout = "...(truncated)\n" + stdout
|
|
371
|
+
msg += f"\n\nStdout:\n{stdout}"
|
|
372
|
+
|
|
373
|
+
if task.stderr:
|
|
374
|
+
stderr = task.stderr[-1500:] if len(task.stderr) > 1500 else task.stderr
|
|
375
|
+
if len(task.stderr) > 1500:
|
|
376
|
+
stderr = "...(truncated)\n" + stderr
|
|
377
|
+
msg += f"\n\nStderr:\n{stderr}"
|
|
378
|
+
|
|
379
|
+
else: # SUBAGENT
|
|
380
|
+
msg = f"[Background sub-agent {task.task_id} ({task.agent_type}) {status_str}]"
|
|
381
|
+
|
|
382
|
+
if task.error:
|
|
383
|
+
msg += f"\nError: {task.error}"
|
|
384
|
+
elif task.result:
|
|
385
|
+
# Include summary from sub-agent result
|
|
386
|
+
if hasattr(task.result, "summary"):
|
|
387
|
+
msg += f"\n\nSummary:\n{task.result.summary}"
|
|
388
|
+
elif isinstance(task.result, dict) and "summary" in task.result:
|
|
389
|
+
msg += f"\n\nSummary:\n{task.result['summary']}"
|
|
390
|
+
|
|
391
|
+
msg += f"\n\nUse task_output(task_id='{task.task_id}') for full details."
|
|
392
|
+
|
|
393
|
+
return msg
|
|
394
|
+
|
|
395
|
+
def kill_task(self, task_id: str) -> bool:
|
|
396
|
+
"""Kill a running task.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
task_id: Task to kill
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if task was killed, False if not found or already completed
|
|
403
|
+
"""
|
|
404
|
+
task = self._tasks.get(task_id)
|
|
405
|
+
if task is None:
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
if task.status != TaskStatus.RUNNING:
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
log.info(f"Killing background task {task_id}")
|
|
412
|
+
|
|
413
|
+
if task.task_type == TaskType.SHELL and task.process:
|
|
414
|
+
try:
|
|
415
|
+
task.process.terminate()
|
|
416
|
+
# Give it a moment to terminate gracefully
|
|
417
|
+
try:
|
|
418
|
+
task.process.wait(timeout=2)
|
|
419
|
+
except subprocess.TimeoutExpired:
|
|
420
|
+
task.process.kill()
|
|
421
|
+
|
|
422
|
+
task.status = TaskStatus.KILLED
|
|
423
|
+
task.exit_code = -15 # SIGTERM
|
|
424
|
+
task.completed_at = time.time()
|
|
425
|
+
return True
|
|
426
|
+
except Exception as e:
|
|
427
|
+
log.warning(f"Failed to kill shell task {task_id}: {e}")
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
elif task.task_type == TaskType.SUBAGENT and task.future:
|
|
431
|
+
# Can't really kill a future, but we can mark it
|
|
432
|
+
task.future.cancel()
|
|
433
|
+
task.status = TaskStatus.KILLED
|
|
434
|
+
task.completed_at = time.time()
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
def cleanup_old_tasks(self, max_age_seconds: int = 3600) -> int:
|
|
440
|
+
"""Remove old completed tasks.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
max_age_seconds: Remove tasks older than this (default 1 hour)
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Number of tasks removed
|
|
447
|
+
"""
|
|
448
|
+
now = time.time()
|
|
449
|
+
to_remove = []
|
|
450
|
+
|
|
451
|
+
for task_id, task in self._tasks.items():
|
|
452
|
+
if task.status == TaskStatus.RUNNING:
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if task.completed_at and (now - task.completed_at) > max_age_seconds:
|
|
456
|
+
to_remove.append(task_id)
|
|
457
|
+
|
|
458
|
+
for task_id in to_remove:
|
|
459
|
+
del self._tasks[task_id]
|
|
460
|
+
|
|
461
|
+
if to_remove:
|
|
462
|
+
log.debug(f"Cleaned up {len(to_remove)} old background tasks")
|
|
463
|
+
|
|
464
|
+
return len(to_remove)
|
|
465
|
+
|
|
466
|
+
def shutdown(self) -> None:
|
|
467
|
+
"""Shutdown the manager and clean up resources."""
|
|
468
|
+
log.info("Shutting down BackgroundTaskManager")
|
|
469
|
+
|
|
470
|
+
# Stop monitor thread
|
|
471
|
+
self._stop_monitor.set()
|
|
472
|
+
if self._monitor_thread:
|
|
473
|
+
self._monitor_thread.join(timeout=2)
|
|
474
|
+
|
|
475
|
+
# Kill any running tasks
|
|
476
|
+
for task in list(self._tasks.values()):
|
|
477
|
+
if task.status == TaskStatus.RUNNING:
|
|
478
|
+
self.kill_task(task.task_id)
|
|
479
|
+
|
|
480
|
+
# Shutdown executor
|
|
481
|
+
self._executor.shutdown(wait=False)
|