dulus 0.2.0__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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
multi_agent/subagent.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Threaded sub-agent system for spawning nested agent loops."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
import queue
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, Future
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional, Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Agent definition ───────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentDefinition:
|
|
19
|
+
"""Definition for a specialized agent type."""
|
|
20
|
+
name: str
|
|
21
|
+
description: str = ""
|
|
22
|
+
system_prompt: str = "" # extra instructions prepended to the base system prompt
|
|
23
|
+
model: str = "" # model override; "" = inherit from parent
|
|
24
|
+
tools: list = field(default_factory=list) # empty list = all tools
|
|
25
|
+
source: str = "user" # "built-in" | "user" | "project"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Built-in agent definitions ─────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
_BUILTIN_AGENTS: Dict[str, AgentDefinition] = {
|
|
31
|
+
"general-purpose": AgentDefinition(
|
|
32
|
+
name="general-purpose",
|
|
33
|
+
description=(
|
|
34
|
+
"General-purpose agent for researching complex questions, "
|
|
35
|
+
"searching for code, and executing multi-step tasks."
|
|
36
|
+
),
|
|
37
|
+
system_prompt="",
|
|
38
|
+
source="built-in",
|
|
39
|
+
),
|
|
40
|
+
"coder": AgentDefinition(
|
|
41
|
+
name="coder",
|
|
42
|
+
description="Specialized coding agent for writing, reading, and modifying code.",
|
|
43
|
+
system_prompt=(
|
|
44
|
+
"You are a specialized coding assistant. Focus on:\n"
|
|
45
|
+
"- Writing clean, idiomatic code\n"
|
|
46
|
+
"- Reading and understanding existing code before modifying\n"
|
|
47
|
+
"- Making minimal targeted changes\n"
|
|
48
|
+
"- Never adding unnecessary features, comments, or error handling\n"
|
|
49
|
+
),
|
|
50
|
+
source="built-in",
|
|
51
|
+
),
|
|
52
|
+
"reviewer": AgentDefinition(
|
|
53
|
+
name="reviewer",
|
|
54
|
+
description="Code review agent analyzing quality, security, and correctness.",
|
|
55
|
+
system_prompt=(
|
|
56
|
+
"You are a code reviewer. Analyze code for:\n"
|
|
57
|
+
"- Correctness and logic errors\n"
|
|
58
|
+
"- Security vulnerabilities (injection, XSS, auth bypass, etc.)\n"
|
|
59
|
+
"- Performance issues\n"
|
|
60
|
+
"- Code quality and maintainability\n"
|
|
61
|
+
"Be concise and specific. Categorize findings as: Critical | Warning | Suggestion.\n"
|
|
62
|
+
),
|
|
63
|
+
tools=["Read", "Glob", "Grep"],
|
|
64
|
+
source="built-in",
|
|
65
|
+
),
|
|
66
|
+
"researcher": AgentDefinition(
|
|
67
|
+
name="researcher",
|
|
68
|
+
description="Research agent for exploring codebases and answering questions.",
|
|
69
|
+
system_prompt=(
|
|
70
|
+
"You are a research assistant focused on understanding codebases.\n"
|
|
71
|
+
"- Read and analyze code thoroughly before answering\n"
|
|
72
|
+
"- Provide factual, evidence-based answers\n"
|
|
73
|
+
"- Cite specific file paths and line numbers\n"
|
|
74
|
+
"- Be concise and focused\n"
|
|
75
|
+
),
|
|
76
|
+
tools=["Read", "Glob", "Grep", "WebFetch", "WebSearch"],
|
|
77
|
+
source="built-in",
|
|
78
|
+
),
|
|
79
|
+
"tester": AgentDefinition(
|
|
80
|
+
name="tester",
|
|
81
|
+
description="Testing agent that writes and runs tests.",
|
|
82
|
+
system_prompt=(
|
|
83
|
+
"You are a testing specialist. Your job:\n"
|
|
84
|
+
"- Write comprehensive tests for the given code\n"
|
|
85
|
+
"- Run existing tests and diagnose failures\n"
|
|
86
|
+
"- Focus on edge cases and error conditions\n"
|
|
87
|
+
"- Keep tests simple, readable, and fast\n"
|
|
88
|
+
),
|
|
89
|
+
source="built-in",
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Loading agent definitions from .md files ──────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _parse_agent_md(path: Path, source: str = "user") -> AgentDefinition:
|
|
97
|
+
"""Parse a .md file with optional YAML frontmatter into an AgentDefinition.
|
|
98
|
+
|
|
99
|
+
File format:
|
|
100
|
+
---
|
|
101
|
+
description: "Short description"
|
|
102
|
+
model: claude-haiku-4-5-20251001
|
|
103
|
+
tools: [Read, Write, Edit, Bash]
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
System prompt body goes here...
|
|
107
|
+
"""
|
|
108
|
+
content = path.read_text()
|
|
109
|
+
name = path.stem
|
|
110
|
+
description = ""
|
|
111
|
+
model = ""
|
|
112
|
+
tools: list = []
|
|
113
|
+
system_prompt_body = content
|
|
114
|
+
|
|
115
|
+
if content.startswith("---"):
|
|
116
|
+
end = content.find("---", 3)
|
|
117
|
+
if end != -1:
|
|
118
|
+
fm_text = content[3:end].strip()
|
|
119
|
+
system_prompt_body = content[end + 3:].strip()
|
|
120
|
+
try:
|
|
121
|
+
import yaml as _yaml
|
|
122
|
+
fm = _yaml.safe_load(fm_text) or {}
|
|
123
|
+
except ImportError:
|
|
124
|
+
# Manual key: value parse (no yaml dependency required)
|
|
125
|
+
fm: dict = {}
|
|
126
|
+
for line in fm_text.splitlines():
|
|
127
|
+
if ":" in line:
|
|
128
|
+
k, _, v = line.partition(":")
|
|
129
|
+
fm[k.strip()] = v.strip()
|
|
130
|
+
description = str(fm.get("description", ""))
|
|
131
|
+
model = str(fm.get("model", ""))
|
|
132
|
+
raw_tools = fm.get("tools", [])
|
|
133
|
+
if isinstance(raw_tools, list):
|
|
134
|
+
tools = [str(t) for t in raw_tools]
|
|
135
|
+
elif isinstance(raw_tools, str):
|
|
136
|
+
# Handle "[Read, Write]" or "Read, Write" format
|
|
137
|
+
s = raw_tools.strip("[]")
|
|
138
|
+
tools = [t.strip() for t in s.split(",") if t.strip()]
|
|
139
|
+
|
|
140
|
+
return AgentDefinition(
|
|
141
|
+
name=name,
|
|
142
|
+
description=description,
|
|
143
|
+
system_prompt=system_prompt_body,
|
|
144
|
+
model=model,
|
|
145
|
+
tools=tools,
|
|
146
|
+
source=source,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_agent_definitions() -> Dict[str, AgentDefinition]:
|
|
151
|
+
"""Load all agent definitions: built-ins → user-level → project-level.
|
|
152
|
+
|
|
153
|
+
Search paths:
|
|
154
|
+
~/.dulus/agents/*.md (user-level)
|
|
155
|
+
.dulus/agents/*.md (project-level, overrides user)
|
|
156
|
+
"""
|
|
157
|
+
defs: Dict[str, AgentDefinition] = dict(_BUILTIN_AGENTS)
|
|
158
|
+
|
|
159
|
+
# User-level
|
|
160
|
+
user_dir = Path.home() / ".dulus" / "agents"
|
|
161
|
+
if user_dir.is_dir():
|
|
162
|
+
for p in sorted(user_dir.glob("*.md")):
|
|
163
|
+
try:
|
|
164
|
+
d = _parse_agent_md(p, source="user")
|
|
165
|
+
defs[d.name] = d
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# Project-level (overrides user)
|
|
170
|
+
proj_dir = Path.cwd() / ".dulus-context" / "agents"
|
|
171
|
+
if proj_dir.is_dir():
|
|
172
|
+
for p in sorted(proj_dir.glob("*.md")):
|
|
173
|
+
try:
|
|
174
|
+
d = _parse_agent_md(p, source="project")
|
|
175
|
+
defs[d.name] = d
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
return defs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_agent_definition(name: str) -> Optional[AgentDefinition]:
|
|
183
|
+
"""Look up an agent definition by name. Returns None if not found."""
|
|
184
|
+
return load_agent_definitions().get(name)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── SubAgentTask ───────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class SubAgentTask:
|
|
191
|
+
"""Represents a sub-agent task with lifecycle tracking."""
|
|
192
|
+
id: str
|
|
193
|
+
prompt: str
|
|
194
|
+
status: str = "pending" # pending | running | completed | failed | cancelled
|
|
195
|
+
result: Optional[str] = None
|
|
196
|
+
depth: int = 0
|
|
197
|
+
name: str = "" # optional human-readable name (addressable by SendMessage)
|
|
198
|
+
worktree_path: str = "" # set if isolation="worktree"
|
|
199
|
+
worktree_branch: str = "" # set if isolation="worktree"
|
|
200
|
+
_cancel_flag: bool = False
|
|
201
|
+
_future: Optional[Future] = field(default=None, repr=False)
|
|
202
|
+
_inbox: Any = field(default_factory=queue.Queue, repr=False) # for send_message
|
|
203
|
+
# When the sub-agent calls AskMainAgentQuestion it registers a pending question
|
|
204
|
+
# here and blocks on the event. SendMessage from the main agent resolves it.
|
|
205
|
+
# Shape: {"question": str, "event": threading.Event, "result": list[str]}
|
|
206
|
+
_pending_question: Optional[dict] = field(default=None, repr=False)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Worktree helpers ───────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def _git_root(cwd: str) -> Optional[str]:
|
|
212
|
+
"""Return the git root directory for cwd, or None if not in a git repo."""
|
|
213
|
+
try:
|
|
214
|
+
r = subprocess.run(
|
|
215
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
216
|
+
cwd=cwd, capture_output=True, text=True, check=True,
|
|
217
|
+
)
|
|
218
|
+
return r.stdout.strip()
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _create_worktree(base_dir: str) -> tuple:
|
|
224
|
+
"""Create a temporary git worktree.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
(worktree_path, branch_name)
|
|
228
|
+
Raises:
|
|
229
|
+
subprocess.CalledProcessError or OSError on failure.
|
|
230
|
+
"""
|
|
231
|
+
branch = f"nano-agent-{uuid.uuid4().hex[:8]}"
|
|
232
|
+
# mkdtemp gives us a path; remove the empty dir so git can create it
|
|
233
|
+
wt_path = tempfile.mkdtemp(prefix="nano-agent-wt-")
|
|
234
|
+
os.rmdir(wt_path)
|
|
235
|
+
subprocess.run(
|
|
236
|
+
["git", "worktree", "add", "-b", branch, wt_path],
|
|
237
|
+
cwd=base_dir, check=True, capture_output=True, text=True,
|
|
238
|
+
)
|
|
239
|
+
return wt_path, branch
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _remove_worktree(wt_path: str, branch: str, base_dir: str) -> None:
|
|
243
|
+
"""Remove a git worktree and delete its branch (best-effort)."""
|
|
244
|
+
try:
|
|
245
|
+
subprocess.run(
|
|
246
|
+
["git", "worktree", "remove", "--force", wt_path],
|
|
247
|
+
cwd=base_dir, capture_output=True,
|
|
248
|
+
)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
try:
|
|
252
|
+
subprocess.run(
|
|
253
|
+
["git", "branch", "-D", branch],
|
|
254
|
+
cwd=base_dir, capture_output=True,
|
|
255
|
+
)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def _agent_run(prompt, state, config, system_prompt, depth=0, cancel_check=None):
|
|
263
|
+
"""Lazy-import wrapper to avoid circular dependency with agent module.
|
|
264
|
+
|
|
265
|
+
Uses absolute import so this works whether called from inside or outside
|
|
266
|
+
the multi_agent package (sys.path includes the project root).
|
|
267
|
+
"""
|
|
268
|
+
import agent as _agent_mod
|
|
269
|
+
return _agent_mod.run(prompt, state, config, system_prompt, depth=depth, cancel_check=cancel_check)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _extract_final_text(messages):
|
|
273
|
+
"""Walk backwards through messages, return first assistant content string."""
|
|
274
|
+
for msg in reversed(messages):
|
|
275
|
+
if msg.get("role") == "assistant" and msg.get("content"):
|
|
276
|
+
return msg["content"]
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── SubAgentManager ────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
class SubAgentManager:
|
|
283
|
+
"""Manages concurrent sub-agent tasks using a thread pool."""
|
|
284
|
+
|
|
285
|
+
def __init__(self, max_concurrent: int = 5, max_depth: int = 5):
|
|
286
|
+
self.tasks: Dict[str, SubAgentTask] = {}
|
|
287
|
+
self._by_name: Dict[str, str] = {} # name → task_id
|
|
288
|
+
self.max_concurrent = max_concurrent
|
|
289
|
+
self.max_depth = max_depth
|
|
290
|
+
self._pool = ThreadPoolExecutor(max_workers=max_concurrent)
|
|
291
|
+
|
|
292
|
+
def spawn(
|
|
293
|
+
self,
|
|
294
|
+
prompt: str,
|
|
295
|
+
config: dict,
|
|
296
|
+
system_prompt: str,
|
|
297
|
+
depth: int = 0,
|
|
298
|
+
agent_def: Optional[AgentDefinition] = None,
|
|
299
|
+
isolation: str = "", # "" | "worktree"
|
|
300
|
+
name: str = "",
|
|
301
|
+
) -> SubAgentTask:
|
|
302
|
+
"""Spawn a new sub-agent task.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
prompt: user message for the sub-agent
|
|
306
|
+
config: agent configuration dict (copied before modification)
|
|
307
|
+
system_prompt: base system prompt
|
|
308
|
+
depth: current nesting depth (prevents infinite recursion)
|
|
309
|
+
agent_def: optional AgentDefinition with model/system_prompt/tools overrides
|
|
310
|
+
isolation: "" for normal, "worktree" for isolated git worktree
|
|
311
|
+
name: optional human-readable name (addressable via SendMessage)
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
SubAgentTask tracking the spawned work.
|
|
315
|
+
"""
|
|
316
|
+
task_id = uuid.uuid4().hex[:12]
|
|
317
|
+
short_name = name or task_id[:8]
|
|
318
|
+
task = SubAgentTask(id=task_id, prompt=prompt, depth=depth, name=short_name)
|
|
319
|
+
self.tasks[task_id] = task
|
|
320
|
+
if name:
|
|
321
|
+
self._by_name[name] = task_id
|
|
322
|
+
|
|
323
|
+
if depth >= self.max_depth:
|
|
324
|
+
task.status = "failed"
|
|
325
|
+
task.result = f"Max depth ({self.max_depth}) exceeded"
|
|
326
|
+
return task
|
|
327
|
+
|
|
328
|
+
# Build effective config and system prompt for this sub-agent
|
|
329
|
+
eff_config = dict(config)
|
|
330
|
+
# Stash task id so tools invoked inside the sub-agent (e.g.
|
|
331
|
+
# AskMainAgentQuestion) can locate their own task via the singleton
|
|
332
|
+
# manager.
|
|
333
|
+
eff_config["_subagent_task_id"] = task_id
|
|
334
|
+
eff_system = system_prompt
|
|
335
|
+
|
|
336
|
+
if agent_def:
|
|
337
|
+
if agent_def.model:
|
|
338
|
+
eff_config["model"] = agent_def.model
|
|
339
|
+
if agent_def.system_prompt:
|
|
340
|
+
eff_system = agent_def.system_prompt.rstrip() + "\n\n" + system_prompt
|
|
341
|
+
|
|
342
|
+
# Handle worktree isolation
|
|
343
|
+
worktree_path = ""
|
|
344
|
+
worktree_branch = ""
|
|
345
|
+
base_dir = os.getcwd()
|
|
346
|
+
|
|
347
|
+
if isolation == "worktree":
|
|
348
|
+
git_root = _git_root(base_dir)
|
|
349
|
+
if not git_root:
|
|
350
|
+
task.status = "failed"
|
|
351
|
+
task.result = "isolation='worktree' requires a git repository"
|
|
352
|
+
return task
|
|
353
|
+
try:
|
|
354
|
+
worktree_path, worktree_branch = _create_worktree(git_root)
|
|
355
|
+
task.worktree_path = worktree_path
|
|
356
|
+
task.worktree_branch = worktree_branch
|
|
357
|
+
notice = (
|
|
358
|
+
f"\n\n[Note: You are working in an isolated git worktree at "
|
|
359
|
+
f"{worktree_path} (branch: {worktree_branch}). "
|
|
360
|
+
f"Your changes are isolated from the main workspace at {git_root}. "
|
|
361
|
+
f"Commit your changes before finishing so they can be reviewed/merged.]"
|
|
362
|
+
)
|
|
363
|
+
prompt = prompt + notice
|
|
364
|
+
except Exception as e:
|
|
365
|
+
task.status = "failed"
|
|
366
|
+
task.result = f"Failed to create worktree: {e}"
|
|
367
|
+
return task
|
|
368
|
+
|
|
369
|
+
def _run():
|
|
370
|
+
import agent as _agent_mod; AgentState = _agent_mod.AgentState
|
|
371
|
+
task.status = "running"
|
|
372
|
+
old_cwd = os.getcwd()
|
|
373
|
+
try:
|
|
374
|
+
if worktree_path:
|
|
375
|
+
os.chdir(worktree_path)
|
|
376
|
+
|
|
377
|
+
state = AgentState()
|
|
378
|
+
gen = _agent_run(
|
|
379
|
+
prompt, state, eff_config, eff_system,
|
|
380
|
+
depth=depth + 1,
|
|
381
|
+
cancel_check=lambda: task._cancel_flag,
|
|
382
|
+
)
|
|
383
|
+
for _event in gen:
|
|
384
|
+
if task._cancel_flag:
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
if task._cancel_flag:
|
|
388
|
+
task.status = "cancelled"
|
|
389
|
+
task.result = None
|
|
390
|
+
else:
|
|
391
|
+
task.result = _extract_final_text(state.messages)
|
|
392
|
+
task.status = "completed"
|
|
393
|
+
|
|
394
|
+
# Drain inbox: process any messages sent via SendMessage
|
|
395
|
+
while not task._inbox.empty() and not task._cancel_flag:
|
|
396
|
+
inbox_msg = task._inbox.get_nowait()
|
|
397
|
+
task.status = "running"
|
|
398
|
+
gen2 = _agent_run(
|
|
399
|
+
inbox_msg, state, eff_config, eff_system,
|
|
400
|
+
depth=depth + 1,
|
|
401
|
+
cancel_check=lambda: task._cancel_flag,
|
|
402
|
+
)
|
|
403
|
+
for _ev in gen2:
|
|
404
|
+
if task._cancel_flag:
|
|
405
|
+
break
|
|
406
|
+
if not task._cancel_flag:
|
|
407
|
+
task.result = _extract_final_text(state.messages)
|
|
408
|
+
task.status = "completed"
|
|
409
|
+
|
|
410
|
+
except Exception as e:
|
|
411
|
+
task.status = "failed"
|
|
412
|
+
task.result = f"Error: {e}"
|
|
413
|
+
finally:
|
|
414
|
+
if worktree_path:
|
|
415
|
+
os.chdir(old_cwd)
|
|
416
|
+
_remove_worktree(worktree_path, worktree_branch, old_cwd)
|
|
417
|
+
|
|
418
|
+
task._future = self._pool.submit(_run)
|
|
419
|
+
return task
|
|
420
|
+
|
|
421
|
+
def wait(self, task_id: str, timeout: float = None) -> Optional[SubAgentTask]:
|
|
422
|
+
"""Block until a task completes or timeout expires.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The task, or None if task_id is unknown.
|
|
426
|
+
"""
|
|
427
|
+
task = self.tasks.get(task_id)
|
|
428
|
+
if task is None:
|
|
429
|
+
return None
|
|
430
|
+
if task._future is not None:
|
|
431
|
+
try:
|
|
432
|
+
task._future.result(timeout=timeout)
|
|
433
|
+
except Exception:
|
|
434
|
+
pass
|
|
435
|
+
return task
|
|
436
|
+
|
|
437
|
+
def get_result(self, task_id: str) -> Optional[str]:
|
|
438
|
+
"""Return the result string for a completed task, or None."""
|
|
439
|
+
task = self.tasks.get(task_id)
|
|
440
|
+
return task.result if task else None
|
|
441
|
+
|
|
442
|
+
def list_tasks(self) -> List[SubAgentTask]:
|
|
443
|
+
"""Return all tracked tasks."""
|
|
444
|
+
return list(self.tasks.values())
|
|
445
|
+
|
|
446
|
+
def send_message(self, task_id_or_name: str, message: str) -> bool:
|
|
447
|
+
"""Send a message to a running background agent.
|
|
448
|
+
|
|
449
|
+
If the agent is currently blocked on an AskMainAgentQuestion call, the
|
|
450
|
+
message is delivered immediately as the answer and the agent resumes
|
|
451
|
+
the SAME turn (preserving full context).
|
|
452
|
+
|
|
453
|
+
Otherwise the message is queued in the inbox and the agent processes
|
|
454
|
+
it after completing its current turn.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
task_id_or_name: task ID or the human-readable name passed to spawn()
|
|
458
|
+
message: message text to send
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
True if the message was delivered or queued, False if task not
|
|
462
|
+
found or already done.
|
|
463
|
+
"""
|
|
464
|
+
# Resolve name → task_id
|
|
465
|
+
task_id = self._by_name.get(task_id_or_name, task_id_or_name)
|
|
466
|
+
task = self.tasks.get(task_id)
|
|
467
|
+
if task is None:
|
|
468
|
+
return False
|
|
469
|
+
if task.status not in ("running", "pending"):
|
|
470
|
+
return False
|
|
471
|
+
# If the sub-agent is waiting on AskMainAgentQuestion, fulfill it now
|
|
472
|
+
# instead of queuing to the inbox.
|
|
473
|
+
pq = task._pending_question
|
|
474
|
+
if pq is not None:
|
|
475
|
+
pq["result"].append(message)
|
|
476
|
+
task._pending_question = None
|
|
477
|
+
pq["event"].set()
|
|
478
|
+
return True
|
|
479
|
+
task._inbox.put(message)
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
def cancel(self, task_id: str) -> bool:
|
|
483
|
+
"""Request cancellation of a running task.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
True if the cancel flag was set, False if task not found or not running.
|
|
487
|
+
"""
|
|
488
|
+
task = self.tasks.get(task_id)
|
|
489
|
+
if task is None:
|
|
490
|
+
return False
|
|
491
|
+
if task.status == "running":
|
|
492
|
+
task._cancel_flag = True
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
def shutdown(self) -> None:
|
|
497
|
+
"""Cancel all running tasks and shut down the thread pool."""
|
|
498
|
+
for task in self.tasks.values():
|
|
499
|
+
if task.status == "running":
|
|
500
|
+
task._cancel_flag = True
|
|
501
|
+
self._pool.shutdown(wait=True)
|