emdash-core 0.1.37__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 +9 -0
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/inprocess_subagent.py +70 -1
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +53 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +73 -1
- emdash_core/agent/prompts/workflow.py +179 -28
- 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 +145 -19
- 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/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 +16 -19
- 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 +297 -2
- 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/models/agent.py +10 -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.37.dist-info → emdash_core-0.1.60.dist-info}/METADATA +6 -6
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/RECORD +47 -52
- 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.37.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
emdash_core/agent/agents.py
CHANGED
|
@@ -81,6 +81,9 @@ class CustomAgent:
|
|
|
81
81
|
system_prompt: Custom system prompt
|
|
82
82
|
tools: List of tools to enable
|
|
83
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)
|
|
84
87
|
examples: Example interactions
|
|
85
88
|
file_path: Source file path
|
|
86
89
|
"""
|
|
@@ -91,6 +94,9 @@ class CustomAgent:
|
|
|
91
94
|
system_prompt: str = ""
|
|
92
95
|
tools: list[str] = field(default_factory=list)
|
|
93
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)
|
|
94
100
|
examples: list[dict] = field(default_factory=list)
|
|
95
101
|
file_path: Optional[Path] = None
|
|
96
102
|
|
|
@@ -200,6 +206,9 @@ def _parse_agent_file(file_path: Path) -> Optional[CustomAgent]:
|
|
|
200
206
|
system_prompt=system_prompt,
|
|
201
207
|
tools=frontmatter.get("tools", []),
|
|
202
208
|
mcp_servers=mcp_servers,
|
|
209
|
+
rules=frontmatter.get("rules", []),
|
|
210
|
+
skills=frontmatter.get("skills", []),
|
|
211
|
+
verifiers=frontmatter.get("verifiers", []),
|
|
203
212
|
examples=examples,
|
|
204
213
|
file_path=file_path,
|
|
205
214
|
)
|
|
@@ -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)
|
|
@@ -39,8 +39,13 @@ class SubAgentResult:
|
|
|
39
39
|
iterations: int
|
|
40
40
|
tools_used: list[str]
|
|
41
41
|
execution_time: float
|
|
42
|
+
exploration_steps: list[dict] = None # Detailed exploration steps
|
|
42
43
|
error: Optional[str] = None
|
|
43
44
|
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
if self.exploration_steps is None:
|
|
47
|
+
self.exploration_steps = []
|
|
48
|
+
|
|
44
49
|
def to_dict(self) -> dict:
|
|
45
50
|
return asdict(self)
|
|
46
51
|
|
|
@@ -102,6 +107,7 @@ class InProcessSubAgent:
|
|
|
102
107
|
# Tracking
|
|
103
108
|
self.files_explored: set[str] = set()
|
|
104
109
|
self.tools_used: list[str] = []
|
|
110
|
+
self.exploration_steps: list[dict] = [] # Detailed step tracking
|
|
105
111
|
|
|
106
112
|
def _inject_thoroughness(self, prompt: str) -> str:
|
|
107
113
|
"""Inject thoroughness level into the system prompt."""
|
|
@@ -146,6 +152,7 @@ class InProcessSubAgent:
|
|
|
146
152
|
event_map = {
|
|
147
153
|
"tool_start": EventType.TOOL_START,
|
|
148
154
|
"tool_result": EventType.TOOL_RESULT,
|
|
155
|
+
"thinking": EventType.THINKING,
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
if event_type in event_map:
|
|
@@ -279,9 +286,11 @@ Now, your task:
|
|
|
279
286
|
if assistant_msg:
|
|
280
287
|
messages.append(assistant_msg)
|
|
281
288
|
|
|
282
|
-
# Save content
|
|
289
|
+
# Save content and emit thinking event
|
|
283
290
|
if response.content:
|
|
284
291
|
last_content = response.content
|
|
292
|
+
# Emit thinking event for UI
|
|
293
|
+
self._emit("thinking", content=response.content)
|
|
285
294
|
|
|
286
295
|
# Check if done
|
|
287
296
|
if not response.tool_calls:
|
|
@@ -316,6 +325,15 @@ Now, your task:
|
|
|
316
325
|
summary=summary,
|
|
317
326
|
)
|
|
318
327
|
|
|
328
|
+
# Track exploration step with details
|
|
329
|
+
step = {
|
|
330
|
+
"tool": tool_call.name,
|
|
331
|
+
"params": self._sanitize_params(args),
|
|
332
|
+
"success": result.success,
|
|
333
|
+
"summary": self._extract_result_summary(tool_call.name, args, result),
|
|
334
|
+
}
|
|
335
|
+
self.exploration_steps.append(step)
|
|
336
|
+
|
|
319
337
|
# Add tool result to messages (truncated to avoid context overflow)
|
|
320
338
|
tool_output = json.dumps(result.to_dict(), indent=2)
|
|
321
339
|
tool_output = truncate_tool_output(tool_output, max_tokens=15000)
|
|
@@ -351,6 +369,7 @@ Now, your task:
|
|
|
351
369
|
iterations=iterations,
|
|
352
370
|
tools_used=list(set(self.tools_used)),
|
|
353
371
|
execution_time=execution_time,
|
|
372
|
+
exploration_steps=self.exploration_steps[-30:], # Last 30 steps
|
|
354
373
|
error=error,
|
|
355
374
|
)
|
|
356
375
|
|
|
@@ -367,6 +386,56 @@ Now, your task:
|
|
|
367
386
|
pass
|
|
368
387
|
return findings[-10:]
|
|
369
388
|
|
|
389
|
+
def _sanitize_params(self, args: dict) -> dict:
|
|
390
|
+
"""Sanitize params for logging - truncate long values."""
|
|
391
|
+
sanitized = {}
|
|
392
|
+
for key, value in args.items():
|
|
393
|
+
if isinstance(value, str) and len(value) > 200:
|
|
394
|
+
sanitized[key] = value[:200] + "..."
|
|
395
|
+
else:
|
|
396
|
+
sanitized[key] = value
|
|
397
|
+
return sanitized
|
|
398
|
+
|
|
399
|
+
def _extract_result_summary(self, tool_name: str, args: dict, result) -> str:
|
|
400
|
+
"""Extract a meaningful summary from tool result based on tool type."""
|
|
401
|
+
if not result.success:
|
|
402
|
+
return f"Failed: {result.error or 'unknown error'}"
|
|
403
|
+
|
|
404
|
+
data = result.data or {}
|
|
405
|
+
|
|
406
|
+
# Tool-specific summaries
|
|
407
|
+
if tool_name == "read_file":
|
|
408
|
+
path = args.get("path", args.get("file_path", ""))
|
|
409
|
+
lines = data.get("line_count", data.get("lines", "?"))
|
|
410
|
+
return f"Read {path} ({lines} lines)"
|
|
411
|
+
|
|
412
|
+
elif tool_name == "glob":
|
|
413
|
+
matches = data.get("matches", data.get("files", []))
|
|
414
|
+
pattern = args.get("pattern", "")
|
|
415
|
+
return f"Found {len(matches)} files matching '{pattern}'"
|
|
416
|
+
|
|
417
|
+
elif tool_name == "grep":
|
|
418
|
+
matches = data.get("matches", [])
|
|
419
|
+
pattern = args.get("pattern", "")
|
|
420
|
+
return f"Found {len(matches)} matches for '{pattern}'"
|
|
421
|
+
|
|
422
|
+
elif tool_name == "semantic_search":
|
|
423
|
+
results = data.get("results", [])
|
|
424
|
+
query = args.get("query", "")[:50]
|
|
425
|
+
return f"Found {len(results)} results for '{query}'"
|
|
426
|
+
|
|
427
|
+
elif tool_name == "list_files":
|
|
428
|
+
files = data.get("files", data.get("entries", []))
|
|
429
|
+
path = args.get("path", "")
|
|
430
|
+
return f"Listed {len(files)} items in {path}"
|
|
431
|
+
|
|
432
|
+
else:
|
|
433
|
+
# Generic summary
|
|
434
|
+
if isinstance(data, dict):
|
|
435
|
+
keys = list(data.keys())[:3]
|
|
436
|
+
return f"Returned: {', '.join(keys)}" if keys else "Success"
|
|
437
|
+
return str(data)[:100] if data else "Success"
|
|
438
|
+
|
|
370
439
|
|
|
371
440
|
# Thread pool for parallel execution
|
|
372
441
|
_executor: Optional[ThreadPoolExecutor] = None
|