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
@@ -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():
@@ -558,10 +589,9 @@ Please reconsider your approach and improve your answer based on the feedback ab
558
589
  def _has_pending_tool_call(self) -> bool:
559
590
  """Check if there are pending tool calls (interrupt recovery)"""
560
591
  intervention_tmp_key = "intervention_explore_block_vars"
561
- return (
562
- intervention_tmp_key in self.context.get_all_variables().keys()
563
- and "tool" in self.context.get_all_variables().keys()
564
- )
592
+ has_intervention = intervention_tmp_key in self.context.get_all_variables().keys()
593
+ has_tool = "tool" in self.context.get_all_variables().keys()
594
+ return has_intervention and has_tool
565
595
 
566
596
  async def _handle_resumed_tool_call(self):
567
597
  """Tools for handling interrupt recovery calls """
@@ -571,17 +601,49 @@ Please reconsider your approach and improve your answer based on the feedback ab
571
601
  intervention_vars = self.context.get_var_value(intervention_tmp_key)
572
602
  self.context.delete_variable(intervention_tmp_key)
573
603
 
574
- # Restore complete message context
604
+ # Restore complete message context to context_manager buckets
575
605
  saved_messages = intervention_vars.get("prompt")
576
606
  if saved_messages is not None:
607
+ from dolphin.core.common.enums import MessageRole
608
+
609
+ # *** FIX: Filter out messages that are already in other buckets ***
610
+ # To avoid duplication, only restore messages generated during the conversation:
611
+ # - SYSTEM messages are already in SYSTEM bucket (from initial execute)
612
+ # - USER messages are already in QUERY/HISTORY buckets (initial query and history)
613
+ # - We only need to restore ASSISTANT and TOOL messages (conversation progress)
614
+ filtered_messages = [
615
+ msg for msg in saved_messages
616
+ if msg.get("role") in [MessageRole.ASSISTANT.value, MessageRole.TOOL.value]
617
+ ]
618
+
577
619
  msgs = Messages()
578
- msgs.extend_plain_messages(saved_messages)
579
- self.context.set_messages(msgs)
620
+ msgs.extend_plain_messages(filtered_messages)
621
+ # Use set_messages_batch to restore to context_manager buckets
622
+ # This ensures messages are available when to_dph_messages() is called
623
+ self.context.set_messages_batch(msgs, bucket=BuildInBucket.SCRATCHPAD.value)
580
624
 
581
625
  input_dict = self.context.get_var_value("tool")
582
626
  function_name = input_dict["tool_name"]
583
627
  raw_tool_args = input_dict["tool_args"]
584
628
  function_params_json = {arg["key"]: arg["value"] for arg in raw_tool_args}
629
+
630
+ # Get saved stage_id for resume
631
+ saved_stage_id = intervention_vars.get("stage_id")
632
+
633
+ # *** FIX: Update the last tool_call message with modified parameters ***
634
+ # This ensures LLM sees the actual parameters used, not the original ones
635
+ messages = self.context.get_messages()
636
+ if messages and len(messages.get_messages()) > 0:
637
+ last_message = messages.get_messages()[-1]
638
+ # Check if last message is an assistant message with tool_calls
639
+ if (hasattr(last_message, 'role') and last_message.role == "assistant" and
640
+ hasattr(last_message, 'tool_calls') and last_message.tool_calls):
641
+ # Find the matching tool_call
642
+ for tool_call in last_message.tool_calls:
643
+ if hasattr(tool_call, 'function') and tool_call.function.name == function_name:
644
+ # Update the arguments with modified parameters
645
+ import json
646
+ tool_call.function.arguments = json.dumps(function_params_json, ensure_ascii=False)
585
647
 
586
648
  if self.recorder:
587
649
  self.recorder.update(
@@ -591,11 +653,60 @@ Please reconsider your approach and improve your answer based on the feedback ab
591
653
  skill_type=self.context.get_skill_type(function_name),
592
654
  skill_args=function_params_json,
593
655
  )
