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.
Files changed (32) hide show
  1. dolphin/cli/runner/runner.py +20 -0
  2. dolphin/cli/ui/console.py +29 -11
  3. dolphin/cli/utils/helpers.py +4 -4
  4. dolphin/core/agent/base_agent.py +2 -2
  5. dolphin/core/code_block/basic_code_block.py +140 -30
  6. dolphin/core/code_block/explore_block.py +353 -29
  7. dolphin/core/code_block/explore_block_v2.py +21 -17
  8. dolphin/core/code_block/explore_strategy.py +1 -0
  9. dolphin/core/code_block/judge_block.py +10 -1
  10. dolphin/core/code_block/skill_call_deduplicator.py +32 -10
  11. dolphin/core/code_block/tool_block.py +12 -3
  12. dolphin/core/common/constants.py +25 -1
  13. dolphin/core/config/global_config.py +35 -0
  14. dolphin/core/context/context.py +168 -5
  15. dolphin/core/context/cow_context.py +392 -0
  16. dolphin/core/flags/definitions.py +2 -2
  17. dolphin/core/runtime/runtime_instance.py +31 -0
  18. dolphin/core/skill/context_retention.py +3 -3
  19. dolphin/core/task_registry.py +404 -0
  20. dolphin/lib/__init__.py +0 -2
  21. dolphin/lib/skillkits/__init__.py +2 -2
  22. dolphin/lib/skillkits/plan_skillkit.py +756 -0
  23. dolphin/lib/skillkits/system_skillkit.py +103 -30
  24. dolphin/sdk/skill/global_skills.py +43 -3
  25. {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/METADATA +1 -1
  26. {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/RECORD +30 -28
  27. {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/WHEEL +1 -1
  28. kweaver_dolphin-0.2.2.dist-info/entry_points.txt +15 -0
  29. dolphin/lib/skillkits/plan_act_skillkit.py +0 -452
  30. kweaver_dolphin-0.2.0.dist-info/entry_points.txt +0 -27
  31. {kweaver_dolphin-0.2.0.dist-info → kweaver_dolphin-0.2.2.dist-info}/licenses/LICENSE.txt +0 -0
  32. {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