diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index f3a249d6b..d76bb564e 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -4374,6 +4374,144 @@ def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str: return base_label +def _build_tree_envelope( + plan_id: str, + tree_data: list[dict[str, object]], + decisions: list[Decision], + show_superseded: bool = False, +) -> dict[str, object]: + """Build the spec-required envelope for ``agents plan tree --format json/yaml``.""" + from datetime import timezone + + filtered = ( + decisions if show_superseded else [d for d in decisions if not d.is_superseded] + ) + + def count_nodes(nodes: list[dict[str, object]]) -> int: + count = 0 + for node in nodes: + count += 1 + children = node.get("children", []) + if isinstance(children, list): + count += count_nodes(children) + return count + + def compute_depth(nodes: list[dict[str, object]]) -> int: + if not nodes: + return 0 + max_depth = 0 + for node in nodes: + children = node.get("children", []) + if isinstance(children, list) and children: + max_depth = max(max_depth, 1 + compute_depth(children)) + return max_depth + + nodes_count = count_nodes(tree_data) + tree_depth = compute_depth(tree_data) + + child_plan_ids: set[str] = set() + for d in filtered: + if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn"): + if hasattr(d, "plan_id") and d.plan_id: + child_plan_ids.add(d.plan_id) + + child_plans_count = len(child_plan_ids) + child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0" + + invariants_count = sum( + 1 for d in filtered if d.decision_type == "invariant_enforced" + ) + + superseded_count = sum(1 for d in decisions if d.is_superseded) + + summary = { + "nodes": nodes_count, + "depth": tree_depth, + "child_plans": child_plans_str, + "invariants": invariants_count, + "superseded": superseded_count, + } + + type_counts: dict[str, int] = {} + decision_ids: dict[str, str] = {} + + for d in filtered: + type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1 + ordinal = type_counts[d.decision_type] + + if d.decision_type == "prompt_definition": + key = "root" + elif d.decision_type == "invariant_enforced": + key = f"invariant_{ordinal}" + elif d.decision_type == "strategy_choice": + key = "strategy" + elif d.decision_type == "implementation_choice": + key = f"implementation_{ordinal}" + elif d.decision_type == "subplan_spawn": + key = f"spawn_{ordinal}" + elif d.decision_type == "subplan_parallel_spawn": + key = f"parallel_{ordinal}" + else: + key = f"{d.decision_type}_{ordinal}" + + decision_ids[key] = d.decision_id + + child_plans_list: list[dict[str, object]] = [] + for d in filtered: + if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn"): + if hasattr(d, "plan_id") and d.plan_id: + child_plans_list.append( + { + "id": d.plan_id, + "phase": "execute", + "state": "queued", + } + ) + + timing: dict[str, object] = { + "started": datetime.now(timezone.utc).isoformat(), + "duration_ms": 0, + } + + def convert_tree_node(node: dict[str, object]) -> dict[str, object]: + """Convert internal tree node format to spec format.""" + spec_node: dict[str, object] = { + "type": node.get("type"), + "description": node.get("question"), + } + + if node.get("confidence") is not None: + spec_node["confidence"] = node.get("confidence") + + if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"): + spec_node["plan_id"] = node.get("plan_id", "") + + children = node.get("children", []) + if isinstance(children, list) and children: + spec_node["children"] = [convert_tree_node(child) for child in children] + + return spec_node + + spec_tree = convert_tree_node(tree_data[0]) if tree_data else {} + + data: dict[str, object] = { + "plan_id": plan_id, + "tree": spec_tree, + "summary": summary, + "child_plans": child_plans_list, + "decision_ids": decision_ids, + } + + return { + "command": "plan tree", + "status": "ok", + "exit_code": 0, + "data": data, + "timing": timing, + "messages": ["Decision tree rendered"], + } + + @app.command("tree") def tree_decisions_cmd( plan_id: Annotated[ @@ -4410,7 +4548,8 @@ def tree_decisions_cmd( ) if fmt in (OutputFormat.JSON, OutputFormat.YAML): - console.print(format_output(tree_data, fmt)) + envelope = _build_tree_envelope(plan_id, tree_data, decisions, show_superseded) + console.print(format_output(envelope, fmt)) elif fmt == OutputFormat.TABLE: # Flatten for table view filtered = (