656
+
657
+ # *** Handle skip action ***
658
+ skip_tool = self.context.get_var_value("__skip_tool__")
659
+ skip_message = self.context.get_var_value("__skip_message__")
660
+
661
+ # Clean up skip flags
662
+ if skip_tool:
663
+ self.context.delete_variable("__skip_tool__")
664
+ if skip_message:
665
+ self.context.delete_variable("__skip_message__")
666
+
594
667
  self.context.delete_variable("tool")
595
668
 
596
669
  return_answer = {}
670
+
671
+ # If user chose to skip, don't execute the tool
672
+ if skip_tool:
673
+ # Generate friendly skip message
674
+ params_str = ", ".join([f"{k}={v}" for k, v in function_params_json.items()])
675
+ default_skip_msg = f"Tool '{function_name}' was skipped by user"
676
+ if skip_message:
677
+ skip_response = f"[SKIPPED] {skip_message}"
678
+ else:
679
+ skip_response = f"[SKIPPED] {default_skip_msg} (parameters: {params_str})"
680
+
681
+ return_answer["answer"] = skip_response
682
+ return_answer["think"] = skip_response
683
+ return_answer["status"] = "completed"
684
+
685
+ if self.recorder:
686
+ self.recorder.update(
687
+ item={"answer": skip_response, "block_answer": skip_response},
688
+ stage=TypeStage.SKILL,
689
+ source_type=SourceType.EXPLORE,
690
+ skill_name=function_name,
691
+ skill_type=self.context.get_skill_type(function_name),
692
+ skill_args=function_params_json,
693
+ )
694
+
695
+ yield [return_answer]
696
+
697
+ # Add tool response message with skip indicator
698
+ tool_call_id = self._extract_tool_call_id()
699
+ if not tool_call_id:
700
+ tool_call_id = f"call_{function_name}_{self.times}"
701
+
702
+ self.strategy.append_tool_response_message(
703
+ self.context, tool_call_id, skip_response, metadata={"skipped": True}
704
+ )
705
+ return
706
+
707
+ # Normal execution (not skipped)
597
708
  try:
598
- props = {"intervention": False}
709
+ props = {"intervention": False, "saved_stage_id": saved_stage_id}
599
710
  have_answer = False
600
711
 
601
712
  async for resp in self.skill_run(
@@ -785,7 +896,11 @@ Please reconsider your approach and improve your answer based on the feedback ab
785
896
  tool_calls = [single] if single else []
786
897
 
787
898
  if not tool_calls:
788
- # 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
789
904
  # If there is pending content, merge before adding
790
905
  if self.pending_content:
791
906
  # Merge pending content and current content
@@ -798,9 +913,18 @@ Please reconsider your approach and improve your answer based on the feedback ab
798
913
  self._append_assistant_message(stream_item.answer)
799
914
  self.context.debug(f"no valid skill call, answer[{stream_item.answer}]")
800
915
 
801
- # Stop exploration immediately
802
- self.should_stop_exploration = True
803
- 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")
804
928
  return
805
929
 
806
930
  # Reset no-tool-call count (because this round has tool call)
@@ -867,6 +991,9 @@ Please reconsider your approach and improve your answer based on the feedback ab
867
991
  """Execute tool call"""
868
992
  # Checkpoint: Check user interrupt before executing tool
869
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)
870
997
 
871
998
  intervention_tmp_key = "intervention_explore_block_vars"
872
999
 
@@ -876,11 +1003,13 @@ Please reconsider your approach and improve your answer based on the feedback ab
876
1003
  metadata = None
877
1004
 
878
1005
  try:
1006
+ # Save intervention vars (stage_id will be filled by skill_run after creating the stage)
879
1007
  intervention_vars = {
880
1008
  "prompt": self.context.get_messages().get_messages_as_dict(),
881
1009
  "tool_name": tool_call.name,
882
1010
  "cur_llm_stream_answer": stream_item.answer,
883
1011
  "all_answer": stream_item.answer,
1012
+ "stage_id": None, # Will be updated by skill_run() after stage creation
884
1013
  }
885
1014
 
886
1015
  self.context.set_variable(intervention_tmp_key, intervention_vars)
@@ -1061,7 +1190,7 @@ Please reconsider your approach and improve your answer based on the feedback ab
1061
1190
 
1062
1191
  def _handle_tool_interrupt(self, e: Exception, tool_name: str):
1063
1192
  """Handling Tool Interruptions"""
1064
- self.context.info(f"tool interrupt in call {tool_name} tool")
1193
+ self.context.info(f"Tool interrupt in call {tool_name} tool")
1065
1194
  if "※tool" in self.context.get_all_variables().keys():
1066
1195
  self.context.delete_variable("※tool")
1067
1196
 
@@ -1072,34 +1201,173 @@ Please reconsider your approach and improve your answer based on the feedback ab
1072
1201
  f"error in call {tool_name} tool, error type: {type(e)}, error info: {str(e)}, error trace: {error_trace}"
1073
1202
  )
1074
1203
 
1075
- def _should_continue_explore(self) -> bool:
1204
+ async def _should_continue_explore(self) -> bool:
1076
1205
  """Check whether to continue the next exploration.
1077
1206
 
1078
- Termination conditions:
1207
+ Termination conditions (Early Return pattern):
1079
1208
  1. Maximum number of tool calls has been reached
1080
- 2. Number of repeated tool calls exceeds limit
1081
- 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
1082
1212
  """
1083
- # 1. If the maximum number of calls has been reached, stop exploring
1213
+ # 1. Early return: max skill calls reached
1084
1214
  if self.times >= MAX_SKILL_CALL_TIMES:
1085
1215
  return False
1086
1216
 
1087
- # 2. Check for repeated calls exceeding the limit
1088
- deduplicator = self.strategy.get_deduplicator()
1089
- if hasattr(deduplicator, 'skillcalls') and deduplicator.skillcalls:
1090
- recent_calls = list(deduplicator.skillcalls.values())
1091
- if (
1092
- recent_calls
1093
- and max(recent_calls) >= DefaultSkillCallDeduplicator.MAX_DUPLICATE_COUNT
1094
- ):
1095
- 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()
1096
1220
 
1097
- # 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
1226
+ if self.should_stop_exploration:
1227
+ return False
1228
+
1229
+ return True
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
1098
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
+ )
1099
1277
  return False
1100
1278
 
1101
1279
  return True
1102
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
+
1103
1371
  def _process_skill_result_with_hook(self, skill_name: str) -> tuple[str | None, dict]:
1104
1372
  """Handle skill results using skillkit_hook"""
1105
1373
  # Get skill object
@@ -1227,7 +1495,7 @@ Please reconsider your approach and improve your answer based on the feedback ab
1227
1495
  async for ret in self._explore_once(no_cache=True):
1228
1496
  yield ret
1229
1497
 
1230
- if not self._should_continue_explore():
1498
+ if not await self._should_continue_explore():
1231
1499
  break
1232
1500
 
1233
1501
  # 6. Cleanup
@@ -1274,6 +1542,103 @@ Please reconsider your approach and improve your answer based on the feedback ab
1274
1542
 
1275
1543
  if getattr(self, "skills", None):
1276
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
1277
1642
 
1278
1643
  def _resolve_mode(self, kwargs: dict):
1279
1644
  """Resolve exploration mode from kwargs or inherit from context."""
@@ -1312,6 +1677,12 @@ Please reconsider your approach and improve your answer based on the feedback ab
1312
1677
  tools_format=self.tools_format,
1313
1678
  )
1314
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
+
1315
1686
  if len(system_message.strip()) > 0 and self.context.context_manager:
1316
1687
  self.context.add_bucket(
1317
1688
  BuildInBucket.SYSTEM.value,
@@ -1319,6 +1690,38 @@ Please reconsider your approach and improve your answer based on the feedback ab
1319
1690
  message_role=MessageRole.SYSTEM,
1320
1691
  )
1321
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
+
1322
1725
  def _apply_deduplicator_config(self, kwargs: dict):
1323
1726
  """Apply skill deduplicator configuration."""
1324
1727
  if "enable_skill_deduplicator" in kwargs: