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
@@ -36,6 +36,22 @@ from dolphin.cli.interrupt.handler import InterruptToken
36
36
  from dolphin.cli.ui.layout import LayoutManager
37
37
 
38
38
 
39
+ def _print_flags_status():
40
+ """Print current status of all feature flags."""
41
+ all_flags = flags.get_all()
42
+ for flag_name, flag_value in sorted(all_flags.items()):
43
+ state = "Enabled" if flag_value else "Disabled"
44
+ console(f"[Flag] {flag_name}: {state}")
45
+
46
+
47
+ def _should_print_flags_status(args: Args) -> bool:
48
+ """Return whether feature flags should be printed to console."""
49
+ if flags.is_enabled(flags.DEBUG_MODE):
50
+ return True
51
+ log_level = str(getattr(args, "logLevel", "") or "").upper()
52
+ return log_level == "DEBUG"
53
+
54
+
39
55
  async def initializeEnvironment(args: Args):
40
56
  """Initialize Dolphin environment
41
57
 
@@ -51,6 +67,10 @@ async def initializeEnvironment(args: Args):
51
67
  globalConfigPath = args.config if args.config else "./config/global.yaml"
52
68
  globalConfig = GlobalConfig.from_yaml(globalConfigPath)
53
69
 
70
+ # Print flags status after loading config (flags may be set by config file)
71
+ if _should_print_flags_status(args):
72
+ _print_flags_status()
73
+
54
74
  env = Env(
55
75
  globalConfig=globalConfig,
56
76
  agentFolderPath=args.folder,
dolphin/cli/ui/console.py CHANGED
@@ -1347,6 +1347,15 @@ class ConsoleUI:
1347
1347
  elif v is None:
1348
1348
  val_display = f"{Theme.NULL_VALUE}null{Theme.RESET}"
1349
1349
  lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
1350
+ # Special case for tasks list in PlanSkillkit
1351
+ elif key_lower == "tasks" and isinstance(v, list) and v and isinstance(v[0], dict):
1352
+ lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding}")
1353
+ for i, task in enumerate(v[:10]):
1354
+ task_id = task.get("id", f"task_{i+1}")
1355
+ task_name = task.get("name", "Unnamed Task")
1356
+ lines.append(f" {Theme.MUTED}•{Theme.RESET} [{Theme.SUCCESS}{task_id}{Theme.RESET}] {Theme.PARAM_VALUE}{task_name}{Theme.RESET}")
1357
+ if len(v) > 10:
1358
+ lines.append(f" {self._format_hidden_lines_hint(len(v) - 10, prefix=' ')}")
1350
1359
  else:
1351
1360
  # Complex types: use existing json highlighter
1352
1361
  val_display = self._highlight_json(v, indent=0, max_depth=1)
@@ -1738,14 +1747,14 @@ class ConsoleUI:
1738
1747
 
1739
1748
  # Block type icons and colors
1740
1749
  block_icons = {
1741
- "explore": ("🔍", Theme.SECONDARY),
1742
- "prompt": ("💬", Theme.SUCCESS),
1743
- "judge": ("⚖️", Theme.WARNING),
1744
- "assign": ("📝", Theme.PRIMARY),
1745
- "tool": ("", Theme.ACCENT),
1750
+ "explore": ("[?]", Theme.SECONDARY),
1751
+ "prompt": ("[>]", Theme.SUCCESS),
1752
+ "judge": ("[J]", Theme.WARNING),
1753
+ "assign": ("[=]", Theme.PRIMARY),
1754
+ "tool": ("[*]", Theme.ACCENT),
1746
1755
  }
1747
1756
 
1748
- icon, color = block_icons.get(block_type.lower(), ("📦", Theme.LABEL))
1757
+ icon, color = block_icons.get(block_type.lower(), ("[X]", Theme.LABEL))
1749
1758
 
1750
1759
  # Build output line
1751
1760
  header = f"{color}{Theme.BOLD}{icon} {block_type.upper()}{Theme.RESET}"
@@ -2250,7 +2259,7 @@ class ConsoleUI:
2250
2259
  print(f" {Theme.MUTED}… +{len(details) - 10} more{Theme.RESET}")
2251
2260
 
2252
2261
  # ─────────────────────────────────────────────────────────────
2253
- # Plan-Specific Renderer (Unique Style for plan_act)
2262
+ # Plan Renderer (Codex-style)
2254
2263
  # ─────────────────────────────────────────────────────────────
2255
2264
 
2256
2265
  def _get_visual_width(self, text: str) -> int:
@@ -2275,7 +2284,7 @@ class ConsoleUI:
2275
2284
  return width
2276
2285
 
2277
2286
  # ─────────────────────────────────────────────────────────────
2278
- # Plan-Specific Renderer (Unique Style for plan_act)
2287
+ # Plan Renderer (Codex-style)
2279
2288
  # ─────────────────────────────────────────────────────────────
2280
2289
 
2281
2290
  def plan_update(
@@ -2289,10 +2298,10 @@ class ConsoleUI:
2289
2298
  verbose: Optional[bool] = None
2290
2299
  ) -> None:
2291
2300
  """
