kweaver-dolphin 0.2.0__py3-none-any.whl → 0.2.2__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.
- dolphin/cli/runner/runner.py +20 -0
- dolphin/cli/ui/console.py +29 -11
- dolphin/cli/utils/helpers.py +4 -4
- dolphin/core/agent/base_agent.py +2 -2
- dolphin/core/code_block/basic_code_block.py +140 -30
- dolphin/core/code_block/explore_block.py +353 -29
- dolphin/core/code_block/explore_block_v2.py +21 -17
- dolphin/core/code_block/explore_strategy.py +1 -0
- dolphin/core/code_block/judge_block.py +10 -1
- dolphin/core/code_block/skill_call_deduplicator.py +32 -10
- dolphin/core/code_block/tool_block.py +12 -3
- dolphin/core/common/constants.py +25 -1
- dolphin/core/config/global_config.py +35 -0
- dolphin/core/context/context.py +168 -5
- dolphin/core/context/cow_context.py +392 -0
- dolphin/core/flags/definitions.py +2 -2
- dolphin/core/runtime/runtime_instance.py +31 -0
- dolphin/core/skill/context_retention.py +3 -3
- dolphin/core/task_registry.py +404 -0
- dolphin/lib/__init__.py +0 -2
- dolphin/lib/skillkits/__init__.py +2 -2
- dolphin/lib/skillkits/plan_skillkit.py +756 -0
- dolphin/lib/skillkits/system_skillkit.py +103 -30
- dolphin/sdk/skill/global_skills.py +43 -3
- {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/METADATA +1 -1
- {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/RECORD +30 -28
- {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/WHEEL +1 -1
- kweaver_dolphin-0.2.2.dist-info/entry_points.txt +15 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +0 -452
- kweaver_dolphin-0.2.0.dist-info/entry_points.txt +0 -27
- {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/licenses/LICENSE.txt +0 -0
- {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"""Plan Skillkit for Unified Plan Architecture.
|
|
2
|
+
|
|
3
|
+
This module provides task orchestration tools for plan mode.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from dolphin.core.context.context import Context
|
|
12
|
+
from dolphin.core.skill.skill_function import SkillFunction
|
|
13
|
+
from dolphin.core.skill.skillkit import Skillkit
|
|
14
|
+
from dolphin.core.task_registry import Task, TaskRegistry, TaskStatus, PlanExecMode
|
|
15
|
+
from dolphin.core.logging.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("plan_skillkit")
|
|
18
|
+
|
|
19
|
+
_VAR_PLAN_OUTPUTS_AUTO_INJECTED_PREFIX = "_plan.outputs_auto_injected"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlanSkillkit(Skillkit):
|
|
23
|
+
"""Task orchestration tools (Plan).
|
|
24
|
+
|
|
25
|
+
Principles:
|
|
26
|
+
- Stateless: persistent state lives in Context.
|
|
27
|
+
- Tool-first: each method is an independent tool.
|
|
28
|
+
- Composable: the agent can combine tools as needed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Import from constants to avoid duplication
|
|
32
|
+
# Tools that should be excluded from subtasks to prevent infinite recursion
|
|
33
|
+
from dolphin.core.common.constants import PLAN_ORCHESTRATION_TOOLS
|
|
34
|
+
EXCLUDED_SUBTASK_TOOLS = PLAN_ORCHESTRATION_TOOLS
|
|
35
|
+
|
|
36
|
+
def __init__(self, context: Optional[Context] = None):
|
|
37
|
+
"""Initialize PlanSkillkit.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
context: Execution context (can be None, will be set via setContext)
|
|
41
|
+
"""
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._context = context
|
|
44
|
+
# Note: running_tasks dict has been removed - all asyncio task handles
|
|
45
|
+
# are now managed centrally in TaskRegistry.running_asyncio_tasks
|
|
46
|
+
self.max_concurrency: int = 5
|
|
47
|
+
self._parent_skills: Optional[List[str]] = None # Cache parent Agent's skills config
|
|
48
|
+
self._last_poll_status: Optional[str] = None
|
|
49
|
+
self._last_poll_time: float = 0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def context(self) -> Optional[Context]:
|
|
53
|
+
"""Compatibility alias for accessing the execution context."""
|
|
54
|
+
return self._context
|
|
55
|
+
|
|
56
|
+
def setContext(self, context: Context):
|
|
57
|
+
"""Set the execution context (called by ExploreBlock)."""
|
|
58
|
+
self._context = context
|
|
59
|
+
|
|
60
|
+
def getContext(self) -> Optional[Context]:
|
|
61
|
+
"""Get the current execution context."""
|
|
62
|
+
return self._context
|
|
63
|
+
|
|
64
|
+
def getName(self) -> str:
|
|
65
|
+
return "plan_skillkit"
|
|
66
|
+
|
|
67
|
+
def _get_runtime_context(self) -> Optional[Context]:
|
|
68
|
+
"""Get the runtime context from various sources.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Context if available, None otherwise
|
|
72
|
+
"""
|
|
73
|
+
# Try instance context first
|
|
74
|
+
if self._context:
|
|
75
|
+
return self._context
|
|
76
|
+
|
|
77
|
+
# Context should be injected by ExploreBlock when skillkit is used
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
async def _plan_tasks(
|
|
81
|
+
self,
|
|
82
|
+
tasks: List[Dict[str, Any]],
|
|
83
|
+
exec_mode: str = "para",
|
|
84
|
+
max_concurrency: Optional[int] = None,
|
|
85
|
+
**kwargs
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Plan and start subtasks.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
tasks: A list of task dicts, e.g.:
|
|
91
|
+
[
|
|
92
|
+
{"id": "task_1", "name": "Task Name", "prompt": "Task description"},
|
|
93
|
+
{"id": "task_2", "name": "Task Name", "prompt": "Task description"},
|
|
94
|
+
]
|
|
95
|
+
exec_mode: "para" (parallel) or "seq" (sequential) (default: "para")
|
|
96
|
+
max_concurrency: Max concurrent tasks for parallel mode (default: 5)
|
|
97
|
+
**kwargs: Additional properties
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A short summary string.
|
|
101
|
+
|
|
102
|
+
Behavior:
|
|
103
|
+
1. If plan is not enabled, enable it lazily.
|
|
104
|
+
2. If a plan already exists, treat as replan.
|
|
105
|
+
3. Register tasks into TaskRegistry.
|
|
106
|
+
4. Start tasks based on execution mode and dependencies.
|
|
107
|
+
5. Emit a `plan_created` event (UI can subscribe).
|
|
108
|
+
"""
|
|
109
|
+
# Ensure context is available
|
|
110
|
+
# Note: context should be injected by ExploreBlock when skillkit is used
|
|
111
|
+
context = self._get_runtime_context()
|
|
112
|
+
if not context:
|
|
113
|
+
raise RuntimeError("PlanSkillkit requires context. Please ensure it's properly initialized.")
|
|
114
|
+
|
|
115
|
+
# Disallow nested planning inside subtask contexts.
|
|
116
|
+
# Subtasks should not orchestrate further plans; they should focus on executing their own prompts.
|
|
117
|
+
try:
|
|
118
|
+
from dolphin.core.context.cow_context import COWContext
|
|
119
|
+
|
|
120
|
+
if isinstance(context, COWContext):
|
|
121
|
+
raise RuntimeError("Nested planning is not supported")
|
|
122
|
+
except RuntimeError:
|
|
123
|
+
# Re-raise our own RuntimeError
|
|
124
|
+
raise
|
|
125
|
+
except Exception:
|
|
126
|
+
# Fail-open: if the runtime type check fails for any reason, proceed with normal behavior.
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Set context for subsequent method calls
|
|
130
|
+
self._context = context
|
|
131
|
+
|
|
132
|
+
# Init or replan
|
|
133
|
+
if not self._context.is_plan_enabled():
|
|
134
|
+
await self._context.enable_plan()
|
|
135
|
+
logger.debug("Plan enabled")
|
|
136
|
+
else:
|
|
137
|
+
await self._context.enable_plan() # Replan: resets registry
|
|
138
|
+
logger.debug("Replan detected")
|
|
139
|
+
|
|
140
|
+
# Capture parent Agent's skills configuration for subtasks
|
|
141
|
+
# This allows subtasks to inherit the same tool set (minus excluded tools)
|
|
142
|
+
self._parent_skills = self._context.get_last_skills()
|
|
143
|
+
logger.debug(
|
|
144
|
+
f"[PlanSkillkit] Captured parent skills for subtasks: {self._parent_skills}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Validate task list
|
|
148
|
+
errors = self._validate_tasks(tasks)
|
|
149
|
+
if errors:
|
|
150
|
+
return f"Validation failed: {'; '.join(errors)}"
|
|
151
|
+
|
|
152
|
+
# Update settings
|
|
153
|
+
if max_concurrency is not None:
|
|
154
|
+
self.max_concurrency = max_concurrency
|
|
155
|
+
|
|
156
|
+
# Priority: input exec_mode (from LLM) > block parameter > default PARALLEL
|
|
157
|
+
current_exec_mode = PlanExecMode.PARALLEL
|
|
158
|
+
|
|
159
|
+
# 1. Check if LLM explicitly requested a mode (other than default)
|
|
160
|
+
if exec_mode and exec_mode != "para":
|
|
161
|
+
current_exec_mode = PlanExecMode.from_str(exec_mode)
|
|
162
|
+
# 2. Check if block parameters configured a mode
|
|
163
|
+
elif self._context:
|
|
164
|
+
cur_block = getattr(self._context.runtime_graph, "cur_block", None) if hasattr(self._context, "runtime_graph") else None
|
|
165
|
+
if cur_block and hasattr(cur_block, "params"):
|
|
166
|
+
block_exec_mode = cur_block.params.get("exec_mode")
|
|
167
|
+
if block_exec_mode:
|
|
168
|
+
# ExploreBlock already validated/converted this to PlanExecMode enum
|
|
169
|
+
current_exec_mode = block_exec_mode if isinstance(block_exec_mode, PlanExecMode) else PlanExecMode.from_str(str(block_exec_mode))
|
|
170
|
+
|
|
171
|
+
logger.debug(f"[PlanSkillkit] Final exec_mode: {current_exec_mode}")
|
|
172
|
+
|
|
173
|
+
# Register tasks
|
|
174
|
+
registry = self._context.task_registry
|
|
175
|
+
for task_dict in tasks:
|
|
176
|
+
task = Task(
|
|
177
|
+
id=task_dict["id"],
|
|
178
|
+
name=task_dict["name"],
|
|
179
|
+
prompt=task_dict["prompt"],
|
|
180
|
+
)
|
|
181
|
+
await registry.add_task(task)
|
|
182
|
+
|
|
183
|
+
registry.exec_mode = current_exec_mode
|
|
184
|
+
registry.max_concurrency = self.max_concurrency
|
|
185
|
+
|
|
186
|
+
# Emit plan_created event
|
|
187
|
+
all_tasks = await registry.get_all_tasks()
|
|
188
|
+
self._context.write_output("plan_created", {
|
|
189
|
+
"plan_id": self._context.get_plan_id(),
|
|
190
|
+
"exec_mode": current_exec_mode.value,
|
|
191
|
+
"max_concurrency": self.max_concurrency,
|
|
192
|
+
"tasks": [
|
|
193
|
+
{"id": t.id, "name": t.name, "status": t.status.value}
|
|
194
|
+
for t in all_tasks
|
|
195
|
+
],
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
# Prepare summary
|
|
199
|
+
task_summary = "\n".join([f"- **{t['id']}**: {t['name']}" for t in tasks])
|
|
200
|
+
|
|
201
|
+
# Start tasks
|
|
202
|
+
if current_exec_mode == PlanExecMode.PARALLEL:
|
|
203
|
+
ready_tasks = await self._select_ready_tasks(limit=self.max_concurrency)
|
|
204
|
+
for task_id in ready_tasks:
|
|
205
|
+
await self._spawn_task(task_id)
|
|
206
|
+
return f"Plan initialized with {len(tasks)} tasks (parallel mode, max_concurrency={self.max_concurrency}):\n\n{task_summary}"
|
|
207
|
+
else:
|
|
208
|
+
ready = await self._select_ready_tasks(limit=1)
|
|
209
|
+
if ready:
|
|
210
|
+
await self._spawn_task(ready[0])
|
|
211
|
+
return f"Plan initialized with {len(tasks)} tasks (sequential mode):\n\n{task_summary}"
|
|
212
|
+
|
|
213
|
+
async def _check_progress(self, **kwargs) -> str:
|
|
214
|
+
"""Check the status of all subtasks.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A formatted status summary with next-step guidance.
|
|
218
|
+
"""
|
|
219
|
+
if not self._context.is_plan_enabled():
|
|
220
|
+
raise RuntimeError("Plan is not enabled. Please call _plan_tasks first.")
|
|
221
|
+
|
|
222
|
+
# Reuse ExploreBlock interrupt mechanism
|
|
223
|
+
self._context.check_user_interrupt()
|
|
224
|
+
|
|
225
|
+
registry = self._context.task_registry
|
|
226
|
+
status_text = await registry.get_all_status()
|
|
227
|
+
|
|
228
|
+
# Check for busy-waiting (same status polled too frequently)
|
|
229
|
+
now = time.time()
|
|
230
|
+
is_same_status = (status_text == self._last_poll_status)
|
|
231
|
+
interval = now - self._last_poll_time
|
|
232
|
+
|
|
233
|
+
# Item 5: Polling Throttling (Debounce / Guidance)
|
|
234
|
+
throttle_warning = ""
|
|
235
|
+
if is_same_status and not await registry.is_all_done():
|
|
236
|
+
if interval < 2.0:
|
|
237
|
+
# Hard limit: return early to prevent busy-waiting
|
|
238
|
+
self._last_poll_time = now
|
|
239
|
+
return (
|
|
240
|
+
"Status Unchanged (checked too recently). \n\n"
|
|
241
|
+
"💡 Guidance: Subtasks need time to execute. Do not poll `_check_progress` repeatedly "
|
|
242
|
+
"within 1-2 seconds. Use `_wait(seconds=10)` or synthesize existing info instead."
|
|
243
|
+
)
|
|
244
|
+
elif interval < 5.0:
|
|
245
|
+
# Soft guidance: add warning but allow execution
|
|
246
|
+
throttle_warning = (
|
|
247
|
+
"\n\n⚠️ Polling Guidance: Tasks are still running. "
|
|
248
|
+
"Consider using _wait(seconds=5) to give them more time to progress, "
|
|
249
|
+
"or work on other parts of the answer while waiting."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
self._last_poll_status = status_text
|
|
253
|
+
self._last_poll_time = now
|
|
254
|
+
|
|
255
|
+
# Summary stats
|
|
256
|
+
counts = await registry.get_status_counts()
|
|
257
|
+
stats = f"{counts['completed']} completed, {counts['running']} running, {counts['failed']} failed"
|
|
258
|
+
|
|
259
|
+
# Reconciliation: if tasks are marked RUNNING but no asyncio task exists,
|
|
260
|
+
# they were probably lost during a snapshot/restore or process restart.
|
|
261
|
+
# We auto-restart them to prevent the plan from stalling.
|
|
262
|
+
reconciled = []
|
|
263
|
+
running_tasks = await registry.get_running_tasks()
|
|
264
|
+
for t in running_tasks:
|
|
265
|
+
# Check without lock first (fast path)
|
|
266
|
+
if t.id not in registry.running_asyncio_tasks:
|
|
267
|
+
# Double-check with lock to prevent race
|
|
268
|
+
async with registry._lock:
|
|
269
|
+
if t.id not in registry.running_asyncio_tasks:
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"[PlanSkillkit] Reconciliation: Task {t.id} is RUNNING in registry but has no asyncio task. "
|
|
272
|
+
"Restarting task."
|
|
273
|
+
)
|
|
274
|
+
# Note: _spawn_task creates the task entry in running_asyncio_tasks
|
|
275
|
+
await self._spawn_task(t.id)
|
|
276
|
+
reconciled.append(t.id)
|
|
277
|
+
|
|
278
|
+
result = f"Task Status:\n{status_text}\n\nSummary: {stats}{throttle_warning}"
|
|
279
|
+
if reconciled:
|
|
280
|
+
result += f"\n\n⚠️ Reconciliation: Restarted {len(reconciled)} stalled tasks ({', '.join(reconciled)})."
|
|
281
|
+
|
|
282
|
+
# Sequential mode defensive check: if no tasks are running but there are pending tasks,
|
|
283
|
+
# it might indicate the task chain was broken (e.g., finally block never executed).
|
|
284
|
+
# Automatically kickstart the next task to prevent starvation.
|
|
285
|
+
if registry.exec_mode == PlanExecMode.SEQUENTIAL:
|
|
286
|
+
running_count = counts.get("running", 0)
|
|
287
|
+
pending_count = counts.get("pending", 0)
|
|
288
|
+
if running_count == 0 and pending_count > 0:
|
|
289
|
+
# Find the next ready task and start it
|
|
290
|
+
ready_tasks = await self._select_ready_tasks(limit=1)
|
|
291
|
+
if ready_tasks:
|
|
292
|
+
logger.warning(
|
|
293
|
+
f"Sequential mode: No running tasks but {pending_count} pending. "
|
|
294
|
+
f"Kickstarting next task: {ready_tasks[0]}"
|
|
295
|
+
)
|
|
296
|
+
await self._spawn_task(ready_tasks[0])
|
|
297
|
+
result += (
|
|
298
|
+
"\n\n⚠️ Sequential Mode: Detected stalled task chain. "
|
|
299
|
+
f"Automatically started next task: {ready_tasks[0]}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Add guidance when plan reaches a terminal state.
|
|
303
|
+
# Also auto-inject task outputs once per plan_id to help the LLM synthesize a final answer
|
|
304
|
+
if await registry.is_all_done() and counts["completed"] > 0:
|
|
305
|
+
result += "\n\n✅ All tasks completed! Next steps:\n"
|
|
306
|
+
result += "Synthesize the results into a comprehensive response for the user"
|
|
307
|
+
|
|
308
|
+
plan_id = self._context.get_plan_id()
|
|
309
|
+
if plan_id:
|
|
310
|
+
injected_var = f"{_VAR_PLAN_OUTPUTS_AUTO_INJECTED_PREFIX}.{plan_id}"
|
|
311
|
+
injected = self._context.get_var_value(injected_var, False)
|
|
312
|
+
if isinstance(injected, str):
|
|
313
|
+
injected = injected.strip().lower() == "true"
|
|
314
|
+
|
|
315
|
+
if not injected:
|
|
316
|
+
outputs = await self._get_task_output(task_id="all")
|
|
317
|
+
max_len = int(self._context.get_max_answer_len() or 0) or 10000
|
|
318
|
+
if len(outputs) > max_len:
|
|
319
|
+
outputs = outputs[:max_len] + f"(... too long, truncated to {max_len})"
|
|
320
|
+
|
|
321
|
+
result += "\n\n=== Task Outputs (Auto) ===\n"
|
|
322
|
+
result += outputs
|
|
323
|
+
result += "\n\nPlease synthesize all task outputs into the final answer."
|
|
324
|
+
self._context.set_variable(injected_var, True)
|
|
325
|
+
elif not await registry.is_all_done():
|
|
326
|
+
# Suggest using _wait tool if tasks are still running
|
|
327
|
+
result += "\n\n💡 Tip: Some tasks are still running. If you have no other tasks to perform, use the `_wait(seconds=10)` tool to wait for progress instead of polling `_check_progress` repeatedly."
|
|
328
|
+
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
async def _get_task_output(self, task_id: str = "all", **kwargs) -> str:
|
|
332
|
+
"""Get the execution results of completed subtasks.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
task_id: The ID of the task to retrieve. Defaults to "all", which returns
|
|
336
|
+
a summary of status and outputs for all tasks.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
The output of the task or a compiled summary.
|
|
340
|
+
"""
|
|
341
|
+
if not self._context.is_plan_enabled():
|
|
342
|
+
raise RuntimeError("Plan is not enabled")
|
|
343
|
+
|
|
344
|
+
registry = self._context.task_registry
|
|
345
|
+
|
|
346
|
+
if task_id == "all":
|
|
347
|
+
all_tasks = await registry.get_all_tasks()
|
|
348
|
+
if not all_tasks:
|
|
349
|
+
return "No tasks found"
|
|
350
|
+
|
|
351
|
+
outputs = []
|
|
352
|
+
for task in all_tasks:
|
|
353
|
+
if task.status == TaskStatus.COMPLETED:
|
|
354
|
+
output = task.answer or "(no output)"
|
|
355
|
+
outputs.append(f"=== {task.id}: {task.name} ===\n{output}\n")
|
|
356
|
+
elif task.status == TaskStatus.RUNNING:
|
|
357
|
+
outputs.append(f"=== {task.id}: {task.name} ===\n[Still running]\n")
|
|
358
|
+
elif task.status == TaskStatus.FAILED:
|
|
359
|
+
error_msg = task.error or "Unknown error"
|
|
360
|
+
outputs.append(f"=== {task.id}: {task.name} ===\n[Failed: {error_msg}]\n")
|
|
361
|
+
else:
|
|
362
|
+
outputs.append(f"=== {task.id}: {task.name} ===\n[{task.status.value}]\n")
|
|
363
|
+
|
|
364
|
+
if not outputs:
|
|
365
|
+
return "No task outputs available"
|
|
366
|
+
return "\n".join(outputs)
|
|
367
|
+
|
|
368
|
+
else:
|
|
369
|
+
task = await registry.get_task(task_id)
|
|
370
|
+
if not task:
|
|
371
|
+
raise RuntimeError(f"Task '{task_id}' not found")
|
|
372
|
+
|
|
373
|
+
if task.status != TaskStatus.COMPLETED:
|
|
374
|
+
raise RuntimeError(f"Task '{task_id}' is not completed (status: {task.status.value})")
|
|
375
|
+
|
|
376
|
+
logger.debug(f"[_get_task_output] task_id={task_id}, answer type={type(task.answer)}, length={len(task.answer or '')}")
|
|
377
|
+
return task.answer or "(no output)"
|
|
378
|
+
|
|
379
|
+
async def _wait(self, seconds: float, **kwargs) -> str:
|
|
380
|
+
"""Wait for a specified time (can be interrupted by user).
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
seconds: Duration to wait in seconds
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Confirmation message
|
|
387
|
+
"""
|
|
388
|
+
for i in range(int(seconds)):
|
|
389
|
+
# Check user interrupt once per second
|
|
390
|
+
self._context.check_user_interrupt()
|
|
391
|
+
await asyncio.sleep(1)
|
|
392
|
+
|
|
393
|
+
return f"Waited {seconds}s"
|
|
394
|
+
|
|
395
|
+
async def _kill_task(self, task_id: str, **kwargs) -> str:
|
|
396
|
+
"""Terminate a running task.
|
|
397
|
+
|
|
398
|
+
This method only sends the cancellation signal to the asyncio task.
|
|
399
|
+
The task's exception handler (in _spawn_task's run_task) is responsible
|
|
400
|
+
for updating the registry status to prevent race conditions.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
task_id: Task identifier
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Confirmation or error message
|
|
407
|
+
"""
|
|
408
|
+
if not self._context.is_plan_enabled():
|
|
409
|
+
raise RuntimeError("Plan is not enabled")
|
|
410
|
+
|
|
411
|
+
registry = self._context.task_registry
|
|
412
|
+
|
|
413
|
+
# Use lock to safely check and cancel the task
|
|
414
|
+
async with registry._lock:
|
|
415
|
+
if task_id in registry.running_asyncio_tasks:
|
|
416
|
+
asyncio_task = registry.running_asyncio_tasks[task_id]
|
|
417
|
+
# Only send cancel signal; status update will be handled by the task's exception handler
|
|
418
|
+
asyncio_task.cancel()
|
|
419
|
+
else:
|
|
420
|
+
raise RuntimeError(f"Task '{task_id}' is not running")
|
|
421
|
+
|
|
422
|
+
# Note: Status update and cleanup are handled by the task's CancelledError handler
|
|
423
|
+
# in _spawn_task's run_task() to avoid race conditions.
|
|
424
|
+
# Yield control to allow the cancellation to propagate to the task.
|
|
425
|
+
await asyncio.sleep(0)
|
|
426
|
+
|
|
427
|
+
return f"Task '{task_id}' cancellation requested (status will update shortly)"
|
|
428
|
+
|
|
429
|
+
async def _retry_task(self, task_id: str, **kwargs) -> str:
|
|
430
|
+
"""Retry a failed task.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
task_id: Task identifier
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Confirmation or error message
|
|
437
|
+
"""
|
|
438
|
+
if not self._context.is_plan_enabled():
|
|
439
|
+
raise RuntimeError("Plan is not enabled")
|
|
440
|
+
|
|
441
|
+
registry = self._context.task_registry
|
|
442
|
+
task = await registry.get_task(task_id)
|
|
443
|
+
|
|
444
|
+
if not task:
|
|
445
|
+
raise RuntimeError(f"Task '{task_id}' not found")
|
|
446
|
+
|
|
447
|
+
if task.status != TaskStatus.FAILED:
|
|
448
|
+
raise RuntimeError(f"Task '{task_id}' cannot be retried (status: {task.status.value})")
|
|
449
|
+
|
|
450
|
+
# Reset status and restart
|
|
451
|
+
await registry.update_status(task_id, TaskStatus.PENDING, error=None)
|
|
452
|
+
await self._spawn_task(task_id)
|
|
453
|
+
|
|
454
|
+
return f"Task '{task_id}' restarted"
|
|
455
|
+
|
|
456
|
+
def _createSkills(self) -> List[SkillFunction]:
|
|
457
|
+
"""Create skill functions for plan orchestration."""
|
|
458
|
+
return [
|
|
459
|
+
SkillFunction(self._plan_tasks),
|
|
460
|
+
SkillFunction(self._check_progress),
|
|
461
|
+
SkillFunction(self._get_task_output),
|
|
462
|
+
SkillFunction(self._wait),
|
|
463
|
+
SkillFunction(self._kill_task),
|
|
464
|
+
SkillFunction(self._retry_task),
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
# ===== Internal helpers =====
|
|
468
|
+
|
|
469
|
+
def _get_filtered_subtask_tools(self) -> Optional[List[str]]:
|
|
470
|
+
"""Get filtered tool list for subtasks by removing PlanSkillkit tools.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
List of tool names (strings) or None if parent didn't specify tools
|
|
474
|
+
"""
|
|
475
|
+
if self._parent_skills is None:
|
|
476
|
+
# Parent didn't specify tools - let subtask inherit from COWContext
|
|
477
|
+
logger.debug("No parent skills configured, subtasks will inherit from COWContext")
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
# Filter out PlanSkillkit tools AND the skillkit name itself
|
|
481
|
+
# (since PlanSkillkit is excluded from subtask contexts)
|
|
482
|
+
excluded_patterns = self.EXCLUDED_SUBTASK_TOOLS | {"plan_skillkit"}
|
|
483
|
+
|
|
484
|
+
filtered = [
|
|
485
|
+
tool for tool in self._parent_skills
|
|
486
|
+
if tool not in excluded_patterns
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
excluded_found = excluded_patterns & set(self._parent_skills)
|
|
490
|
+
logger.debug(
|
|
491
|
+
f"[PlanSkillkit] Filtered subtask tools: {filtered} (excluded: {excluded_found})"
|
|
492
|
+
)
|
|
493
|
+
return filtered if filtered else None
|
|
494
|
+
|
|
495
|
+
async def _spawn_task(self, task_id: str):
|
|
496
|
+
"""Spawn a single subtask using ExploreBlock with a COW Context.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
task_id: Task identifier
|
|
500
|
+
"""
|
|
501
|
+
from dolphin.core.code_block.explore_block import ExploreBlock
|
|
502
|
+
|
|
503
|
+
registry = self._context.task_registry
|
|
504
|
+
task = await registry.get_task(task_id)
|
|
505
|
+
|
|
506
|
+
# Capture plan_id at spawn time to prevent cleanup race conditions
|
|
507
|
+
spawn_plan_id = self._context.get_plan_id()
|
|
508
|
+
|
|
509
|
+
# Filter parent skills to exclude PlanSkillkit tools
|
|
510
|
+
subtask_tools = self._get_filtered_subtask_tools()
|
|
511
|
+
|
|
512
|
+
explore_block_content = self._build_subtask_explore_block_content(
|
|
513
|
+
task.prompt,
|
|
514
|
+
tools=subtask_tools
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
async def run_task():
|
|
518
|
+
try:
|
|
519
|
+
# Transition to RUNNING
|
|
520
|
+
await registry.update_status(task_id, TaskStatus.RUNNING, started_at=time.time())
|
|
521
|
+
|
|
522
|
+
self._context.write_output("plan_task_update", {
|
|
523
|
+
"plan_id": self._context.get_plan_id(),
|
|
524
|
+
"task_id": task_id,
|
|
525
|
+
"status": "running",
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
# Create COW context
|
|
529
|
+
# Note: Subtask variable writes are isolated in this child_context.
|
|
530
|
+
# By design, they are NOT merged back to the parent to prevent side effects
|
|
531
|
+
# and maintain strict task isolation. Each task should communicate its results
|
|
532
|
+
# via its 'answer' output.
|
|
533
|
+
child_context = self._context.fork(task_id)
|
|
534
|
+
|
|
535
|
+
# Execute via ExploreBlock
|
|
536
|
+
explore = ExploreBlock(context=child_context)
|
|
537
|
+
result = None
|
|
538
|
+
async for output in explore.execute(content=explore_block_content):
|
|
539
|
+
result = output
|
|
540
|
+
# Stream output to UI
|
|
541
|
+
if isinstance(output, dict):
|
|
542
|
+
# Extract answer and think deltas if available
|
|
543
|
+
answer_chunk = output.get("answer", "")
|
|
544
|
+
think_chunk = output.get("think", "")
|
|
545
|
+
|
|
546
|
+
if answer_chunk or think_chunk:
|
|
547
|
+
self._context.write_output("plan_task_output", {
|
|
548
|
+
"plan_id": self._context.get_plan_id(),
|
|
549
|
+
"task_id": task_id,
|
|
550
|
+
"answer": answer_chunk,
|
|
551
|
+
"think": think_chunk,
|
|
552
|
+
"stream_mode": "delta",
|
|
553
|
+
"is_final": False,
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
# Extract final output components
|
|
557
|
+
output_dict = self._extract_output_dict(result)
|
|
558
|
+
|
|
559
|
+
# Clear COW context's local changes to release memory
|
|
560
|
+
# This prevents memory bloat from intermediate variables in long-running tasks
|
|
561
|
+
if hasattr(child_context, 'clear_local_changes'):
|
|
562
|
+
child_context.clear_local_changes()
|
|
563
|
+
|
|
564
|
+
# Transition to COMPLETED
|
|
565
|
+
duration = time.time() - task.started_at
|
|
566
|
+
await registry.update_status(
|
|
567
|
+
task_id,
|
|
568
|
+
TaskStatus.COMPLETED,
|
|
569
|
+
answer=output_dict.get("answer"),
|
|
570
|
+
think=output_dict.get("think"),
|
|
571
|
+
block_answer=output_dict.get("block_answer"),
|
|
572
|
+
duration=duration
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
self._context.write_output("plan_task_update", {
|
|
576
|
+
"plan_id": self._context.get_plan_id(),
|
|
577
|
+
"task_id": task_id,
|
|
578
|
+
"status": "completed",
|
|
579
|
+
"duration_ms": duration * 1000,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
# Sequential mode: start next ready task
|
|
583
|
+
# Note: This is only done on success. In case of failure or cancellation,
|
|
584
|
+
# we rely on the orchestrator calling _check_progress(), which has a
|
|
585
|
+
# recovery mechanism to kickstart the next task if the chain is stalled.
|
|
586
|
+
if registry.exec_mode == PlanExecMode.SEQUENTIAL:
|
|
587
|
+
ready = await self._select_ready_tasks(limit=1)
|
|
588
|
+
if ready:
|
|
589
|
+
await self._spawn_task(ready[0])
|
|
590
|
+
|
|
591
|
+
except asyncio.CancelledError:
|
|
592
|
+
task_obj = await registry.get_task(task_id)
|
|
593
|
+
started_at = task_obj.started_at if task_obj else None
|
|
594
|
+
duration = (time.time() - started_at) if started_at else None
|
|
595
|
+
await registry.update_status(task_id, TaskStatus.CANCELLED, duration=duration)
|
|
596
|
+
|
|
597
|
+
payload = {
|
|
598
|
+
"plan_id": self._context.get_plan_id(),
|
|
599
|
+
"task_id": task_id,
|
|
600
|
+
"status": "cancelled",
|
|
601
|
+
}
|
|
602
|
+
if duration is not None:
|
|
603
|
+
payload["duration_ms"] = duration * 1000
|
|
604
|
+
|
|
605
|
+
self._context.write_output("plan_task_update", payload)
|
|
606
|
+
raise
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.error(f"Task {task_id} failed: {e}", exc_info=True)
|
|
609
|
+
await registry.update_status(task_id, TaskStatus.FAILED, error=str(e))
|
|
610
|
+
|
|
611
|
+
self._context.write_output("plan_task_update", {
|
|
612
|
+
"plan_id": self._context.get_plan_id(),
|
|
613
|
+
"task_id": task_id,
|
|
614
|
+
"status": "failed",
|
|
615
|
+
"error": str(e),
|
|
616
|
+
})
|
|
617
|
+
finally:
|
|
618
|
+
# Idempotent cleanup with plan_id check to prevent race conditions
|
|
619
|
+
# Only clean up if the plan hasn't been reset/replaced
|
|
620
|
+
# Use lock to prevent race conditions with _kill_task and _check_progress reconciliation
|
|
621
|
+
current_plan_id = self._context.get_plan_id()
|
|
622
|
+
if current_plan_id == spawn_plan_id:
|
|
623
|
+
async with registry._lock:
|
|
624
|
+
registry.running_asyncio_tasks.pop(task_id, None)
|
|
625
|
+
else:
|
|
626
|
+
logger.debug(
|
|
627
|
+
f"Skipping cleanup for task {task_id}: plan_id mismatch "
|
|
628
|
+
f"(spawn={spawn_plan_id}, current={current_plan_id})"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Start asyncio task
|
|
632
|
+
# Use lock to ensure atomicity when adding to running_asyncio_tasks
|
|
633
|
+
asyncio_task = asyncio.create_task(run_task())
|
|
634
|
+
async with registry._lock:
|
|
635
|
+
registry.running_asyncio_tasks[task_id] = asyncio_task
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _build_subtask_explore_block_content(prompt: str, tools: Optional[List[str]] = None) -> str:
|
|
639
|
+
"""Build a valid DPH explore block string for subtask execution.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
prompt: Task description/instructions
|
|
643
|
+
tools: List of tool names to include (if None, subtask inherits all parent skills)
|
|
644
|
+
|
|
645
|
+
Subtask tool inheritance strategy:
|
|
646
|
+
- Subtasks inherit parent Agent's tools configuration
|
|
647
|
+
- PlanSkillkit tools are automatically excluded to prevent infinite recursion
|
|
648
|
+
- If parent didn't specify tools, subtask inherits from COWContext (all skills)
|
|
649
|
+
|
|
650
|
+
Excluded tools (defined in EXCLUDED_SUBTASK_TOOLS):
|
|
651
|
+
- _plan_tasks, _check_progress, _get_task_output, _wait, _kill_task, _retry_task
|
|
652
|
+
|
|
653
|
+
This design allows parent Agent to control subtask capabilities naturally.
|
|
654
|
+
|
|
655
|
+
Example:
|
|
656
|
+
- Parent: /explore/(tools=[_search, _plan_tasks, _bash, _cog_think])
|
|
657
|
+
- Subtask: /explore/(tools=[_search, _bash, _cog_think]) # plan tools filtered out
|
|
658
|
+
"""
|
|
659
|
+
prompt = (prompt or "").strip()
|
|
660
|
+
# BasicCodeBlock.parse_block_content requires an assign suffix ("-> var").
|
|
661
|
+
|
|
662
|
+
if tools is not None:
|
|
663
|
+
# Use quoted tool names to avoid parsing ambiguity and support special characters.
|
|
664
|
+
tools_str = ", ".join(json.dumps(tool) for tool in tools)
|
|
665
|
+
return f"/explore/(tools=[{tools_str}]) {prompt} -> result"
|
|
666
|
+
else:
|
|
667
|
+
# Always include an empty params list to avoid ambiguity when prompt begins with "(".
|
|
668
|
+
# No tools specified - inherit from COWContext (already filtered)
|
|
669
|
+
return f"/explore/() {prompt} -> result"
|
|
670
|
+
|
|
671
|
+
async def _select_ready_tasks(self, limit: int) -> List[str]:
|
|
672
|
+
"""Select runnable tasks based on dependency readiness.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
limit: Maximum number of tasks to return
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
List of task IDs
|
|
679
|
+
"""
|
|
680
|
+
registry = self._context.task_registry
|
|
681
|
+
ready_tasks = await registry.get_ready_tasks()
|
|
682
|
+
return [t.id for t in ready_tasks][:limit]
|
|
683
|
+
|
|
684
|
+
def _validate_tasks(self, tasks: List[Dict[str, Any]]) -> List[str]:
|
|
685
|
+
"""Validate task list.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
tasks: List of task dictionaries
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
List of error messages (empty if valid)
|
|
692
|
+
"""
|
|
693
|
+
errors = []
|
|
694
|
+
|
|
695
|
+
if not tasks:
|
|
696
|
+
errors.append("Empty task list")
|
|
697
|
+
return errors
|
|
698
|
+
|
|
699
|
+
seen_ids = set()
|
|
700
|
+
for i, task in enumerate(tasks):
|
|
701
|
+
if not isinstance(task, dict):
|
|
702
|
+
errors.append(f"Task {i} is not a dictionary")
|
|
703
|
+
continue
|
|
704
|
+
|
|
705
|
+
task_id = task.get("id")
|
|
706
|
+
if not task_id:
|
|
707
|
+
errors.append(f"Task {i} missing 'id' field")
|
|
708
|
+
elif task_id in seen_ids:
|
|
709
|
+
errors.append(f"Duplicate task ID: {task_id}")
|
|
710
|
+
else:
|
|
711
|
+
seen_ids.add(task_id)
|
|
712
|
+
|
|
713
|
+
if not task.get("name"):
|
|
714
|
+
errors.append(f"Task {i} ({task_id}) missing 'name' field")
|
|
715
|
+
|
|
716
|
+
if not task.get("prompt"):
|
|
717
|
+
errors.append(f"Task {i} ({task_id}) missing 'prompt' field")
|
|
718
|
+
|
|
719
|
+
return errors
|
|
720
|
+
|
|
721
|
+
def _extract_output(self, result: Any) -> str:
|
|
722
|
+
"""Extract primary answer text from results (for terminal logic)."""
|
|
723
|
+
output_dict = self._extract_output_dict(result)
|
|
724
|
+
return output_dict.get("answer") or ""
|
|
725
|
+
|
|
726
|
+
def _extract_output_dict(self, result: Any) -> Dict[str, str]:
|
|
727
|
+
"""Extract multi-field output from ExploreBlock result.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
result: ExploreBlock execution result
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Dict with answer, think, and block_answer
|
|
734
|
+
"""
|
|
735
|
+
logger.debug(f"[_extract_output_dict] result type={type(result)}, value={repr(result)[:500] if result else None}")
|
|
736
|
+
|
|
737
|
+
if isinstance(result, dict):
|
|
738
|
+
# Capture all relevant fields
|
|
739
|
+
return {
|
|
740
|
+
"answer": result.get("answer", "") or result.get("output", "") or result.get("result", "") or "",
|
|
741
|
+
"think": result.get("think", "") or "",
|
|
742
|
+
"block_answer": result.get("block_answer", "") or "",
|
|
743
|
+
}
|
|
744
|
+
elif isinstance(result, str):
|
|
745
|
+
return {"answer": result, "think": "", "block_answer": ""}
|
|
746
|
+
else:
|
|
747
|
+
return {"answer": str(result) if result is not None else "", "think": "", "block_answer": ""}
|
|
748
|
+
|
|
749
|
+
@staticmethod
|
|
750
|
+
def should_exclude_from_subtask() -> bool:
|
|
751
|
+
"""Mark this skillkit for exclusion from subtask contexts.
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
True to exclude from subtasks
|
|
755
|
+
"""
|
|
756
|
+
return True
|