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
@@ -13,6 +13,8 @@ Design document: docs/design/architecture/explore_block_merge.md
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ from dolphin.core.task_registry import PlanExecMode
17
+
16
18
  import asyncio
17
19
  import json
18
20
  import traceback
@@ -42,6 +44,7 @@ from dolphin.core.common.enums import (
42
44
  )
43
45
  from dolphin.core.common.constants import (
44
46
  MAX_SKILL_CALL_TIMES,
47
+ MAX_PLAN_SILENT_ROUNDS,
45
48
  get_msg_duplicate_skill_call,
46
49
  )
47
50
  from dolphin.core.context.context import Context
@@ -60,6 +63,7 @@ from dolphin.core.code_block.explore_strategy import (
60
63
  from dolphin.core.code_block.skill_call_deduplicator import (
61
64
  DefaultSkillCallDeduplicator,
62
65
  )
66
+ from dolphin.core.skill.skill_matcher import SkillMatcher
63
67
  from dolphin.core import flags
64
68
 
65
69
  logger = get_logger("code_block.explore_block")
@@ -99,11 +103,6 @@ class ExploreBlock(BasicCodeBlock):
99
103
  self.mode = "tool_call"
100
104
  self.strategy = self._create_strategy()
101
105
 
102
- # By default, deduplication is controlled by the Block parameter (finally takes effect in execute/continue_exploration)
103
- self.enable_skill_deduplicator = getattr(
104
- self, "enable_skill_deduplicator", True
105
- )
106
-
107
106
  # State Variables
108
107
  self.times = 0
109
108
  self.should_stop_exploration = False
@@ -114,6 +113,14 @@ class ExploreBlock(BasicCodeBlock):
114
113
  # Incremented each time LLM returns tool calls (per batch, not per tool)
115
114
  self.session_tool_call_counter = 0
116
115
 
116
+ # Plan mode: guard against excessive "silent" rounds where the agent does not make
117
+ # meaningful progress on the active plan (e.g., repeatedly calling unrelated tools).
118
+ self.plan_silent_max_rounds: int = MAX_PLAN_SILENT_ROUNDS
119
+ self._plan_silent_rounds: int = 0
120
+ self._plan_last_signature: Optional[tuple] = None
121
+ self._last_tool_name: Optional[str] = None
122
+ self._current_round_tools: List[str] = [] # Track all tools called in current round
123
+
117
124
  # Hook-based verify attributes
118
125
  self.on_stop: Optional[HookConfig] = None
119
126
  self.current_attempt: int = 0
@@ -153,6 +160,23 @@ class ExploreBlock(BasicCodeBlock):
153
160
  self.mode = parsed_mode
154
161
  self.strategy = self._create_strategy()
155
162
 
163
+ # Handle exec_mode for plan orchestration
164
+ exec_mode_param = self.params.get("exec_mode")
165
+ if exec_mode_param:
166
+ # PlanExecMode.from_str handles validation and mapping (seq/para/etc.)
167
+ self.params["exec_mode"] = PlanExecMode.from_str(str(exec_mode_param))
168
+
169
+ # Optional: override plan silent rounds limit via DPH params.
170
+ silent_max = self.params.get("plan_silent_max_rounds")
171
+ if silent_max is not None:
172
+ try:
173
+ silent_max_int = int(silent_max)
174
+ if silent_max_int < 0:
175
+ raise ValueError("plan_silent_max_rounds must be >= 0")
176
+ self.plan_silent_max_rounds = silent_max_int
177
+ except Exception as e:
178
+ raise ValueError(f"Invalid plan_silent_max_rounds: {silent_max}") from e
179
+
156
180
  async def execute(
157
181
  self,
158
182
  content,
@@ -185,6 +209,8 @@ class ExploreBlock(BasicCodeBlock):
185
209
  # Save the current skills configuration to context, so it can be inherited during multi-turn conversations.
186
210
  if getattr(self, "skills", None):
187
211
  self.context.set_last_skills(self.skills)
212
+ # Inject context to skillkits that support it
213
+ self._inject_context_to_skillkits()
188
214
 
189
215
  # Save the current mode configuration to context for inheritance in multi-turn conversations.
190
216
  if getattr(self, "mode", None):
@@ -299,7 +325,7 @@ class ExploreBlock(BasicCodeBlock):
299
325
  has_add = self._write_output_var(ret, has_add)
300
326
  yield ret
301
327
 
302
- if not self._should_continue_explore():
328
+ if not await self._should_continue_explore():
303
329
  break
304
330
 
305
331
  def _write_output_var(
@@ -547,6 +573,11 @@ Please reconsider your approach and improve your answer based on the feedback ab
547
573
  f"length[{self.context.get_messages().length()}]"
548
574
  )
549
575
 
576
+ # Reset tool tracking at the start of each round to prevent stale state
577
+ # This ensures used_plan_tool detection is accurate in plan silent rounds guard
578
+ self._last_tool_name = None
579
+ self._current_round_tools = [] # Clear tools from previous round
580
+
550
581
  # Check if there is a tool call for interruption recovery
551
582
  if self._has_pending_tool_call():
552
583
  async for ret in self._handle_resumed_tool_call():
@@ -560,7 +591,6 @@ Please reconsider your approach and improve your answer based on the feedback ab
560
591
  intervention_tmp_key = "intervention_explore_block_vars"
561
592
  has_intervention = intervention_tmp_key in self.context.get_all_variables().keys()
562
593
  has_tool = "tool" in self.context.get_all_variables().keys()
563
- logger.debug(f"[DEBUG _has_pending_tool_call] has_intervention={has_intervention}, has_tool={has_tool}")
564
594
  return has_intervention and has_tool
565
595
 
566
596
  async def _handle_resumed_tool_call(self):
@@ -597,6 +627,9 @@ Please reconsider your approach and improve your answer based on the feedback ab
597
627
  raw_tool_args = input_dict["tool_args"]
598
628
  function_params_json = {arg["key"]: arg["value"] for arg in raw_tool_args}
599
629
 
630
+ # Get saved stage_id for resume
631
+ saved_stage_id = intervention_vars.get("stage_id")
632
+
600
633
  # *** FIX: Update the last tool_call message with modified parameters ***
601
634
  # This ensures LLM sees the actual parameters used, not the original ones
602
635
  messages = self.context.get_messages()
@@ -611,7 +644,6 @@ Please reconsider your approach and improve your answer based on the feedback ab
611
644
  # Update the arguments with modified parameters
612
645
  import json
613
646
  tool_call.function.arguments = json.dumps(function_params_json, ensure_ascii=False)
614
- logger.debug(f"[FIX] Updated tool_call arguments from original to modified: {function_params_json}")
615
647
 
616
648
  if self.recorder:
617
649
  self.recorder.update(
@@ -674,7 +706,7 @@ Please reconsider your approach and improve your answer based on the feedback ab
674
706
 
675
707
  # Normal execution (not skipped)
676
708
  try:
677
- props = {"intervention": False}
709
+ props = {"intervention": False, "saved_stage_id": saved_stage_id}
678
710
  have_answer = False
679
711
 
680
712
  async for resp in self.skill_run(
@@ -864,7 +896,11 @@ Please reconsider your approach and improve your answer based on the feedback ab
864
896
  tool_calls = [single] if single else []
865
897
 
866
898
  if not tool_calls:
867
- # No tool call detected: stop immediately
899
+ # No tool call detected: terminate normally
900
+ # Note: Plan mode continuation logic is handled in _should_continue_explore()
901
+ # which checks has_active_plan() and may inject guidance messages if needed.
902
+
903
+ # Normal termination
868
904
  # If there is pending content, merge before adding
869
905
  if self.pending_content:
870
906
  # Merge pending content and current content
@@ -877,9 +913,18 @@ Please reconsider your approach and improve your answer based on the feedback ab
877
913
  self._append_assistant_message(stream_item.answer)
878
914
  self.context.debug(f"no valid skill call, answer[{stream_item.answer}]")
879
915
 
880
- # Stop exploration immediately
881
- self.should_stop_exploration = True
882
- self.context.debug("No tool call, stopping exploration")
916
+ # If plan mode is active, do NOT stop immediately.
917
+ # Instead, keep exploration running so the agent can poll progress
918
+ # (e.g., via _check_progress / _wait) until tasks reach terminal states.
919
+ if hasattr(self.context, "has_active_plan") and await self.context.has_active_plan():
920
+ self.should_stop_exploration = False
921
+ self.context.debug("No tool call, but plan is active; continuing exploration")
922
+ # Avoid tight looping while waiting for running tasks to make progress.
923
+ # This small backoff gives subtasks time to update their status.
924
+ await asyncio.sleep(0.2)
925
+ else:
926
+ self.should_stop_exploration = True
927
+ self.context.debug("No tool call, stopping exploration")
883
928
  return
884
929
 
885
930
  # Reset no-tool-call count (because this round has tool call)
@@ -946,6 +991,9 @@ Please reconsider your approach and improve your answer based on the feedback ab
946
991
  """Execute tool call"""
947
992
  # Checkpoint: Check user interrupt before executing tool
948
993
  self.context.check_user_interrupt()
994
+ self._last_tool_name = tool_call.name
995
+ # Track all tools in current round for accurate plan silent rounds detection
996
+ self._current_round_tools.append(tool_call.name)
949
997
 
950
998
  intervention_tmp_key = "intervention_explore_block_vars"
951
999
 
@@ -955,11 +1003,13 @@ Please reconsider your approach and improve your answer based on the feedback ab
955
1003
  metadata = None
956
1004
 
957
1005
  try:
1006
+ # Save intervention vars (stage_id will be filled by skill_run after creating the stage)
958
1007
  intervention_vars = {
959
1008
  "prompt": self.context.get_messages().get_messages_as_dict(),
960
1009
  "tool_name": tool_call.name,
961
1010
  "cur_llm_stream_answer": stream_item.answer,
962
1011
  "all_answer": stream_item.answer,
1012
+ "stage_id": None, # Will be updated by skill_run() after stage creation
963
1013
  }
964
1014
 
965
1015
  self.context.set_variable(intervention_tmp_key, intervention_vars)
@@ -1151,34 +1201,173 @@ Please reconsider your approach and improve your answer based on the feedback ab
1151
1201
  f"error in call {tool_name} tool, error type: {type(e)}, error info: {str(e)}, error trace: {error_trace}"
1152
1202
  )
1153
1203
 
1154
- def _should_continue_explore(self) -> bool:
1204
+ async def _should_continue_explore(self) -> bool:
1155
1205
  """Check whether to continue the next exploration.
1156
1206
 
1157
- Termination conditions:
1207
+ Termination conditions (Early Return pattern):
1158
1208
  1. Maximum number of tool calls has been reached
1159
- 2. Number of repeated tool calls exceeds limit
1160
- 3. No tool call occurred once
1209
+ 2. Plan mode has special continuation logic
1210
+ 3. Number of repeated tool calls exceeds limit
1211
+ 4. No tool call occurred once
1161
1212
  """
1162
- # 1. If the maximum number of calls has been reached, stop exploring
1213
+ # 1. Early return: max skill calls reached
1163
1214
  if self.times >= MAX_SKILL_CALL_TIMES:
1164
1215
  return False
1165
1216
 
1166
- # 2. Check for repeated calls exceeding the limit
1167
- deduplicator = self.strategy.get_deduplicator()
1168
- if hasattr(deduplicator, 'skillcalls') and deduplicator.skillcalls:
1169
- recent_calls = list(deduplicator.skillcalls.values())
1170
- if (
1171
- recent_calls
1172
- and max(recent_calls) >= DefaultSkillCallDeduplicator.MAX_DUPLICATE_COUNT
1173
- ):
1174
- return False
1217
+ # 2. Plan mode has special logic - delegate to separate method
1218
+ if hasattr(self.context, "has_active_plan") and await self.context.has_active_plan():
1219
+ return await self._should_continue_explore_in_plan_mode()
1175
1220
 
1176
- # 3. Stop exploring when there is no tool call.
1221
+ # 3. Early return: repeated calls exceeding limit
1222
+ if self._has_exceeded_duplicate_limit():
1223
+ return False
1224
+
1225
+ # 4. Early return: no tool call
1177
1226
  if self.should_stop_exploration:
1178
1227
  return False
1179
1228
 
1180
1229
  return True
1181
1230
 
1231
+ async def _should_continue_explore_in_plan_mode(self) -> bool:
1232
+ """Check whether to continue exploration in plan mode.
1233
+
1234
+ Plan mode has special continuation logic:
1235
+ - Must continue if tasks are active (unless max attempts reached)
1236
+ - Tracks progress via TaskRegistry signature
1237
+ - Guards against silent rounds (no progress for too long)
1238
+ - Prevents infinite loops when agent stops without progress
1239
+
1240
+ Returns:
1241
+ True if exploration should continue, False otherwise
1242
+ """
1243
+ from dolphin.core.common.constants import PLAN_ORCHESTRATION_TOOLS
1244
+
1245
+ # Check if current round used any plan orchestration tool
1246
+ used_plan_tool = self._used_plan_tool_this_round()
1247
+
1248
+ # Check for actual task progress and get current signature
1249
+ registry = getattr(self.context, "task_registry", None)
1250
+ has_progress, current_signature = await self._check_plan_progress_with_signature(registry)
1251
+
1252
+ # Early return: agent stopped without progress or plan tool usage
1253
+ if self.should_stop_exploration:
1254
+ if not has_progress and not used_plan_tool:
1255
+ logger.warning(
1256
+ "Plan mode: Agent stopped without task progress or plan tool usage. "
1257
+ "Terminating to prevent infinite loop."
1258
+ )
1259
+ return False
1260
+
1261
+ if not has_progress and self._plan_silent_rounds >= 2:
1262
+ logger.warning(
1263
+ f"Plan mode: Agent stopped with plan tool but no progress for "
1264
+ f"{self._plan_silent_rounds} rounds. Terminating to prevent infinite loop."
1265
+ )
1266
+ return False
1267
+
1268
+ # Update silent rounds tracking and check limit (also updates signature)
1269
+ self._update_plan_silent_rounds(current_signature, has_progress, used_plan_tool)
1270
+
1271
+ # Early return: no progress and agent wants to stop
1272
+ if self.should_stop_exploration and not has_progress:
1273
+ logger.warning(
1274
+ "Plan mode: Stopping - no tool calls and no task progress. "
1275
+ "Prevents infinite loops from repeated responses."
1276
+ )
1277
+ return False
1278
+
1279
+ return True
1280
+
1281
+ def _used_plan_tool_this_round(self) -> bool:
1282
+ """Check if any plan orchestration tool was used in current round."""
1283
+ from dolphin.core.common.constants import PLAN_ORCHESTRATION_TOOLS
1284
+
1285
+ if not self._current_round_tools:
1286
+ return False
1287
+
1288
+ return any(
1289
+ tool_name in PLAN_ORCHESTRATION_TOOLS
1290
+ for tool_name in self._current_round_tools
1291
+ )
1292
+
1293
+ async def _check_plan_progress_with_signature(self, registry) -> tuple[bool, tuple]:
1294
+ """Check if tasks have made progress since last round.
1295
+
1296
+ Args:
1297
+ registry: TaskRegistry instance
1298
+
1299
+ Returns:
1300
+ Tuple of (has_progress, current_signature)
1301
+ """
1302
+ if registry is None:
1303
+ return False, ()
1304
+
1305
+ signature = await registry.get_progress_signature()
1306
+ has_progress = (
1307
+ self._plan_last_signature is None
1308
+ or signature != self._plan_last_signature
1309
+ )
1310
+ return has_progress, signature
1311
+
1312
+ def _update_plan_silent_rounds(
1313
+ self, current_signature: tuple, has_progress: bool, used_plan_tool: bool
1314
+ ) -> None:
1315
+ """Update silent rounds counter and check threshold.
1316
+
1317
+ Silent rounds are rounds where:
1318
+ - No task status progress AND
1319
+ - No plan orchestration tools used
1320
+
1321
+ Args:
1322
+ current_signature: Current task progress signature
1323
+ has_progress: Whether progress was detected this round
1324
+ used_plan_tool: Whether plan orchestration tool was used
1325
+
1326
+ Raises:
1327
+ ToolInterrupt: If silent rounds exceed threshold
1328
+ """
1329
+ if not self.plan_silent_max_rounds or self.plan_silent_max_rounds <= 0:
1330
+ return
1331
+
1332
+ # Reset or increment silent rounds counter
1333
+ if has_progress or used_plan_tool:
1334
+ self._plan_silent_rounds = 0
1335
+ else:
1336
+ self._plan_silent_rounds += 1
1337
+
1338
+ # Update last signature for next round comparison
1339
+ self._plan_last_signature = current_signature
1340
+
1341
+ if self._plan_silent_rounds >= self.plan_silent_max_rounds:
1342
+ raise ToolInterrupt(
1343
+ "Plan mode terminated: no task status progress for too many rounds. "
1344
+ "Use _wait() or _check_progress() instead of repeatedly calling unrelated tools."
1345
+ )
1346
+
1347
+ def _has_exceeded_duplicate_limit(self) -> bool:
1348
+ """Check if repeated tool calls have exceeded the limit.
1349
+
1350
+ Returns:
1351
+ True if duplicate limit exceeded, False otherwise
1352
+ """
1353
+ deduplicator = self.strategy.get_deduplicator()
1354
+ if not hasattr(deduplicator, 'skillcalls') or not deduplicator.skillcalls:
1355
+ return False
1356
+
1357
+ # Ignore polling-style tools
1358
+ ignored_tools = getattr(
1359
+ deduplicator, "_always_allow_duplicate_skills", set()
1360
+ ) or set()
1361
+
1362
+ counts = []
1363
+ for call_key, count in deduplicator.skillcalls.items():
1364
+ tool_name = str(call_key).split(":", 1)[0]
1365
+ if tool_name in ignored_tools:
1366
+ continue
1367
+ counts.append(count)
1368
+
1369
+ return counts and max(counts) >= DefaultSkillCallDeduplicator.MAX_DUPLICATE_COUNT
1370
+
1182
1371
  def _process_skill_result_with_hook(self, skill_name: str) -> tuple[str | None, dict]:
1183
1372
  """Handle skill results using skillkit_hook"""
1184
1373
  # Get skill object
@@ -1306,7 +1495,7 @@ Please reconsider your approach and improve your answer based on the feedback ab
1306
1495
  async for ret in self._explore_once(no_cache=True):
1307
1496
  yield ret
1308
1497
 
1309
- if not self._should_continue_explore():
1498
+ if not await self._should_continue_explore():
1310
1499
  break
1311
1500
 
1312
1501
  # 6. Cleanup
@@ -1353,6 +1542,103 @@ Please reconsider your approach and improve your answer based on the feedback ab
1353
1542
 
1354
1543
  if getattr(self, "skills", None):
1355
1544
  self.context.set_last_skills(self.skills)
1545
+ # Inject context to skillkits that support it
1546
+ self._inject_context_to_skillkits()
1547
+
1548
+ def _inject_context_to_skillkits(self):
1549
+ """Inject execution context to skillkits that need it (e.g., PlanSkillkit).
1550
+
1551
+ This allows skillkits to access runtime context for operations like
1552
+ task registry management, variable forking, etc.
1553
+ """
1554
+ if not self.skills or not self.context:
1555
+ return
1556
+
1557
+ skill_list = self._resolve_skill_list()
1558
+ if not skill_list:
1559
+ return
1560
+
1561
+ self._inject_to_unique_skillkits(skill_list)
1562
+
1563
+ def _resolve_skill_list(self) -> list:
1564
+ """Convert self.skills to a unified list of SkillFunction objects.
1565
+
1566
+ Returns:
1567
+ List of SkillFunction objects, or empty list if conversion fails
1568
+ """
1569
+ # Case 1: Skillset object with getSkills() method
1570
+ if hasattr(self.skills, 'getSkills'):
1571
+ return self.skills.getSkills()
1572
+
1573
+ # Case 2: String list (e.g., ["plan_skillkit.*", "search.*"])
1574
+ if isinstance(self.skills, list) and self.skills and isinstance(self.skills[0], str):
1575
+ return self._resolve_skill_patterns_to_functions()
1576
+
1577
+ # Case 3: Already a list of SkillFunction objects
1578
+ return self.skills if isinstance(self.skills, list) else []
1579
+
1580
+ def _resolve_skill_patterns_to_functions(self) -> list:
1581
+ """Resolve skill name patterns to SkillFunction objects.
1582
+
1583
+ Returns:
1584
+ List of matched SkillFunction objects
1585
+ """
1586
+ current_skillkit = self.context.get_skillkit()
1587
+ if not current_skillkit:
1588
+ return []
1589
+
1590
+ available_skills = current_skillkit.getSkills()
1591
+ owner_names = SkillMatcher.get_owner_skillkits(available_skills)
1592
+
1593
+ # Match requested patterns against available skills
1594
+ matched_skills = []
1595
+ for pattern in self.skills:
1596
+ for skill in available_skills:
1597
+ if SkillMatcher.match_skill(skill, pattern, owner_names=owner_names):
1598
+ matched_skills.append(skill)
1599
+
1600
+ return matched_skills
1601
+
1602
+ def _inject_to_unique_skillkits(self, skill_list: list):
1603
+ """Inject context to unique skillkit instances.
1604
+
1605
+ Args:
1606
+ skill_list: List of SkillFunction objects
1607
+
1608
+ Note:
1609
+ Uses skillkit instance ID to avoid duplicate injections
1610
+ """
1611
+ processed_skillkits = set()
1612
+
1613
+ for skill in skill_list:
1614
+ skillkit = self._get_skillkit_from_skill(skill)
1615
+ if not skillkit:
1616
+ continue
1617
+
1618
+ skillkit_id = id(skillkit)
1619
+ if skillkit_id in processed_skillkits:
1620
+ continue
1621
+
1622
+ skillkit.setContext(self.context)
1623
+ processed_skillkits.add(skillkit_id)
1624
+
1625
+ def _get_skillkit_from_skill(self, skill):
1626
+ """Extract skillkit from a skill object if it supports context injection.
1627
+
1628
+ Args:
1629
+ skill: Skill object (typically SkillFunction)
1630
+
1631
+ Returns:
1632
+ Skillkit instance if valid, None otherwise
1633
+ """
1634
+ if not hasattr(skill, 'owner_skillkit'):
1635
+ return None
1636
+
1637
+ skillkit = skill.owner_skillkit
1638
+ if not skillkit or not hasattr(skillkit, 'setContext'):
1639
+ return None
1640
+
1641
+ return skillkit
1356
1642
 
1357
1643
  def _resolve_mode(self, kwargs: dict):
1358
1644
  """Resolve exploration mode from kwargs or inherit from context."""
@@ -1391,6 +1677,12 @@ Please reconsider your approach and improve your answer based on the feedback ab
1391
1677
  tools_format=self.tools_format,
1392
1678
  )
1393
1679
 
1680
+ # Auto-inject Plan orchestration guidance when plan_skillkit is used
1681
+ if self._has_plan_skillkit():
1682
+ plan_guidance = self._get_plan_guidance()
1683
+ if plan_guidance:
1684
+ system_message = system_message + "\n\n" + plan_guidance
1685
+
1394
1686
  if len(system_message.strip()) > 0 and self.context.context_manager:
1395
1687
  self.context.add_bucket(
1396
1688
  BuildInBucket.SYSTEM.value,
@@ -1398,6 +1690,38 @@ Please reconsider your approach and improve your answer based on the feedback ab
1398
1690
  message_role=MessageRole.SYSTEM,
1399
1691
  )
1400
1692
 
1693
+ def _has_plan_skillkit(self) -> bool:
1694
+ """Check if plan_skillkit is included in the current skills."""
1695
+ if not hasattr(self, "skills") or not self.skills:
1696
+ return False
1697
+
1698
+ # Check if skills list contains plan_skillkit pattern
1699
+ if isinstance(self.skills, list):
1700
+ for pattern in self.skills:
1701
+ if isinstance(pattern, str) and "plan_skillkit" in pattern:
1702
+ return True
1703
+
1704
+ return False
1705
+
1706
+ def _get_plan_guidance(self) -> str:
1707
+ """Get auto-injected guidance for using plan orchestration tools.
1708
+
1709
+ Returns:
1710
+ Multi-line guidance string for plan workflow
1711
+ """
1712
+ return """# Plan Orchestration Workflow
1713
+
1714
+ When using plan tools to break down complex tasks:
1715
+
1716
+ 1. **Create Plan**: Use `_plan_tasks` to define subtasks with id, name, and prompt
1717
+ 2. **Monitor Progress**: Call `_check_progress` to track task status (provides next-step guidance)
1718
+ 3. **Retrieve Results**: When all tasks complete:
1719
+ - Use `_get_task_output()` to get all results at once (recommended)
1720
+ - Or use `_get_task_output(task_id)` for a specific task output
1721
+ 4. **Synthesize**: Combine all outputs into a comprehensive response for the user
1722
+
1723
+ Important: Your response is INCOMPLETE if you stop after tasks finish. You MUST retrieve outputs and provide a final synthesized answer."""
1724
+
1401
1725
  def _apply_deduplicator_config(self, kwargs: dict):
1402
1726
  """Apply skill deduplicator configuration."""
1403
1727
  if "enable_skill_deduplicator" in kwargs:
@@ -322,6 +322,10 @@ class ExploreBlockV2(BasicCodeBlock):
322
322
  raw_tool_args = input_dict["tool_args"]
323
323
  function_params_json = {arg["key"]: arg["value"] for arg in raw_tool_args}
324
324
 
325
+ # Get saved stage_id for resume
326
+ saved_stage_id = intervention_vars.get("stage_id")
327
+ logger.debug(f"Resuming tool call for {input_dict['tool_name']}, saved_stage_id: {saved_stage_id}")
328
+
325
329
  # *** FIX: Update the last tool_call message with modified parameters ***
326
330
  # This ensures LLM sees the actual parameters used, not the original ones
327
331
  messages = self.context.get_messages()
@@ -336,19 +340,21 @@ class ExploreBlockV2(BasicCodeBlock):
336
340
  # Update the arguments with modified parameters
337
341
  import json
338
342
  tool_call.function.arguments = json.dumps(function_params_json, ensure_ascii=False)
339
- logger.debug(f"[FIX] Updated tool_call arguments from original to modified: {function_params_json}")
340
343
 
341
- (
342
- self.recorder.update(
343
- stage=TypeStage.SKILL,
344
- source_type=SourceType.EXPLORE,
345
- skill_name=function_name,
346
- skill_type=self.context.get_skill_type(function_name),
347
- skill_args=function_params_json,
348
- )
349
- if self.recorder
350
- else None
351
- )
344
+ # *** FIX: Don't call recorder.update() here during resume ***
345
+ # skill_run() will create the stage with the correct saved_stage_id
346
+ # Calling update() here would create an extra stage with a new ID
347
+ # (
348
+ # self.recorder.update(
349
+ # stage=TypeStage.SKILL,
350
+ # source_type=SourceType.EXPLORE,
351
+ # skill_name=function_name,
352
+ # skill_type=self.context.get_skill_type(function_name),
353
+ # skill_args=function_params_json,
354
+ # )
355
+ # if self.recorder
356
+ # else None
357
+ # )
352
358
 
353
359
  # *** Handle skip action ***
354
360
  skip_tool = self.context.get_var_value("__skip_tool__")
@@ -403,7 +409,7 @@ class ExploreBlockV2(BasicCodeBlock):
403
409
 
404
410
  # Normal execution (not skipped)
405
411
  try:
406
- props = {"intervention": False}
412
+ props = {"intervention": False, "saved_stage_id": saved_stage_id}
407
413
  have_answer = False
408
414
 
409
415
  async for resp in self.skill_run(
@@ -561,7 +567,6 @@ class ExploreBlockV2(BasicCodeBlock):
561
567
  return
562
568
 
563
569
  # Add assistant message containing tool calls
564
- logger.debug(f"[DEBUG] Tool call detected, preparing to execute: {stream_item.tool_name}")
565
570
  tool_call_id = f"call_{stream_item.tool_name}_{self.times}"
566
571
  tool_call_openai_format = [
567
572
  {
@@ -588,24 +593,23 @@ class ExploreBlockV2(BasicCodeBlock):
588
593
  )
589
594
  self.deduplicator_skillcall.add(tool_call)
590
595
 
591
- logger.debug(f"[DEBUG] Calling _execute_tool_call for: {stream_item.tool_name}")
592
596
  async for ret in self._execute_tool_call(stream_item, tool_call_id):
593
597
  yield ret
594
- logger.debug(f"[DEBUG] _execute_tool_call completed for: {stream_item.tool_name}")
595
598
  else:
596
599
  await self._handle_duplicate_tool_call(tool_call, stream_item)
597
600
 
598
601
  async def _execute_tool_call(self, stream_item, tool_call_id: str):
599
602
  """Execute tool call"""
600
- logger.debug(f"[DEBUG] _execute_tool_call ENTERED for: {stream_item.tool_name}")
601
603
  intervention_tmp_key = "intervention_explore_block_vars"
602
604
 
603
605
  try:
606
+ # Save intervention vars (stage_id will be filled by skill_run after creating the stage)
604
607
  intervention_vars = {
605
608
  "prompt": self.context.get_messages().get_messages_as_dict(),
606
609
  "tool_name": stream_item.tool_name,
607
610
  "cur_llm_stream_answer": stream_item.answer,
608
611
  "all_answer": stream_item.answer,
612
+ "stage_id": None, # Will be updated by skill_run() after stage creation
609
613
  }
610
614
 
611
615
  self.context.set_variable(intervention_tmp_key, intervention_vars)
@@ -668,6 +668,7 @@ class ToolCallStrategy(ExploreStrategy):
668
668
  f"Tool call {info.name} (id={info.id}) skipped: "
669
669
  f"Stream ended but JSON arguments incomplete or invalid. "
670
670
  f"Raw arguments: '{info.raw_arguments}'"
671
+ f"finish_reason: {stream_item.finish_reason}"
671
672
  )
672
673
 
673
674
  return result