2292
- Render a complete plan update in a unique style for plan_act.
2301
+ Render a complete plan update in a plan-focused visual style.
2293
2302
 
2294
- This is a specialized renderer that replaces the generic skill_call box
2295
- with a distinctive plan-focused visual design.
2303
+ This renderer is intended for plan orchestration tools (e.g., plan_skillkit)
2304
+ and can be used by callers who want a dedicated plan visualization.
2296
2305
 
2297
2306
  Args:
2298
2307
  tasks: List of tasks with 'content', 'status'
@@ -2756,10 +2765,15 @@ if __name__ == "__main__":
2756
2765
 
2757
2766
  # Demo skill call
2758
2767
  ui.skill_call_start(
2759
- "_plan_act",
2768
+ "_plan_tasks",
2760
2769
  {
2761
- "planningMode": "create",
2762
- "taskList": "1. Load Excel file\n2. Analyze structure\n3. Generate insights"
2770
+ "exec_mode": "seq",
2771
+ "max_concurrency": 3,
2772
+ "tasks": [
2773
+ {"id": "task_1", "name": "Load Excel file", "prompt": "Load the Excel file"},
2774
+ {"id": "task_2", "name": "Analyze structure", "prompt": "Analyze the file structure"},
2775
+ {"id": "task_3", "name": "Generate insights", "prompt": "Generate key insights"},
2776
+ ],
2763
2777
  }
2764
2778
  )
2765
2779
 
@@ -2767,11 +2781,15 @@ if __name__ == "__main__":
2767
2781
  time.sleep(0.5)
2768
2782
 
2769
2783
  ui.skill_call_end(
2770
- "_plan_act",
2784
+ "_plan_tasks",
2771
2785
  {
2772
2786
  "status": "success",
2773
- "tasks": ["Task 1", "Task 2", "Task 3"],
2774
- "nextStep": "Execute Task 1"
2787
+ "tasks": [
2788
+ {"id": "task_1", "name": "Load Excel file"},
2789
+ {"id": "task_2", "name": "Analyze structure"},
2790
+ {"id": "task_3", "name": "Generate insights"},
2791
+ ],
2792
+ "next_step": "Execute task_1",
2775
2793
  },
2776
2794
  duration_ms=156.3
2777
2795
  )
@@ -115,6 +115,10 @@ def setupFlagsFromArgs(args: Args) -> None:
115
115
 
116
116
  Args:
117
117
  args: Parsed CLI arguments
118
+
119
+ Note:
120
+ Flags status may be printed after config is loaded in initializeEnvironment()
121
+ when running in debug/verbose logging modes.
118
122
  """
119
123
  from dolphin.core import flags
120
124
 
@@ -129,7 +133,3 @@ def setupFlagsFromArgs(args: Args) -> None:
129
133
  boolValue = bool(flagValue)
130
134
 
131
135
  flags.set_flag(flagName, boolValue)
132
- # Cleaner flag output
133
- state = "Enabled" if boolValue else "Disabled"
134
- console(f"[Flag] {flagName}: {state}")
135
-
@@ -406,12 +406,34 @@ class BaseAgent(ABC):
406
406
  AgentState.PAUSED, "Agent paused due to tool interrupt"
407
407
  )
408
408
 
409
+ # Map interrupt_type: "tool_interrupt" (internal) -> "tool_confirmation" (API)
410
+ api_interrupt_type = self._pause_type.value
411
+ if run_result.resume_handle:
412
+ internal_type = run_result.resume_handle.interrupt_type
413
+ if internal_type == "tool_interrupt":
414
+ api_interrupt_type = "tool_confirmation"
415
+ elif internal_type == "user_interrupt":
416
+ api_interrupt_type = "user_interrupt"
417
+
409
418
  # 统一输出格式:status 固定为 "interrupted",通过 interrupt_type 区分
410
- yield {
419
+ interrupt_response = {
411
420
  "status": "interrupted",
412
421
  "handle": run_result.resume_handle,
413
- "interrupt_type": run_result.resume_handle.interrupt_type if run_result.resume_handle else self._pause_type.value,
422
+ "interrupt_type": api_interrupt_type,
414
423
  }
424
+
425
+ # For ToolInterrupt, include tool data from frame.error (same as step mode)
426
+ frame_error = getattr(self._current_frame, "error", None) if self._current_frame else None
427
+ if run_result.is_tool_interrupted and frame_error:
428
+ if frame_error.get("error_type") == "ToolInterrupt":
429
+ interrupt_response["data"] = {
430
+ "tool_name": frame_error.get("tool_name", ""),
431
+ "tool_description": "", # Can be added if available
432
+ "tool_args": frame_error.get("tool_args", []),
433
+ "interrupt_config": frame_error.get("tool_config", {}),
434
+ }
435
+
436
+ yield interrupt_response
415
437
  return
416
438
 
417
439
  elif run_result.is_completed:
@@ -463,11 +485,32 @@ class BaseAgent(ABC):
463
485
  )
464
486
 
465
487
  # 统一输出格式
466
- yield {
488
+ # Map interrupt_type: "tool_interrupt" (internal) -> "tool_confirmation" (API)
489
+ api_interrupt_type = self._pause_type.value
490
+ if step_result.resume_handle:
491
+ internal_type = step_result.resume_handle.interrupt_type
492
+ if internal_type == "tool_interrupt":
493
+ api_interrupt_type = "tool_confirmation"
494
+ elif internal_type == "user_interrupt":
495
+ api_interrupt_type = "user_interrupt"
496
+
497
+ interrupt_response = {
467
498
  "status": "interrupted",
468
499
  "handle": step_result.resume_handle,
469
- "interrupt_type": step_result.resume_handle.interrupt_type if step_result.resume_handle else self._pause_type.value,
500
+ "interrupt_type": api_interrupt_type,
470
501
  }
502
+
503
+ # For ToolInterrupt, include tool data from frame.error
504
+ if step_result.is_tool_interrupted and self._current_frame and self._current_frame.error:
505
+ frame_error = self._current_frame.error
506
+ if frame_error.get("error_type") == "ToolInterrupt":
507
+ interrupt_response["data"] = {
508
+ "tool_name": frame_error.get("tool_name", ""),
509
+ "tool_args": frame_error.get("tool_args", []),
510
+ "tool_config": frame_error.get("tool_config", {}),
511
+ }
512
+
513
+ yield interrupt_response
471
514
  break
472
515
 
473
516
  elif step_result.is_completed:
@@ -568,11 +611,22 @@ class BaseAgent(ABC):
568
611
  """Pause logic implemented by subclasses"""
569
612
  pass
570
613
 
571
- async def resume(self, updates: Optional[Dict[str, Any]] = None) -> bool:
614
+ async def resume(
615
+ self,
616
+ updates: Optional[Dict[str, Any]] = None,
617
+ resume_handle=None # External resume handle (for stateless scenarios)
618
+ ) -> bool:
572
619
  """Resume Agent (based on coroutine)
573
620
 
574
621
  Args:
575
622
  updates: Variable updates to inject (used to resume from tool interruption)
