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.
Files changed (38) hide show
  1. dolphin/cli/runner/runner.py +20 -0
  2. dolphin/cli/ui/console.py +35 -17
  3. dolphin/cli/utils/helpers.py +4 -4
  4. dolphin/core/agent/base_agent.py +70 -7
  5. dolphin/core/code_block/basic_code_block.py +162 -26
  6. dolphin/core/code_block/explore_block.py +438 -35
  7. dolphin/core/code_block/explore_block_v2.py +105 -16
  8. dolphin/core/code_block/explore_strategy.py +3 -1
  9. dolphin/core/code_block/judge_block.py +41 -8
  10. dolphin/core/code_block/skill_call_deduplicator.py +32 -10
  11. dolphin/core/code_block/tool_block.py +69 -23
  12. dolphin/core/common/constants.py +25 -1
  13. dolphin/core/config/global_config.py +35 -0
  14. dolphin/core/context/context.py +175 -9
  15. dolphin/core/context/cow_context.py +392 -0
  16. dolphin/core/executor/dolphin_executor.py +9 -0
  17. dolphin/core/flags/definitions.py +2 -2
  18. dolphin/core/llm/llm.py +2 -3
  19. dolphin/core/llm/llm_client.py +1 -0
  20. dolphin/core/runtime/runtime_instance.py +31 -0
  21. dolphin/core/skill/context_retention.py +3 -3
  22. dolphin/core/task_registry.py +404 -0
  23. dolphin/core/utils/cache_kv.py +70 -8
  24. dolphin/core/utils/tools.py +2 -0
  25. dolphin/lib/__init__.py +0 -2
  26. dolphin/lib/skillkits/__init__.py +2 -2
  27. dolphin/lib/skillkits/plan_skillkit.py +756 -0
  28. dolphin/lib/skillkits/system_skillkit.py +103 -30
  29. dolphin/sdk/skill/global_skills.py +43 -3
  30. dolphin/sdk/skill/traditional_toolkit.py +4 -0
  31. {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/METADATA +1 -1
  32. {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/RECORD +36 -34
  33. {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/WHEEL +1 -1
  34. kweaver_dolphin-0.2.1.dist-info/entry_points.txt +15 -0
  35. dolphin/lib/skillkits/plan_act_skillkit.py +0 -452
  36. kweaver_dolphin-0.1.0.dist-info/entry_points.txt +0 -27
  37. {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/licenses/LICENSE.txt +0 -0
  38. {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/top_level.txt +0 -0
@@ -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 _get_result_detail if any skill uses omitting modes (SUMMARY/REFERENCE)"""
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
- "_get_result_detail" in s.get_function_name()
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("_get_result_detail")
517
+ detail_skill = SystemFunctions.getSkill("_get_cached_result_detail")
505
518
  if not detail_skill:
506
- detail_skill = SystemFunctions.getSkill("system_functions._get_result_detail")
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 "_get_result_detail" in s.get_function_name():
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
- # Empty the current bucket
973
- self.context_manager.clear_bucket(bucket)
974
- # Add new messages
975
- self.context_manager.add_bucket(bucket, messages)
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 = "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 = "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: