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,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)
@@ -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:
@@ -1,4 +1,5 @@
1
1
  from enum import Enum
2
+ import logging
2
3
  import time
3
4
  from typing import List, Optional, TYPE_CHECKING
4
5
  import uuid
@@ -6,6 +7,8 @@ import uuid
6
7
  from dolphin.core.common.enums import Messages, SkillInfo, Status, TypeStage
7
8
  from dolphin.core.common.constants import estimate_tokens_from_chars
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
  if TYPE_CHECKING:
10
13
  from dolphin.core.agent.base_agent import BaseAgent
11
14
  from dolphin.core.code_block.basic_code_block import BasicCodeBlock
@@ -292,6 +295,7 @@ class ProgressInstance(RuntimeInstance):
292
295
  self.context = context
293
296
  self.stages: List[StageInstance] = []
294
297
  self.flags = flags
298
+ self._next_stage_id: Optional[str] = None # ✅ NEW: for interrupt resume
295
299
 
296
300
  def add_stage(
297
301
  self,
@@ -306,6 +310,7 @@ class ProgressInstance(RuntimeInstance):
306
310
  input_content: str = "",
307
311
  input_messages: Optional[Messages] = None,
308
312
  interrupted: bool = False,
313
+ stage_id: Optional[str] = None, # ✅ NEW: support custom stage_id for resume
309
314
  ):
310
315
  pop_last_stage = False
311
316
  if len(self.stages) > 0 and self.stages[-1].llm_empty_answer():
@@ -325,6 +330,15 @@ class ProgressInstance(RuntimeInstance):
325
330
  interrupted=interrupted,
326
331
  flags=self.flags,
327
332
  )
333
+
334
+ # ✅ NEW: Override ID if custom stage_id is provided (for interrupt resume)
335
+ # Priority: explicit stage_id parameter > _next_stage_id temporary variable
336
+ if stage_id is not None:
337
+ stage_instance.id = stage_id
338
+ elif self._next_stage_id is not None:
339
+ stage_instance.id = self._next_stage_id
340
+ self._next_stage_id = None # Clear after use (one-time only)
341
+
328
342
  self.add_stage_instance(stage_instance, pop_last_stage)
329
343
 
330
344
  def add_stage_instance(
@@ -333,6 +347,7 @@ class ProgressInstance(RuntimeInstance):
333
347
  stage_instance.set_parent(self)
334
348
  if pop_last_stage:
335
349
  self.stages.pop()
350
+
336
351
  self.stages.append(stage_instance)
337
352
 
338
353
  # Register stage instance to runtime_graph if available
@@ -376,6 +391,22 @@ class ProgressInstance(RuntimeInstance):
376
391
  # Check if we need to create a new stage (when stage type changes)
377
392
  last_stage = self.stages[-1]
378
393
 
394
+ # *** FIX: If _next_stage_id is set and doesn't match last stage, create new stage ***
395
+ # This handles resume cases where we need to create a stage with a specific ID
396
+ if self._next_stage_id is not None and self._next_stage_id != last_stage.id:
397
+ logger.debug(f"_next_stage_id ({self._next_stage_id}) != last_stage.id ({last_stage.id}), creating new stage for resume")
398
+ self.add_stage(
399
+ stage=stage if stage is not None else last_stage.stage,
400
+ answer=answer,
401
+ think=think,
402
+ raw_output=raw_output,
403
+ status=status,
404
+ skill_info=skill_info,
405
+ block_answer=block_answer,
406
+ input_messages=input_messages,
407
+ )
408
+ return
409
+
379
410
  # Create new stage if stage type is changing (and it's not None)
380
411
  if stage is not None and stage != last_stage.stage:
381
412
  self.add_stage(
@@ -54,7 +54,7 @@ class SummaryContextStrategy(ContextRetentionStrategy):
54
54
  # Provide reference_id so LLM can fetch full content if needed
55
55
  ref_hint = ""
56
56
  if reference_id:
57
- ref_hint = f"\n[For full content, call _get_result_detail('{reference_id}')]"
57
+ ref_hint = f"\n[For full content, call _get_cached_result_detail('{reference_id}', scope='skill')]"
58
58
 
59
59
  omitted = len(result) - head_chars - tail_chars
60
60
  # Ensure we don't have negative omission if rounding puts us over
@@ -114,8 +114,8 @@ class ReferenceContextStrategy(ContextRetentionStrategy):
114
114
  hint = config.reference_hint or "Full result stored"
115
115
  return (f"[{hint}]\n"
116
116
  f"Original length: {len(result)} chars\n"
117
- f"Get full content: _get_result_detail('{reference_id}')\n"
118
- f"Get range: _get_result_detail('{reference_id}', offset=0, limit=2000)")
117
+ f"Get full content: _get_cached_result_detail('{reference_id}', scope='skill')\n"
118
+ f"Get range: _get_cached_result_detail('{reference_id}', scope='skill', offset=0, limit=2000)")
119
119
 
120
120
 
121
121
  # Strategy mapping