623
+ resume_handle: Optional external resume handle (for web apps/stateless scenarios)
624
+ If provided, will override internal _resume_handle
625
+ This allows resuming across different requests/processes
626
+
627
+ Usage Scenarios:
628
+ 1. Stateful (same process): resume(updates) - uses internal _resume_handle
629
+ 2. Stateless (web apps): resume(updates, resume_handle) - uses external handle
576
630
  """
577
631
  if self.state != AgentState.PAUSED:
578
632
  raise AgentLifecycleException(
@@ -580,9 +634,18 @@ class BaseAgent(ABC):
580
634
  )
581
635
 
582
636
  try:
583
- # Resume coroutine execution
584
- if self._resume_handle is not None:
637
+ # Use external handle if provided (for stateless scenarios like web apps)
638
+ # Otherwise use internal handle (for stateful scenarios like testing)
639
+ handle_to_use = resume_handle if resume_handle is not None else self._resume_handle
640
+
641
+ if handle_to_use is not None:
642
+ # Temporarily set internal handle for _on_resume_coroutine to use
643
+ original_handle = self._resume_handle
644
+ self._resume_handle = handle_to_use
645
+
585
646
  self._current_frame = await self._on_resume_coroutine(updates)
647
+
648
+ # Clear handles after resume
586
649
  self._resume_handle = None
587
650
  self._pause_type = None
588
651
 
@@ -90,8 +90,12 @@ class BasicCodeBlock:
90
90
  self.recorder: Optional[Recorder] = None
91
91
  # tool_choice support (auto|none|required|provider-specific)
92
92
  self.tool_choice: Optional[str] = None
93
- # Whether to enable skill deduplication (used only in explore blocks, enabled by default)
94
- self.enable_skill_deduplicator: bool = True
93
+ # Whether to enable skill deduplication (used only in explore blocks)
94
+ # Default is False to avoid incorrectly blocking legitimate repeated detection/polling tool calls.
95
+ # Recent observations show that deduplication can prematurely stop valid polling scenarios
96
+ # (e.g., repeatedly checking task status until completion).
97
+ # Set to True explicitly via /explore/(enable_skill_deduplicator=true) when needed.
98
+ self.enable_skill_deduplicator: bool = False
95
99
  self.skills = None
96
100
  self.system_prompt = ""
97
101
 
@@ -115,6 +119,26 @@ class BasicCodeBlock:
115
119
  if context:
116
120
  context.set_skillkit_hook(self.skillkit_hook)
117
121
 
122
+ @staticmethod
123
+ def _normalize_bool_param(value: Any, default: bool) -> bool:
124
+ """Normalize boolean-like params from DPH parsing.
125
+
126
+ DPH params may be parsed as strings such as "true"/"false".
127
+ """
128
+ if isinstance(value, bool):
129
+ return value
130
+ if value is None:
131
+ return default
132
+ if isinstance(value, (int, float)):
133
+ return bool(value)
134
+ if isinstance(value, str):
135
+ normalized = value.strip().lower()
136
+ if normalized in {"true", "1", "yes", "y", "on"}:
137
+ return True
138
+ if normalized in {"false", "0", "no", "n", "off"}:
139
+ return False
140
+ return default
141
+
118
142
  def validate(self, content):
119
143
  """Verify the correctness of the content
120
144
  :param content: The content to be verified
@@ -658,11 +682,11 @@ class BasicCodeBlock:
658
682
  self._validate_skills()
659
683
 
660
684
  self.ttc_mode = params_dict.get("ttc_mode", None)
661
- self.no_cache = params_dict.get("no_cache", False)
685
+ self.no_cache = self._normalize_bool_param(params_dict.get("no_cache", False), False)
662
686
  self.flags = params_dict.get("flags", "")
663
687
  # 是否启用技能调用去重(仅 explore 块会实际使用该参数)
664
- self.enable_skill_deduplicator = params_dict.get(
665
- "enable_skill_deduplicator", True
688
+ self.enable_skill_deduplicator = self._normalize_bool_param(
689
+ params_dict.get("enable_skill_deduplicator", False), False
666
690
  )
667
691
 
668
692
  # 处理输出格式参数
@@ -1133,6 +1157,7 @@ class BasicCodeBlock:
1133
1157
  skill_params_json: Dict[str, Any] = {},
1134
1158
  props=None,
1135
1159
  ):
1160
+ from dolphin.core.utils.tools import ToolInterrupt
1136
1161
  if self.context.is_skillkit_empty():
1137
1162
  self.context.warn(f"skillkit is None, skill_name[{skill_name}]")
1138
1163
  return
@@ -1149,23 +1174,79 @@ class BasicCodeBlock:
1149
1174
  return
1150
1175
 
1151
1176
  # Create initial SKILL stage to track skill execution start
1152
- assert self.recorder, "recorder is None"
1153
- self.recorder.getProgress().add_stage(
1154
- agent_name=skill_name,
1155
- stage=TypeStage.SKILL,
1156
- status=Status.PROCESSING,
1157
- skill_info=SkillInfo.build(
1158
- skill_type=SkillType.TOOL,
1159
- skill_name=skill_name,
1160
- skill_args=skill_params_json,
1161
- ),
1162
- input_content=str(skill_params_json),
1163
- interrupted=False,
1164
- )
1177
+ # Only create new stage if this is a new tool call (intervention=True)
1178
+ # For resumed tool calls (intervention=False), update the existing stage using saved_stage_id
1179
+ if props is None:
1180
+ props = {}
1181
+
1182
+ is_resumed_call = not props.get('intervention', True)
1183
+ saved_stage_id = props.get('saved_stage_id')
1184
+
1185
+ if not is_resumed_call:
1186
+ # First-time call: create new Stage
1187
+ assert self.recorder, "recorder is None"
1188
+ self.recorder.getProgress().add_stage(
1189
+ agent_name=skill_name,
1190
+ stage=TypeStage.SKILL,
1191
+ status=Status.PROCESSING,
1192
+ skill_info=SkillInfo.build(
1193
+ skill_type=SkillType.TOOL,
1194
+ skill_name=skill_name,
1195
+ skill_args=skill_params_json,
1196
+ ),
1197
+ input_content=str(skill_params_json),
1198
+ interrupted=False,
1199
+ )
1200
+
1201
+ # ✅ FIX: Save the newly created Stage ID to intervention_vars for potential interrupt
1202
+ # This must be done AFTER creating the stage, not before
1203
+ # Determine the correct intervention_vars key based on source_type
1204
+ progress = self.recorder.getProgress()
1205
+ if len(progress.stages) > 0:
1206
+ new_stage_id = progress.stages[-1].id
1207
+
1208
+ # Map source_type to intervention_vars key
1209
+ # Priority: judge_block > tool_block (both use SourceType.SKILL) > explore_block
1210
+ var_key = None
1211
+ intervention_vars = None
1212
+
1213
+ if source_type == SourceType.EXPLORE:
1214
+ var_key = "intervention_explore_block_vars"
1215
+ intervention_vars = self.context.get_var_value(var_key)
1216
+ elif source_type == SourceType.SKILL:
1217
+ # Check judge block first (also uses SourceType.SKILL)
1218
+ judge_vars = self.context.get_var_value("intervention_judge_block_vars")
1219
+ if judge_vars is not None:
1220
+ var_key = "intervention_judge_block_vars"
1221
+ intervention_vars = judge_vars
1222
+ else:
1223
+ var_key = "intervention_tool_block_vars"
1224
+ intervention_vars = self.context.get_var_value(var_key)
1225
+
1226
+ # Update the intervention_vars with the correct Stage ID
1227
+ if intervention_vars is not None and var_key is not None:
1228
+ intervention_vars["stage_id"] = new_stage_id
1229
+ self.context.set_variable(var_key, intervention_vars)
1165
1230
 
1166
- # notify app
1167
- async for result in self.yield_message(answer="", think=""):
1168
- yield result
1231
+ # notify app
1232
+ async for result in self.yield_message(answer="", think=""):
1233
+ yield result
1234
+ else:
1235
+ # *** FIX: Resumed call - Set _next_stage_id to use saved ID ***
1236
+ # Don't create stage here; let the normal flow handle it
1237
+ # This avoids creating extra stages and ensures consistency
1238
+ assert self.recorder, "recorder is None"
1239
+
1240
+ if saved_stage_id:
1241
+ # Set _next_stage_id so the next add_stage() call will use this ID
1242
+ progress = self.recorder.getProgress()
1243
+ progress._next_stage_id = saved_stage_id
1244
+ logger.debug(f"Resumed tool call for {skill_name}, set _next_stage_id = {saved_stage_id}")
1245
+ else:
1246
+ logger.debug(f"Resumed tool call for {skill_name}, no saved_stage_id provided")
1247
+
1248
+ # NOTE: Do NOT call yield_message() in resume branch!
1249
+ # The resumed tool execution will create the stage naturally through recorder.update()
1169
1250
 
1170
1251
  agent_as_skill = self.context.get_agent_skill(skill)
1171
1252
  if agent_as_skill is not None:
@@ -1195,10 +1276,39 @@ class BasicCodeBlock:
1195
1276
  have_answer = False
1196
1277
  cur_agent = self.context.get_cur_agent()
1197
1278
 
1198
- if props is None:
1199
- props = {}
1279
+ # props already initialized above, just update it
1200
1280
  props.update({"gvp": self.context})
1201
1281
  try:
1282
+ # Check for tool interrupt configuration (ToolInterrupt mechanism)
1283
+ # Default: all tool calls support interrupt if tool has interrupt_config
1284
+ # Skip interrupt check if this is a resumed tool call (intervention=False)
1285
+ if props.get('intervention', True):
1286
+ interrupt_config = getattr(skill, 'interrupt_config', None)
1287
+
1288
+ if interrupt_config and interrupt_config.get('requires_confirmation'):
1289
+ # Format confirmation message (support parameter interpolation)
1290
+ message = interrupt_config.get('confirmation_message', 'Tool requires confirmation')
1291
+ if message and skill_params_json:
1292
+ try:
1293
+ message = message.format(**skill_params_json)
1294
+ except (KeyError, ValueError):
1295
+ # If parameter interpolation fails, use original message
1296
+ pass
1297
+
1298
+ # Construct tool arguments list
1299
+ tool_args = [
1300
+ {"key": k, "value": v, "type": type(v).__name__}
1301
+ for k, v in skill_params_json.items()
1302
+ ]
1303
+
1304
+ # Throw ToolInterrupt (checked before execution)
1305
+ raise ToolInterrupt(
1306
+ message=message,
1307
+ tool_name=skill_name,
1308
+ tool_args=tool_args,
1309
+ tool_config=interrupt_config
1310
+ )
1311
+
1202
1312
  console_skill_call(
1203
1313
  skill_name, skill_params_json, verbose=self.context.verbose, skill=skill
1204
1314
  )
@@ -1274,13 +1384,33 @@ class BasicCodeBlock:
1274
1384
  if agent_as_skill is not None:
1275
1385
  self.context.set_cur_agent(cur_agent)
1276
1386
 
1277
- yield self.recorder.update(
1387
+ # *** FIX: Update stage status to COMPLETED ***
1388
+ # This updates the stage status and triggers set_variable() to update _progress in context
1389
+ self.recorder.update(
1278
1390
  item=result,
1279
1391
  source_type=SourceType.SKILL,
1280
1392
  skill_name=skill_name,
1281
1393
  skill_args=skill_params_json,
1282
1394
  is_completed=True,
1283
1395
  )
1396
+
1397
+ # *** FIX: Yield completion update in a format that explore_block_v2 recognizes ***
1398
+ # Use a nested structure that matches the check in explore_block_v2.py line 422-427
1399
+ # This prevents explore_block_v2 from calling recorder.update() again
1400
+ if self.context:
1401
+ progress_var = self.context.get_var_value("_progress")
1402
+ completion_data = {
1403
+ "answer": {
1404
+ "answer": result.get("output") if isinstance(result, dict) else str(result),
1405
+ "think": "",
1406
+ },
1407
+ "_status": "running",
1408
+ "_progress": progress_var if progress_var else []
1409
+ }
1410
+ yield completion_data
1411
+ else:
1412
+ yield result
1413
+
1284
1414
  if agent_as_skill is not None:
1285
1415
  console_agent_skill_exit(skill_name, verbose=self.context.verbose)
1286
1416
  except ToolInterrupt as e:
@@ -1410,6 +1540,12 @@ class BasicCodeBlock:
1410
1540
  if recorder:
1411
1541
  recorder.update(item=stream_item, raw_output=stream_item.answer)
1412
1542
 
1543
+ # Update tool call detection flags
1544
+ if stream_item.has_tool_call():
1545
+ tool_call_detected = True
1546
+ if stream_item.has_complete_tool_call():
1547
+ complete_tool_call = stream_item.get_tool_call()
1548
+
1413
1549
  yield stream_item
1414
1550
 
1415
1551
  # If a complete tool call is detected and early-stop is enabled, stop streaming.
@@ -1829,7 +1965,7 @@ class BasicCodeBlock:
1829
1965
  f"[_load_dynamic_tools] self.skills does not exist on {type(self).__name__}"
1830
1966
  )
1831
1967
 
1832
- self.context.debug(f" Dynamically loaded tool: {tool_name}")
1968
+ self.context.debug(f"[OK] Dynamically loaded tool: {tool_name}")
1833
1969
 
1834
1970
  except Exception as e:
1835
1971
  tool_name = (
@@ -1837,7 +1973,7 @@ class BasicCodeBlock:
1837
1973
  if isinstance(tool_def, dict)
1838
1974
  else "unknown"
1839
1975
  )
1840
- self.context.error(f" Failed to load dynamic tool {tool_name}: {e}")
1976
+ self.context.error(f"[ERROR] Failed to load dynamic tool {tool_name}: {e}")
1841
1977
  import traceback
1842
1978
 
1843
1979
  self.context.debug(f"Error traceback: {traceback.format_exc()}")