kweaver-dolphin 0.1.0__py3-none-any.whl → 0.2.1__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 +35 -17
- dolphin/cli/utils/helpers.py +4 -4
- dolphin/core/agent/base_agent.py +70 -7
- dolphin/core/code_block/basic_code_block.py +162 -26
- dolphin/core/code_block/explore_block.py +438 -35
- dolphin/core/code_block/explore_block_v2.py +105 -16
- dolphin/core/code_block/explore_strategy.py +3 -1
- dolphin/core/code_block/judge_block.py +41 -8
- dolphin/core/code_block/skill_call_deduplicator.py +32 -10
- dolphin/core/code_block/tool_block.py +69 -23
- dolphin/core/common/constants.py +25 -1
- dolphin/core/config/global_config.py +35 -0
- dolphin/core/context/context.py +175 -9
- dolphin/core/context/cow_context.py +392 -0
- dolphin/core/executor/dolphin_executor.py +9 -0
- dolphin/core/flags/definitions.py +2 -2
- dolphin/core/llm/llm.py +2 -3
- dolphin/core/llm/llm_client.py +1 -0
- dolphin/core/runtime/runtime_instance.py +31 -0
- dolphin/core/skill/context_retention.py +3 -3
- dolphin/core/task_registry.py +404 -0
- dolphin/core/utils/cache_kv.py +70 -8
- dolphin/core/utils/tools.py +2 -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
- dolphin/sdk/skill/traditional_toolkit.py +4 -0
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/METADATA +1 -1
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/RECORD +36 -34
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/WHEEL +1 -1
- kweaver_dolphin-0.2.1.dist-info/entry_points.txt +15 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +0 -452
- kweaver_dolphin-0.1.0.dist-info/entry_points.txt +0 -27
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/licenses/LICENSE.txt +0 -0
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/top_level.txt +0 -0
dolphin/core/context/context.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import os
|
|
3
3
|
import json
|
|
4
|
+
import time
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
from typing import Dict, List, Optional, Any, Union, TYPE_CHECKING
|
|
6
7
|
|
|
@@ -104,6 +105,18 @@ class Context:
|
|
|
104
105
|
# User interrupt event (injected by Agent layer for cooperative cancellation)
|
|
105
106
|
self._interrupt_event: Optional[asyncio.Event] = None
|
|
106
107
|
|
|
108
|
+
# Plan Mode support (unified architecture)
|
|
109
|
+
self._plan_enabled: bool = False
|
|
110
|
+
self._plan_id: Optional[str] = None
|
|
111
|
+
self.task_registry: Optional["TaskRegistry"] = None
|
|
112
|
+
|
|
113
|
+
# Nesting level tracking (to prevent infinite recursion in plan mode)
|
|
114
|
+
# Incremented when fork() is called
|
|
115
|
+
self._nesting_level: int = 0
|
|
116
|
+
|
|
117
|
+
# Output event buffer (for UI/SDK consumption)
|
|
118
|
+
self._output_events: List[Dict[str, Any]] = []
|
|
119
|
+
|
|
107
120
|
def set_skillkit_hook(self, skillkit_hook: "SkillkitHook"):
|
|
108
121
|
"""Set skillkit_hook"""
|
|
109
122
|
self.skillkit_hook = skillkit_hook
|
|
@@ -471,7 +484,7 @@ class Context:
|
|
|
471
484
|
return result_skillset
|
|
472
485
|
|
|
473
486
|
def _inject_detail_skill_if_needed(self, skillset: Skillset):
|
|
474
|
-
"""Auto-inject
|
|
487
|
+
"""Auto-inject _get_cached_result_detail if any skill uses omitting modes (SUMMARY/REFERENCE)"""
|
|
475
488
|
try:
|
|
476
489
|
from dolphin.core.skill.context_retention import ContextRetentionMode
|
|
477
490
|
from dolphin.lib.skillkits.system_skillkit import SystemFunctions
|
|
@@ -494,16 +507,16 @@ class Context:
|
|
|
494
507
|
if should_inject:
|
|
495
508
|
# Check if already exists in skillset
|
|
496
509
|
has_detail_skill = any(
|
|
497
|
-
"
|
|
510
|
+
"_get_cached_result_detail" in s.get_function_name()
|
|
498
511
|
for s in skills
|
|
499
512
|
)
|
|
500
513
|
|
|
501
514
|
if not has_detail_skill:
|
|
502
515
|
# Try to get the skill from SystemFunctions
|
|
503
516
|
# We try different name variations just in case
|
|
504
|
-
detail_skill = SystemFunctions.getSkill("
|
|
517
|
+
detail_skill = SystemFunctions.getSkill("_get_cached_result_detail")
|
|
505
518
|
if not detail_skill:
|
|
506
|
-
detail_skill = SystemFunctions.getSkill("system_functions.
|
|
519
|
+
detail_skill = SystemFunctions.getSkill("system_functions._get_cached_result_detail")
|
|
507
520
|
|
|
508
521
|
# If still not found (e.g. SystemFunctions not initialized with it), we can pick it manually if possible
|
|
509
522
|
# But typically SystemFunctions singleton has it.
|
|
@@ -512,7 +525,7 @@ class Context:
|
|
|
512
525
|
else:
|
|
513
526
|
# Fallback: look through SystemFunctions.getSkills() manually
|
|
514
527
|
for s in SystemFunctions.getSkills():
|
|
515
|
-
if "
|
|
528
|
+
if "_get_cached_result_detail" in s.get_function_name():
|
|
516
529
|
skillset.addSkill(s)
|
|
517
530
|
break
|
|
518
531
|
|
|
@@ -969,10 +982,13 @@ class Context:
|
|
|
969
982
|
if bucket is None:
|
|
970
983
|
bucket = BuildInBucket.SCRATCHPAD.value
|
|
971
984
|
|
|
972
|
-
#
|
|
973
|
-
self.context_manager.
|
|
974
|
-
|
|
975
|
-
|
|
985
|
+
# Replace bucket content (or create if not exists)
|
|
986
|
+
if self.context_manager.has_bucket(bucket):
|
|
987
|
+
# Use replace_bucket_content to directly replace existing bucket
|
|
988
|
+
self.context_manager.replace_bucket_content(bucket, messages)
|
|
989
|
+
else:
|
|
990
|
+
# Create new bucket if it doesn't exist
|
|
991
|
+
self.context_manager.add_bucket(bucket, messages)
|
|
976
992
|
|
|
977
993
|
# Mark as dirty
|
|
978
994
|
self.messages_dirty = True
|
|
@@ -1422,6 +1438,10 @@ class Context:
|
|
|
1422
1438
|
"session_id": self.session_id,
|
|
1423
1439
|
"cur_agent": self.cur_agent.getName() if self.cur_agent else None,
|
|
1424
1440
|
"max_answer_len": self.max_answer_len,
|
|
1441
|
+
"plan_enabled": self._plan_enabled,
|
|
1442
|
+
"plan_id": self._plan_id,
|
|
1443
|
+
"task_registry": self.task_registry.to_dict() if self.task_registry else None,
|
|
1444
|
+
"nesting_level": self._nesting_level,
|
|
1425
1445
|
}
|
|
1426
1446
|
|
|
1427
1447
|
# Export skill set status
|
|
@@ -1519,6 +1539,19 @@ class Context:
|
|
|
1519
1539
|
"max_answer_len", MAX_ANSWER_CONTENT_LENGTH
|
|
1520
1540
|
)
|
|
1521
1541
|
|
|
1542
|
+
# Restore plan state
|
|
1543
|
+
self._plan_enabled = runtime_state.get("plan_enabled", False)
|
|
1544
|
+
self._plan_id = runtime_state.get("plan_id")
|
|
1545
|
+
|
|
1546
|
+
task_registry_data = runtime_state.get("task_registry")
|
|
1547
|
+
if task_registry_data:
|
|
1548
|
+
from dolphin.core.task_registry import TaskRegistry
|
|
1549
|
+
self.task_registry = TaskRegistry.from_dict(task_registry_data)
|
|
1550
|
+
else:
|
|
1551
|
+
self.task_registry = None
|
|
1552
|
+
|
|
1553
|
+
self._nesting_level = runtime_state.get("nesting_level", 0)
|
|
1554
|
+
|
|
1522
1555
|
# Resume the current agent (to be handled in the calling function above)
|
|
1523
1556
|
# The restoration of self.cur_agent requires external coordination
|
|
1524
1557
|
|
|
@@ -1578,3 +1611,136 @@ class Context:
|
|
|
1578
1611
|
"""Clear the interrupt status (called when resuming execution)."""
|
|
1579
1612
|
if self._interrupt_event is not None:
|
|
1580
1613
|
self._interrupt_event.clear()
|
|
1614
|
+
|
|
1615
|
+
# === Output Events API ===
|
|
1616
|
+
|
|
1617
|
+
def write_output(self, event_type: "str | OutputEventType", data: Dict[str, Any]) -> None:
|
|
1618
|
+
"""Record an output event for UI/SDK consumers.
|
|
1619
|
+
|
|
1620
|
+
Args:
|
|
1621
|
+
event_type: Event type (OutputEventType enum or string for backward compatibility)
|
|
1622
|
+
data: Event payload data
|
|
1623
|
+
|
|
1624
|
+
Notes:
|
|
1625
|
+
- This is an in-memory buffer only (process-local).
|
|
1626
|
+
- Consumers can call drain_output_events() to fetch and clear.
|
|
1627
|
+
- Prefer using OutputEventType enum for type safety.
|
|
1628
|
+
"""
|
|
1629
|
+
from dolphin.core.task_registry import OutputEventType
|
|
1630
|
+
|
|
1631
|
+
# Convert enum to string for backward compatibility
|
|
1632
|
+
event_type_str = event_type.value if isinstance(event_type, OutputEventType) else event_type
|
|
1633
|
+
|
|
1634
|
+
event = {
|
|
1635
|
+
"event_type": event_type_str,
|
|
1636
|
+
"data": data,
|
|
1637
|
+
"timestamp_ms": int(time.time() * 1000),
|
|
1638
|
+
}
|
|
1639
|
+
self._output_events.append(event)
|
|
1640
|
+
|
|
1641
|
+
def drain_output_events(self) -> List[Dict[str, Any]]:
|
|
1642
|
+
"""Drain and clear buffered output events."""
|
|
1643
|
+
events = self._output_events
|
|
1644
|
+
self._output_events = []
|
|
1645
|
+
return events
|
|
1646
|
+
|
|
1647
|
+
# === Plan Mode API ===
|
|
1648
|
+
|
|
1649
|
+
async def enable_plan(self, plan_id: Optional[str] = None) -> None:
|
|
1650
|
+
"""Enable plan mode (lazy initialization).
|
|
1651
|
+
|
|
1652
|
+
This method can be called multiple times for replan scenarios.
|
|
1653
|
+
|
|
1654
|
+
Args:
|
|
1655
|
+
plan_id: Optional plan identifier (auto-generated if not provided)
|
|
1656
|
+
|
|
1657
|
+
Behavior:
|
|
1658
|
+
- First call: Creates TaskRegistry
|
|
1659
|
+
- Subsequent calls (replan): Generates new plan_id, resets TaskRegistry
|
|
1660
|
+
"""
|
|
1661
|
+
import uuid
|
|
1662
|
+
from dolphin.core.task_registry import TaskRegistry
|
|
1663
|
+
|
|
1664
|
+
if not self._plan_enabled:
|
|
1665
|
+
self._plan_enabled = True
|
|
1666
|
+
self.task_registry = TaskRegistry()
|
|
1667
|
+
logger.info("Plan mode enabled")
|
|
1668
|
+
else:
|
|
1669
|
+
# Replan: cancel running tasks and reset registry
|
|
1670
|
+
if self.task_registry:
|
|
1671
|
+
cancelled = await self.task_registry.cancel_all_running()
|
|
1672
|
+
if cancelled > 0:
|
|
1673
|
+
logger.info(f"Replan: cancelled {cancelled} running tasks")
|
|
1674
|
+
await self.task_registry.reset()
|
|
1675
|
+
logger.info("Plan mode replan triggered")
|
|
1676
|
+
|
|
1677
|
+
self._plan_id = plan_id or str(uuid.uuid4())
|
|
1678
|
+
logger.debug(f"Plan ID: {self._plan_id}")
|
|
1679
|
+
|
|
1680
|
+
async def disable_plan(self) -> None:
|
|
1681
|
+
"""Disable plan mode and cleanup resources."""
|
|
1682
|
+
if self.task_registry:
|
|
1683
|
+
await self.task_registry.cancel_all_running()
|
|
1684
|
+
self.task_registry = None
|
|
1685
|
+
self._plan_enabled = False
|
|
1686
|
+
self._plan_id = None
|
|
1687
|
+
logger.info("Plan mode disabled")
|
|
1688
|
+
|
|
1689
|
+
def is_plan_enabled(self) -> bool:
|
|
1690
|
+
"""Check if plan mode is enabled.
|
|
1691
|
+
|
|
1692
|
+
Returns:
|
|
1693
|
+
True if plan mode is enabled, False otherwise
|
|
1694
|
+
"""
|
|
1695
|
+
return self._plan_enabled
|
|
1696
|
+
|
|
1697
|
+
async def has_active_plan(self) -> bool:
|
|
1698
|
+
"""Check if there is an active plan with non-terminal tasks.
|
|
1699
|
+
|
|
1700
|
+
Returns:
|
|
1701
|
+
True if plan is enabled, has tasks, and not all tasks are done
|
|
1702
|
+
"""
|
|
1703
|
+
if not self._plan_enabled:
|
|
1704
|
+
return False
|
|
1705
|
+
if not self.task_registry or not await self.task_registry.has_tasks():
|
|
1706
|
+
return False
|
|
1707
|
+
return not await self.task_registry.is_all_done()
|
|
1708
|
+
|
|
1709
|
+
def get_plan_id(self) -> Optional[str]:
|
|
1710
|
+
"""Get the current plan ID.
|
|
1711
|
+
|
|
1712
|
+
Returns:
|
|
1713
|
+
Plan ID string, or None if plan mode is not enabled
|
|
1714
|
+
"""
|
|
1715
|
+
return self._plan_id
|
|
1716
|
+
|
|
1717
|
+
def fork(self, task_id: str) -> "COWContext":
|
|
1718
|
+
"""Create a Copy-On-Write child context for subtask isolation.
|
|
1719
|
+
|
|
1720
|
+
Args:
|
|
1721
|
+
task_id: Task identifier for the child context
|
|
1722
|
+
|
|
1723
|
+
Returns:
|
|
1724
|
+
COWContext instance that isolates writes
|
|
1725
|
+
|
|
1726
|
+
Raises:
|
|
1727
|
+
RuntimeError: If nesting level exceeds maximum allowed depth (3 levels)
|
|
1728
|
+
|
|
1729
|
+
Note:
|
|
1730
|
+
Maximum nesting depth is enforced to prevent memory overflow from
|
|
1731
|
+
deeply nested subtasks (e.g., subtask creates subtask creates subtask...).
|
|
1732
|
+
"""
|
|
1733
|
+
MAX_NESTING_LEVEL = 3
|
|
1734
|
+
|
|
1735
|
+
if self._nesting_level >= MAX_NESTING_LEVEL:
|
|
1736
|
+
raise RuntimeError(
|
|
1737
|
+
f"Maximum subtask nesting depth ({MAX_NESTING_LEVEL}) exceeded. "
|
|
1738
|
+
f"Current level: {self._nesting_level}. "
|
|
1739
|
+
"Deeply nested subtasks can cause memory overflow. "
|
|
1740
|
+
"Consider flattening your task structure or breaking it into sequential steps."
|
|
1741
|
+
)
|
|
1742
|
+
|
|
1743
|
+
from dolphin.core.context.cow_context import COWContext
|
|
1744
|
+
child = COWContext(self, task_id)
|
|
1745
|
+
child._nesting_level = self._nesting_level + 1
|
|
1746
|
+
return child
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Copy-On-Write Context for Subtask Isolation.
|
|
2
|
+
|
|
3
|
+
This module provides isolated context for subtasks in plan mode.
|
|
4
|
+
|
|
5
|
+
Logging conventions:
|
|
6
|
+
- DEBUG: Variable operations (set/delete/merge), initialization details
|
|
7
|
+
- INFO: Significant events (merge completion with summary)
|
|
8
|
+
- WARNING: Unexpected but recoverable situations
|
|
9
|
+
- ERROR: Critical failures requiring attention
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
from typing import Any, Dict, Optional, Set
|
|
14
|
+
|
|
15
|
+
from dolphin.core.common.types import SourceType, Var
|
|
16
|
+
from dolphin.core.context.context import Context
|
|
17
|
+
from dolphin.core.context_engineer.core.context_manager import ContextManager
|
|
18
|
+
from dolphin.core.logging.logger import get_logger
|
|
19
|
+
from dolphin.core.skill.skillset import Skillset
|
|
20
|
+
from dolphin.core.context.variable_pool import VariablePool
|
|
21
|
+
|
|
22
|
+
logger = get_logger("cow_context")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _TrackingVariablePool(VariablePool):
|
|
26
|
+
"""A VariablePool that tracks COWContext writes/deletes.
|
|
27
|
+
|
|
28
|
+
This ensures that callers who bypass COWContext.set_variable()/delete_variable()
|
|
29
|
+
and directly mutate `context.variable_pool` still participate in copy-on-write
|
|
30
|
+
semantics and can be merged back to the parent context.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, owner: "COWContext"):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._owner = owner
|
|
36
|
+
|
|
37
|
+
def set_var(self, name, value):
|
|
38
|
+
super().set_var(name, value)
|
|
39
|
+
tracked_value = value.value if isinstance(value, Var) else value
|
|
40
|
+
self._owner.writes[name] = tracked_value
|
|
41
|
+
self._owner.deletes.discard(name)
|
|
42
|
+
|
|
43
|
+
def set_var_output(
|
|
44
|
+
self, name, value, source_type: SourceType = SourceType.OTHER, skill_info=None
|
|
45
|
+
):
|
|
46
|
+
super().set_var_output(name, value, source_type=source_type, skill_info=skill_info)
|
|
47
|
+
# Track the user-facing value for merging behavior consistency.
|
|
48
|
+
self._owner.writes[name] = value
|
|
49
|
+
self._owner.deletes.discard(name)
|
|
50
|
+
|
|
51
|
+
def delete_var(self, name):
|
|
52
|
+
super().delete_var(name)
|
|
53
|
+
self._owner.deletes.add(name)
|
|
54
|
+
self._owner.writes.pop(name, None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class COWContext(Context):
|
|
58
|
+
"""Copy-On-Write Context for subtask isolation.
|
|
59
|
+
|
|
60
|
+
Contract:
|
|
61
|
+
- Variables: COW isolation (read-through + local writes).
|
|
62
|
+
- Messages: isolated (subtask-local).
|
|
63
|
+
- Interrupt/output: delegated to parent for unified control and UI routing.
|
|
64
|
+
- Output events are tagged with task_id for UI routing.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, parent: Context, task_id: str):
|
|
68
|
+
"""Initialize COWContext with parent and task ID.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
parent: Parent context to delegate to
|
|
72
|
+
task_id: Task identifier for event tagging
|
|
73
|
+
"""
|
|
74
|
+
super().__init__(
|
|
75
|
+
config=parent.config,
|
|
76
|
+
global_skills=parent.global_skills,
|
|
77
|
+
memory_manager=parent.memory_manager,
|
|
78
|
+
global_types=parent.global_types,
|
|
79
|
+
skillkit_hook=getattr(parent, "skillkit_hook", None),
|
|
80
|
+
context_manager=ContextManager(),
|
|
81
|
+
verbose=parent.verbose,
|
|
82
|
+
is_cli=parent.is_cli,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self.parent = parent
|
|
86
|
+
self.task_id = task_id
|
|
87
|
+
self.writes: Dict[str, Any] = {}
|
|
88
|
+
self.deletes: Set[str] = set()
|
|
89
|
+
|
|
90
|
+
# Create a new isolated VariablePool to prevent direct mutations to parent's pool.
|
|
91
|
+
# This ensures COW isolation even if code bypasses set_variable() and directly
|
|
92
|
+
# calls context.variable_pool.set_var().
|
|
93
|
+
self._parent_pool = parent.variable_pool # Keep reference for read-through
|
|
94
|
+
self.variable_pool = _TrackingVariablePool(self) # New empty pool for local writes (tracked)
|
|
95
|
+
|
|
96
|
+
# Isolate messages/buckets for subtask execution.
|
|
97
|
+
self.messages = {}
|
|
98
|
+
self.messages_dirty = True
|
|
99
|
+
|
|
100
|
+
# Keep IDs aligned for observability.
|
|
101
|
+
self.user_id = parent.user_id
|
|
102
|
+
self.session_id = parent.session_id
|
|
103
|
+
self.cur_agent = parent.cur_agent
|
|
104
|
+
|
|
105
|
+
# Share the interrupt event for cooperative cancellation.
|
|
106
|
+
self._interrupt_event = parent.get_interrupt_event()
|
|
107
|
+
|
|
108
|
+
# Subtasks must NOT be considered "plan-enabled" to avoid infinite loops.
|
|
109
|
+
self._plan_enabled = False
|
|
110
|
+
# Keep plan_id aligned for observability, while still disabling plan mode APIs.
|
|
111
|
+
self._plan_id = parent.get_plan_id()
|
|
112
|
+
self.task_registry = None
|
|
113
|
+
|
|
114
|
+
# Filter out orchestration-only tools (e.g., PlanSkillkit) from subtask toolset.
|
|
115
|
+
# Create a new isolated Skillset instead of referencing parent's skillkit directly
|
|
116
|
+
# to prevent permission escalation via context.skillkit.getSkills()
|
|
117
|
+
self._calc_all_skills()
|
|
118
|
+
self.all_skills = self._filter_subtask_skills(self.all_skills)
|
|
119
|
+
# Create a new Skillset that contains only filtered skills
|
|
120
|
+
# This ensures context.get_skill() and context.skillkit.getSkills() both respect filtering
|
|
121
|
+
filtered_skillkit = Skillset()
|
|
122
|
+
for skill in self.all_skills.getSkills():
|
|
123
|
+
filtered_skillkit.addSkill(skill)
|
|
124
|
+
self.skillkit = filtered_skillkit
|
|
125
|
+
|
|
126
|
+
# Inherit last-session configs where safe.
|
|
127
|
+
self._last_model_name = getattr(parent, "_last_model_name", None)
|
|
128
|
+
self._last_explore_mode = getattr(parent, "_last_explore_mode", None)
|
|
129
|
+
self._last_system_prompt = getattr(parent, "_last_system_prompt", None)
|
|
130
|
+
self._last_skills = None
|
|
131
|
+
|
|
132
|
+
logger.debug(f"COWContext initialized for task: {task_id}")
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _filter_subtask_skills(skillset: Skillset) -> Skillset:
|
|
136
|
+
"""Filter out skillkits that should not be exposed to subtasks."""
|
|
137
|
+
filtered = Skillset()
|
|
138
|
+
for skill in skillset.getSkills():
|
|
139
|
+
owner = None
|
|
140
|
+
if hasattr(skill, "get_owner_skillkit"):
|
|
141
|
+
owner = skill.get_owner_skillkit()
|
|
142
|
+
if owner is not None:
|
|
143
|
+
should_exclude = getattr(owner, "should_exclude_from_subtask", None)
|
|
144
|
+
if callable(should_exclude):
|
|
145
|
+
try:
|
|
146
|
+
if bool(should_exclude()):
|
|
147
|
+
continue
|
|
148
|
+
except Exception:
|
|
149
|
+
# Fail-open: do not exclude if the hook misbehaves.
|
|
150
|
+
pass
|
|
151
|
+
filtered.addSkill(skill)
|
|
152
|
+
return filtered
|
|
153
|
+
|
|
154
|
+
def get_variable(self, key: str, default_value: Any = None) -> Any:
|
|
155
|
+
"""Get a variable (check local layer first, then parent).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
key: Variable key
|
|
159
|
+
default_value: Default value if not found
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Variable value, or default_value if deleted or not found
|
|
163
|
+
|
|
164
|
+
Note:
|
|
165
|
+
Container types (list, dict, set) are deep-copied to prevent
|
|
166
|
+
accidental mutation of parent context's data through in-place operations
|
|
167
|
+
like list.append() or dict.update().
|
|
168
|
+
"""
|
|
169
|
+
if key in self.deletes:
|
|
170
|
+
return default_value
|
|
171
|
+
# Check local variable_pool first (for compatibility with direct variable_pool.set_var()).
|
|
172
|
+
# NOTE: Variable values can legitimately be None, so use a sentinel to distinguish
|
|
173
|
+
# "missing key" vs "stored None".
|
|
174
|
+
sentinel = object()
|
|
175
|
+
local_value = self.variable_pool.get_var_value(key, sentinel)
|
|
176
|
+
if local_value is not sentinel:
|
|
177
|
+
return self._safe_copy_if_mutable(local_value, key)
|
|
178
|
+
# Check writes dict (explicit set_variable() calls)
|
|
179
|
+
if key in self.writes:
|
|
180
|
+
return self._safe_copy_if_mutable(self.writes[key], key)
|
|
181
|
+
# Fall back to parent context (keeps compatibility logic such as flags).
|
|
182
|
+
parent_value = self.parent.get_var_value(key, default_value)
|
|
183
|
+
return self._safe_copy_if_mutable(parent_value, key)
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _safe_copy_if_mutable(value: Any, key: str) -> Any:
|
|
187
|
+
"""Deep copy mutable container types to prevent accidental mutation.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
value: Variable value
|
|
191
|
+
key: Variable key (for error reporting)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Deep copy if value is a mutable container (list/dict/set or custom object),
|
|
195
|
+
original value otherwise.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
TypeError: If a mutable object cannot be deep-copied, ensuring isolation.
|
|
199
|
+
|
|
200
|
+
Note:
|
|
201
|
+
Isolation is guaranteed by deepcopy. If an object is not deepcopy-able,
|
|
202
|
+
it cannot be safely used in a COWContext as mutation would leak to parent.
|
|
203
|
+
"""
|
|
204
|
+
if value is None:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
# Fast path for immutable primitives: return as-is
|
|
208
|
+
if isinstance(value, (str, int, float, bool, bytes)):
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
# Mutable types (containers and custom objects): try deepcopy
|
|
212
|
+
try:
|
|
213
|
+
return copy.deepcopy(value)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
# Item 3: Fail-fast for non-deepcopyable objects to ensure isolation.
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Isolation failure for variable '{key}': Object of type {type(value).__name__} is not deepcopyable. "
|
|
218
|
+
"Explicitly raising TypeError to prevent silent parent context corruption."
|
|
219
|
+
)
|
|
220
|
+
raise TypeError(
|
|
221
|
+
f"Cannot safely isolate variable '{key}': "
|
|
222
|
+
f"Object of type {type(value).__name__} is not deepcopyable. "
|
|
223
|
+
"Ensure task variables are serializable (e.g., data classes, dicts, primitives)."
|
|
224
|
+
) from e
|
|
225
|
+
|
|
226
|
+
def get_var_value(self, key: str, default_value: Any = None) -> Any:
|
|
227
|
+
"""Get variable value (alias for get_variable for Context compatibility).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
key: Variable key
|
|
231
|
+
default_value: Default value if not found
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Variable value
|
|
235
|
+
"""
|
|
236
|
+
return self.get_variable(key, default_value)
|
|
237
|
+
|
|
238
|
+
def set_variable(self, key: str, value: Any):
|
|
239
|
+
"""Set a variable in the local layer only (copy-on-write).
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
key: Variable key
|
|
243
|
+
value: Variable value
|
|
244
|
+
|
|
245
|
+
Note:
|
|
246
|
+
This does NOT modify parent's variable_pool, ensuring isolation.
|
|
247
|
+
Use merge_to_parent() to propagate changes after task completion.
|
|
248
|
+
Updates both self.writes (tracking) and self.variable_pool (isolation).
|
|
249
|
+
"""
|
|
250
|
+
self.writes[key] = value
|
|
251
|
+
self.deletes.discard(key)
|
|
252
|
+
# Update local variable_pool to catch both set_variable() and variable_pool.set_var() paths
|
|
253
|
+
self.variable_pool.set_var(key, value)
|
|
254
|
+
logger.debug(f"COWContext[{self.task_id}] set variable: {key}")
|
|
255
|
+
|
|
256
|
+
def delete_variable(self, key: str):
|
|
257
|
+
"""Delete a variable in the local layer (tombstone, copy-on-write).
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
key: Variable key to delete
|
|
261
|
+
|
|
262
|
+
Note:
|
|
263
|
+
This does NOT modify parent's variable_pool, ensuring isolation.
|
|
264
|
+
The delete is recorded as a tombstone in self.deletes.
|
|
265
|
+
"""
|
|
266
|
+
self.deletes.add(key)
|
|
267
|
+
self.writes.pop(key, None)
|
|
268
|
+
# Delete from local variable_pool to ensure isolation
|
|
269
|
+
self.variable_pool.delete_var(key)
|
|
270
|
+
logger.debug(f"COWContext[{self.task_id}] deleted variable: {key}")
|
|
271
|
+
|
|
272
|
+
def get_local_changes(self) -> Dict[str, Any]:
|
|
273
|
+
"""Return all local writes.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Dictionary of local variable writes
|
|
277
|
+
"""
|
|
278
|
+
return self.writes.copy()
|
|
279
|
+
|
|
280
|
+
def clear_local_changes(self):
|
|
281
|
+
"""Clear local writes and deletes to release memory.
|
|
282
|
+
|
|
283
|
+
Note:
|
|
284
|
+
This should be called after merge_to_parent() to free memory held by
|
|
285
|
+
intermediate variables. Useful for long-running subtasks that generate
|
|
286
|
+
many temporary variables (e.g., web scraping loops).
|
|
287
|
+
|
|
288
|
+
Warning:
|
|
289
|
+
Do NOT call this before merge_to_parent() unless you want to discard changes.
|
|
290
|
+
"""
|
|
291
|
+
self.writes.clear()
|
|
292
|
+
self.deletes.clear()
|
|
293
|
+
# Also clear the local variable pool
|
|
294
|
+
if hasattr(self.variable_pool, 'clear'):
|
|
295
|
+
self.variable_pool.clear()
|
|
296
|
+
logger.debug(f"COWContext[{self.task_id}] cleared local changes to release memory")
|
|
297
|
+
|
|
298
|
+
def merge_to_parent(self, keys: Optional[Set[str]] = None):
|
|
299
|
+
"""Merge local variable writes and deletes back to parent.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
keys: Optional set of keys to merge (if None, merge all)
|
|
303
|
+
|
|
304
|
+
Note:
|
|
305
|
+
Merges both writes (set operations) and deletes (delete operations).
|
|
306
|
+
"""
|
|
307
|
+
if keys:
|
|
308
|
+
# Selective merge
|
|
309
|
+
merged_count = 0
|
|
310
|
+
deleted_count = 0
|
|
311
|
+
for key in keys:
|
|
312
|
+
if key in self.deletes:
|
|
313
|
+
self.parent.delete_variable(key)
|
|
314
|
+
deleted_count += 1
|
|
315
|
+
elif key in self.writes:
|
|
316
|
+
self.parent.set_variable(key, self.writes[key])
|
|
317
|
+
merged_count += 1
|
|
318
|
+
logger.debug(
|
|
319
|
+
f"COWContext[{self.task_id}] merged {merged_count} variables, "
|
|
320
|
+
f"deleted {deleted_count} variables to parent"
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
# Full merge
|
|
324
|
+
# First apply deletes
|
|
325
|
+
for key in self.deletes:
|
|
326
|
+
self.parent.delete_variable(key)
|
|
327
|
+
# Then apply writes
|
|
328
|
+
for key, value in self.writes.items():
|
|
329
|
+
self.parent.set_variable(key, value)
|
|
330
|
+
logger.debug(
|
|
331
|
+
f"COWContext[{self.task_id}] merged {len(self.writes)} variables, "
|
|
332
|
+
f"deleted {len(self.deletes)} variables to parent"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def check_user_interrupt(self) -> None:
|
|
336
|
+
"""Delegate interrupt checks to parent."""
|
|
337
|
+
return self.parent.check_user_interrupt()
|
|
338
|
+
|
|
339
|
+
def write_output(self, event_type: str, data: Dict[str, Any]) -> None:
|
|
340
|
+
"""Tag output events with task_id and forward to parent."""
|
|
341
|
+
payload = dict(data)
|
|
342
|
+
payload.setdefault("task_id", self.task_id)
|
|
343
|
+
payload.setdefault("plan_id", self.parent.get_plan_id())
|
|
344
|
+
return self.parent.write_output(event_type, payload)
|
|
345
|
+
|
|
346
|
+
def get_plan_id(self) -> Optional[str]:
|
|
347
|
+
"""Expose parent plan_id for event tagging/observability."""
|
|
348
|
+
return self.parent.get_plan_id()
|
|
349
|
+
|
|
350
|
+
def enable_plan(self, plan_id: Optional[str] = None) -> None:
|
|
351
|
+
"""Prevent nested plan orchestration in subtasks.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
RuntimeError: Always raises to prevent nested planning.
|
|
355
|
+
|
|
356
|
+
Note:
|
|
357
|
+
This is intentionally a hard error rather than a soft warning.
|
|
358
|
+
Subtasks should execute their assigned work, not create sub-plans.
|
|
359
|
+
The error message is designed to help models understand the constraint.
|
|
360
|
+
"""
|
|
361
|
+
raise RuntimeError(
|
|
362
|
+
"Plan orchestration is not supported inside a subtask context. "
|
|
363
|
+
"Subtasks should focus on executing their assigned work directly. "
|
|
364
|
+
"If you need to break down the subtask further, return the breakdown "
|
|
365
|
+
"as part of your answer for the parent to orchestrate."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def __getattr__(self, name: str):
|
|
369
|
+
"""Delegate unknown attributes to parent with special handling for skillkit.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
name: Attribute name
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Attribute value from parent, or filtered skillkit for security
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
AttributeError: If attribute not found in parent
|
|
379
|
+
|
|
380
|
+
Note:
|
|
381
|
+
Special handling for 'skillkit' attribute to prevent subtasks from
|
|
382
|
+
bypassing PLAN_ORCHESTRATION_TOOLS filtering by directly accessing
|
|
383
|
+
parent's skillkit via attribute delegation.
|
|
384
|
+
"""
|
|
385
|
+
# Intercept skillkit access to return filtered version
|
|
386
|
+
if name == "skillkit":
|
|
387
|
+
# Return the filtered skillkit stored in __init__
|
|
388
|
+
# This prevents subtasks from accessing parent's unfiltered skillkit
|
|
389
|
+
return object.__getattribute__(self, name)
|
|
390
|
+
|
|
391
|
+
# Delegate all other attributes to parent
|
|
392
|
+
return getattr(self.parent, name)
|
|
@@ -484,6 +484,10 @@ class DolphinExecutor:
|
|
|
484
484
|
if isinstance(e, UserInterrupt):
|
|
485
485
|
frame.status = FrameStatus.WAITING_FOR_INTERVENTION
|
|
486
486
|
frame.wait_reason = WaitReason.USER_INTERRUPT
|
|
487
|
+
# *** FIX: Update block_pointer to current block before saving snapshot ***
|
|
488
|
+
# This ensures resume will continue from the interrupted block, not restart from beginning
|
|
489
|
+
frame.block_pointer = block_pointer
|
|
490
|
+
self.state_registry.update_frame(frame) # Save updated pointer
|
|
487
491
|
intervention_snapshot_id = self._save_frame_snapshot(frame)
|
|
488
492
|
frame.error = {
|
|
489
493
|
"error_type": "UserInterrupt",
|
|
@@ -504,12 +508,17 @@ class DolphinExecutor:
|
|
|
504
508
|
if isinstance(e, ToolInterrupt):
|
|
505
509
|
frame.status = FrameStatus.WAITING_FOR_INTERVENTION
|
|
506
510
|
frame.wait_reason = WaitReason.TOOL_REQUEST
|
|
511
|
+
# *** FIX: Update block_pointer to current block before saving snapshot ***
|
|
512
|
+
# This ensures resume will continue from the interrupted block, not restart from beginning
|
|
513
|
+
frame.block_pointer = block_pointer
|
|
514
|
+
self.state_registry.update_frame(frame) # Save updated pointer
|
|
507
515
|
intervention_snapshot_id = self._save_frame_snapshot(frame)
|
|
508
516
|
frame.error = {
|
|
509
517
|
"error_type": "ToolInterrupt",
|
|
510
518
|
"message": str(e),
|
|
511
519
|
"tool_name": getattr(e, "tool_name", ""),
|
|
512
520
|
"tool_args": getattr(e, "tool_args", []),
|
|
521
|
+
"tool_config": getattr(e, "tool_config", {}),
|
|
513
522
|
"at_block": block_pointer,
|
|
514
523
|
"intervention_snapshot_id": intervention_snapshot_id,
|
|
515
524
|
}
|
|
@@ -16,13 +16,13 @@ Scope: Executor, DebugController
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
# =========== Disable LLM Cache ===========
|
|
19
|
-
DISABLE_LLM_CACHE = "
|
|
19
|
+
DISABLE_LLM_CACHE = "llm_cache"
|
|
20
20
|
"""Disable LLM cache
|
|
21
21
|
Scope: LLMClient
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
24
|
# =========== Multiple Tool Calls Support ===========
|
|
25
|
-
ENABLE_PARALLEL_TOOL_CALLS = "
|
|
25
|
+
ENABLE_PARALLEL_TOOL_CALLS = "parallel_tool_calls"
|
|
26
26
|
"""Enable multiple tool calls support
|
|
27
27
|
|
|
28
28
|
When enabled:
|