Files
Artemis-Eapil/artemis/utils/task_tree.py
T
somew1nd 67248c887e feat: improve task execution, verification and packaging
Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
2026-09-02 21:45:51 -07:00

929 lines
36 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
import json
import logging
from pathlib import Path
import re
from typing import Any
from artemis.utils.plan_grammar import parse_plan
logger = logging.getLogger(__name__)
def format_action_clean(action_obj) -> str:
if not action_obj:
return "No Action"
if isinstance(action_obj, list):
if not action_obj:
return "No Action"
action_obj = action_obj[0]
if not isinstance(action_obj, dict):
return str(action_obj)
act_type = action_obj.get("action") or action_obj.get("name")
if not act_type:
return json.dumps(action_obj, ensure_ascii=False)
target_text = action_obj.get("target_text") or action_obj.get("text")
coords = action_obj.get("coordinates") or action_obj.get("target")
app_name = (
action_obj.get("app_name")
or action_obj.get("package_name")
or action_obj.get("package")
or action_obj.get("package_or_bundle_id")
or action_obj.get("bundle_id")
)
keycode = action_obj.get("keycode") or action_obj.get("key")
if act_type in ("tap", "click", "long_press"):
label = f"'{target_text}'" if target_text else "element"
if act_type == "long_press":
duration = action_obj.get("duration") or action_obj.get("duration_ms")
duration_str = f" for {duration}ms" if duration else ""
return f"Long pressed {label} at {coords}{duration_str}"
else:
times = action_obj.get("times") or action_obj.get("click_times") or 1
try:
times = int(times)
except (ValueError, TypeError):
times = 1
if times == 2:
return f"Double tapped {label} at {coords}"
elif times > 2:
delay = action_obj.get("delay_ms") or action_obj.get("delay")
delay_str = f" with {delay}ms delay" if delay else ""
return f"Tapped {label} at {coords} {times} times{delay_str}"
else:
return f"Tapped {label} at {coords}"
elif act_type in ("input_text", "focus_and_input_text"):
label = f"'{target_text}'" if target_text else "field"
text_val = action_obj.get("text")
clear_exist = action_obj.get("clear_exist") or action_obj.get("clear_before_input")
clear_str = " (without clearing)" if clear_exist is False else ""
return f"Inputted '{text_val}' into {label} at {coords}{clear_str}"
elif act_type == "swipe":
direction = action_obj.get("action_direction") or action_obj.get("direction")
duration = action_obj.get("duration") or action_obj.get("duration_ms")
duration_str = f" over {duration}ms" if duration else ""
if direction:
return f"Swiped {direction}{duration_str}"
start_coords = action_obj.get("start_coordinates")
end_coords = action_obj.get("end_coordinates")
if (not start_coords or not end_coords) and "coordinates" in action_obj:
coords_list = action_obj.get("coordinates")
if isinstance(coords_list, list) and len(coords_list) == 4:
start_coords = coords_list[:2]
end_coords = coords_list[2:]
return f"Swiped from {start_coords} to {end_coords}{duration_str}"
elif act_type == "press_key":
return f"Pressed key '{keycode}'"
elif act_type == "launch_app":
return f"Launched app '{app_name}'"
elif act_type == "stop_app":
return f"Stopped app '{app_name}'"
elif act_type == "manage_app":
intent = action_obj.get("intent")
return f"Managed app '{app_name}' (action: {intent})"
elif act_type == "wait_for_delay":
delay_ms = (
action_obj.get("delay_ms") or action_obj.get("time_in_ms") or action_obj.get("duration")
)
if not delay_ms:
delay_sec = action_obj.get("delay_seconds") or action_obj.get("delay")
if delay_sec:
try:
delay_ms = int(float(delay_sec) * 1000)
except ValueError:
delay_ms = f"{delay_sec}s"
delay_val = (
f"{delay_ms}ms"
if isinstance(delay_ms, int) or (isinstance(delay_ms, str) and delay_ms.isdigit())
else str(delay_ms)
)
return f"Waited for {delay_val}"
elif act_type in ("click_sequence", "tap_sequence"):
seq = action_obj.get("target") or action_obj.get("sequence") or []
return f"Tapped sequence of targets: {seq}"
else:
return f"Action: {act_type} with args: {json.dumps(action_obj, ensure_ascii=False)}"
def format_actions_clean(actions) -> str:
"""Renders one action or a fast-action burst (a list of 2+ actions)."""
if isinstance(actions, list):
if not actions:
return "No Action"
if len(actions) > 1:
steps = " -> ".join(format_action_clean(a) for a in actions)
return f"Fast-action burst ({len(actions)} actions, unvetted): {steps}"
return format_action_clean(actions[0])
return format_action_clean(actions)
def _is_terminal_attempt_failure(attempts) -> bool:
if not attempts:
return False
last = str(attempts[-1])
return last != "Success" and not last.startswith("Skipped")
def failed_execution_error(result_obj) -> str | None:
"""The error text of the action that failed in an execution report, if any."""
if not isinstance(result_obj, dict):
return None
exec_list = result_obj.get("execution") or result_obj.get("executed_actions") or []
if isinstance(exec_list, list):
for entry in exec_list:
if isinstance(entry, dict) and _is_terminal_attempt_failure(entry.get("attempts")):
return " | ".join(str(a) for a in entry["attempts"])
first = exec_list[0] if exec_list and isinstance(exec_list[0], dict) else {}
if first.get("error"):
return str(first["error"])
error = result_obj.get("error") or result_obj.get("error_msg")
return str(error) if error else None
def format_incident_clean(incident: dict) -> str:
"""One-line rendering of an execution incident for history and result lines."""
kind = incident.get("kind") or "exec_error"
category = incident.get("category") or "general"
consecutive = incident.get("consecutive_failures") or 1
description = incident.get("action_description") or format_action_clean(incident.get("action"))
reason = str(incident.get("reason") or "").strip()
burst_size = int(incident.get("burst_size") or 1)
index = int(incident.get("action_index") or 0)
prefix = (
"Intercepted by Pre-Execution Safety Net" if kind == "safety_net" else "Execution failed"
)
where = f"burst action {index + 1}/{burst_size}" if burst_size > 1 else "action"
tail = ""
if burst_size > 1 and index < burst_size - 1:
tail = "; the remaining burst actions were not executed"
return (
f"Error: {prefix} ({category}, consecutive failure #{consecutive}) on"
f" {where} `{description}`: {reason}{tail}"
)
def format_result_clean(result_obj) -> str | None:
if not result_obj:
return None
if not isinstance(result_obj, dict):
return str(result_obj)
incident = result_obj.get("incident")
if isinstance(incident, dict) and incident.get("reason"):
return format_incident_clean(incident)
status = result_obj.get("status")
error = failed_execution_error(result_obj)
if status in ("failed", "error"):
return f"Error: {error}" if error else "Error"
if error:
return f"Error: {error}"
return None
def safe_parse_validation_result(result: Any) -> list:
if isinstance(result, (list, tuple)):
return list(result)
if not isinstance(result, str):
return []
# Clean up enum references in string format
cleaned = re.sub(
r"<ValidationErrorCategory\.[A-Z_]+:\s*['\"]([^'\"]*)['\"]>",
r"'\1'",
result.strip(),
)
# Try parsing with ast.literal_eval
try:
parsed = ast.literal_eval(cleaned)
if isinstance(parsed, (list, tuple)):
return list(parsed)
except (ValueError, SyntaxError, TypeError, RecursionError):
pass
# Fallback to json.loads if literal_eval failed
try:
parsed = json.loads(cleaned)
if isinstance(parsed, (list, tuple)):
return list(parsed)
except ValueError:
pass
return []
def format_tool_call_clean(name, args, result) -> str | None:
ACTION_TOOLS = {
"click",
"click_sequence",
"tap_sequence",
"swipe",
"input_text",
"focus_and_input_text",
"press_key",
"long_press",
"launch_app",
"manage_app",
"stop_app",
"wait_for_delay",
}
# Custom rendering for safety net validations
if name in ("safety_net_validation", "safety_net_pixel_validation"):
res_list = safe_parse_validation_result(result)
if len(res_list) >= 3:
passed, category, detail = res_list[0], res_list[1], res_list[2]
if passed:
return "Passed"
else:
return f"Failed - {str(detail).strip()}"
return "Passed" if result else "Failed"
if name in ("read_note", "save_note", "update_note") and args.get("key") == "task_plan":
return None
# Custom rendering for all Action Tools
if name in ACTION_TOOLS:
action_name = name
# Convert arguments to action_obj format with thorough parameter mapping
action_obj = {
"action": action_name,
"intent": args.get("intent") or args.get("action"),
"target": (args.get("target") or args.get("coordinates") or args.get("sequence")),
"target_text": (
args.get("text") or args.get("target_text") or args.get("target_text_or_desc")
),
"text": args.get("text"),
"clear_exist": (args.get("clear_exist") or args.get("clear_before_input")),
"clear_before_input": args.get("clear_before_input"),
"keycode": args.get("keycode") or args.get("key"),
"key": args.get("key"),
"app_name": (
args.get("app_name")
or args.get("package_name")
or args.get("package")
or args.get("package_or_bundle_id")
or args.get("bundle_id")
),
"delay": (args.get("delay") or args.get("delay_ms") or args.get("time_in_ms")),
"delay_ms": args.get("delay_ms"),
"time_in_ms": args.get("time_in_ms"),
"duration": (args.get("duration") or args.get("duration_ms") or args.get("time_in_ms")),
"duration_ms": args.get("duration_ms"),
"times": args.get("times") or args.get("click_times"),
"click_times": args.get("click_times"),
"direction": args.get("direction") or args.get("action_direction"),
"action_direction": args.get("action_direction"),
}
# Handle special FA swipe "action" parameter
if action_name == "swipe" and "action" in args:
action_val = args["action"]
if isinstance(action_val, list) and len(action_val) == 4:
action_obj["coordinates"] = action_val
elif isinstance(action_val, str):
action_obj["action_direction"] = action_val
# Call format_action_clean to get factual natural language action
action_clean = format_action_clean(action_obj)
# Determine if there was an error in the result
error_detail = None
if isinstance(result, dict):
error_detail = result.get("error") or result.get("message")
elif isinstance(result, (list, tuple)) and result:
first_val = result[0]
if isinstance(first_val, str) and any(
x in first_val.lower() for x in ("error", "failed", "timeout")
):
error_detail = first_val
elif isinstance(result, str):
res_lower = result.lower()
if "error" in res_lower or "failed" in res_lower or "timeout" in res_lower:
error_detail = result
if error_detail:
error_clean = str(error_detail).strip()
# If the error is formatted as a Python tuple string, e.g. "('Error...', None, None, None)"
if error_clean.startswith("(") and error_clean.endswith(")"):
parts = error_clean[1:-1].split(",")
if parts:
first_part = parts[0].strip()
if (first_part.startswith("'") and first_part.endswith("'")) or (
first_part.startswith('"') and first_part.endswith('"')
):
first_part = first_part[1:-1]
error_clean = first_part
for prefix in (
"Error executing click: ",
"Error during click: ",
"Swipe failed: ",
"Error during swipe: ",
"Error executing key press: ",
"Error executing app launch: ",
"Error launching app: ",
):
if error_clean.startswith(prefix):
error_clean = error_clean[len(prefix) :]
return f"{action_clean} -> (Execution failed: {error_clean})"
else:
return action_clean
clean_args = {k: v for k, v in args.items() if k not in ("state", "tool_call_id")}
args_str = json.dumps(clean_args, ensure_ascii=False)
# Clean the result
res_str = ""
if isinstance(result, str) and result.startswith("["):
try:
parsed = json.loads(result)
if isinstance(parsed, list):
result = parsed
except (json.JSONDecodeError, TypeError):
pass
if isinstance(result, list):
text_blocks = []
for block in result:
if isinstance(block, dict):
if block.get("type") == "text":
text_blocks.append(block.get("text", "").strip())
elif block.get("type") == "image_url":
pass
if text_blocks:
res_str = "\n".join(text_blocks)
else:
res_str = str(result)
elif isinstance(result, dict):
error = result.get("error")
if error:
res_str = f"Error: {error}"
else:
res_str = json.dumps(result, ensure_ascii=False)
if len(res_str) > 120:
res_str = res_str[:120] + "..."
else:
res_str = str(result)
if len(res_str) > 120:
res_str = res_str[:120] + "..."
return f"`{name}({args_str})` -> {res_str}"
def _detect_interception(interleaved: list, result: Any) -> tuple[bool, str | None]:
"""Whether the pre-execution safety net intercepted the step's action."""
for event in interleaved or []:
if event.get("type") == "tool_call" and event.get("name") in (
"safety_net_validation",
"safety_net_pixel_validation",
):
res_list = safe_parse_validation_result(event.get("result"))
if len(res_list) > 0 and not res_list[0]:
detail = res_list[2] if len(res_list) >= 3 else None
return True, detail
break
# Fallback on the result structure (legacy / non-interleaved steps).
if isinstance(result, dict):
incident = result.get("incident")
if isinstance(incident, dict) and incident.get("kind") == "safety_net":
return True, incident.get("reason")
exec_list = result.get("execution") or []
if exec_list and isinstance(exec_list[0], dict):
attempts = exec_list[0].get("attempts") or []
if attempts and any(
"pre-execution validation" in str(att).lower()
or "validation failed" in str(att).lower()
for att in attempts
):
return True, attempts[0]
return False, None
def format_step_action_result(step: dict) -> str:
action = step.get("action_taken")
action_clean = format_actions_clean(action) if action else "No Action"
result_obj = step.get("last_execution_result")
intercepted, safety_net_detail = _detect_interception(
step.get("interleaved_events") or [], result_obj
)
output_parts = [action_clean]
if intercepted:
detail_msg = f": {str(safety_net_detail).strip()}" if safety_net_detail else ""
output_parts.append(f"(Intercepted by Pre-Execution Safety Net{detail_msg})")
elif isinstance(result_obj, dict) and result_obj.get("status") == "failed":
error_msg = failed_execution_error(result_obj)
if error_msg:
output_parts.append(f"(Execution failed: {str(error_msg).strip()})")
return " -> ".join(output_parts)
def _burst_member_status(result: Any, index: int) -> str:
"""Per-action outcome suffix for one member of a fast-action burst."""
if not isinstance(result, dict):
return ""
exec_list = result.get("execution") or []
if index >= len(exec_list) or not isinstance(exec_list[index], dict):
return " (not executed)"
attempts = exec_list[index].get("attempts") or []
if not attempts:
return " (executed)"
last = str(attempts[-1])
if last == "Success":
return " (executed)"
if last.startswith("Skipped"):
return " (skipped)"
return f" (FAILED: {last.strip()})"
def _render_step_detailed(
step: dict,
relative_time: str,
summary: str,
action: Any,
result: Any,
is_most_recent: bool,
) -> str:
# 1. Step Header
status_str = "Most Recent Step, " if is_most_recent else ""
# Summary is omitted from the detailed view header as it is already fully detailed below.
step_line = f"- **Step {step['step_number']} ({status_str}Start: {relative_time})**"
interleaved = step.get("interleaved_events") or []
# 2. Determine if Safety Net validation failed (intercepted)
intercepted, safety_net_detail = _detect_interception(interleaved, result)
# 3. Collect the Operator's own events (system guardians are rendered separately)
operator_events = []
INTERNAL_SYSTEM_TOOLS = {
"safety_net_validation",
"safety_net_pixel_validation",
"hopper",
}
if interleaved:
for event in interleaved:
e_type = event.get("type")
name = event.get("name") or ""
if e_type in ("thought", "native_thought"):
operator_events.append(event)
elif (
e_type == "tool_call"
and name not in INTERNAL_SYSTEM_TOOLS
and not name.startswith("_exec_")
):
operator_events.append(event)
else:
# Fallback for legacy/non-interleaved steps
raw_thinking = step.get("operator_raw_thinking")
native_thinking = step.get("operator_native_thinking")
if native_thinking:
operator_events.append({"type": "native_thought", "content": native_thinking})
if raw_thinking:
operator_events.append({"type": "thought", "content": raw_thinking})
legacy_tool_calls = step.get("tool_calls") or []
for tc in legacy_tool_calls:
name = tc.get("name") or ""
if name in INTERNAL_SYSTEM_TOOLS or name.startswith("_exec_"):
continue
payload = tc.get("payload") or {}
tc_args = payload.get("args") or tc.get("args") or {}
tc_result = (
payload.get("result") or payload.get("error") or tc.get("result") or "No result"
)
operator_events.append(
{
"type": "tool_call",
"name": name,
"args": tc_args,
"result": tc_result,
}
)
# Helper to check if a thought content is already in operator_events
def thought_exists(content):
content_clean = content.strip().lower()
for ev in operator_events:
if (
ev.get("type") in ("thought", "native_thought")
and ev.get("content", "").strip().lower() == content_clean
):
return True
return False
# Extract thought from final physical action if present
action_thought = None
if action:
action_parsed = action[0] if isinstance(action, list) and action else action
if isinstance(action_parsed, dict):
action_thought = action_parsed.get("thought")
if action_thought and action_thought.strip() and not thought_exists(action_thought):
operator_events.insert(0, {"type": "thought", "content": action_thought.strip()})
# Extract native thinking from LLM thinking block first if not already present
native_thinking = step.get("operator_native_thinking")
if native_thinking and native_thinking.strip() and not thought_exists(native_thinking):
operator_events.append({"type": "native_thought", "content": native_thinking.strip()})
# Extract raw thinking from LLM text content second if not already present
raw_thinking = step.get("operator_raw_thinking")
if raw_thinking and raw_thinking.strip() and not thought_exists(raw_thinking):
operator_events.append({"type": "thought", "content": raw_thinking.strip()})
# 4. Render Operator Loop
if operator_events:
step_line += "\n * [Operator Decision Loop]:"
ACTION_TOOLS = {
"click",
"swipe",
"input_text",
"press_key",
"long_press",
"launch_app",
"wait_for_delay",
}
for event in operator_events:
e_type = event["type"]
content = event.get("content") or ""
name = event.get("name") or ""
if not content.strip() and e_type != "tool_call":
continue
if e_type in ("native_thought", "thought"):
cleaned = re.sub(
r"<short_term_memory>.*?</short_term_memory>",
"",
content,
flags=re.DOTALL,
)
cleaned = re.sub(r"</?thought>", "", cleaned, flags=re.DOTALL).strip()
if cleaned:
step_line += f"\n - {cleaned}"
elif e_type == "tool_call":
if name in ACTION_TOOLS:
continue
formatted = format_tool_call_clean(
name, event.get("args") or {}, event.get("result")
)
if formatted:
step_line += f"\n - [Tool Call]: {formatted}"
# Check if the execution failed
exec_error = None
if isinstance(result, dict) and result.get("status") in ("failed", "error"):
exec_error = failed_execution_error(result)
# 5. Render Planned Action (single vetted action or fast-action burst)
if action:
actions = action if isinstance(action, list) else [action]
if len(actions) > 1:
step_line += (
f"\n * [Planned Fast-Action Burst]: {len(actions)} actions fired back to"
" back without the safety net"
)
for i, member in enumerate(actions):
step_line += (
f"\n {i + 1}. {format_action_clean(member)}{_burst_member_status(result, i)}"
)
else:
action_clean = format_action_clean(actions[0])
if intercepted:
step_line += (
f"\n * [Planned Action]: {action_clean}"
" (Intercepted by Pre-Execution Safety Net)"
)
elif exec_error:
step_line += (
f"\n * [Planned Action]: {action_clean} -> (Execution failed: {exec_error})"
)
else:
step_line += f"\n * [Planned Action]: {action_clean}"
# 6. Render Pre-Execution Safety Net
if intercepted:
step_line += "\n * [Pre-Execution Safety Net]:"
detail_str = (
f"(Intercepted by Pre-Execution Safety Net: {str(safety_net_detail).strip()})"
if safety_net_detail
else "(Intercepted by Pre-Execution Safety Net)"
)
step_line += f"\n - [Safety Net Check]: {detail_str}"
# 7. Validator Execution Result (an open incident renders as the result line)
if result:
result_clean = format_result_clean(result)
if result_clean:
step_line += f"\n * [Result]: {result_clean}"
return step_line
def build_plan_and_history(
task_plan: str,
steps: list,
current_subgoal_hash: str,
keep_subgoal_hashes: set | None = None,
min_summaries: int = 5,
last_n_detailed: int = 1,
all_detailed: bool = False,
strict_milestone_pruning: bool = False,
recent_window_size: int = 3,
chunks: list | None = None,
) -> str:
"""Builds a clean plan list and separate flat chronological execution history with adaptive compression.
``chunks`` (optional, M4): pre-rendered history-chunk blocks — each a dict
with ``start_step_number``/``end_step_number``/``text``. When provided,
the chunk blocks are emitted right after the history header and the
per-step lines inside any chunked range are dropped (the chunk block
replaces them), except steps that must render detailed (the recent
window / the most-recent step stay untouched). When ``chunks`` is
``None`` or empty the output is byte-identical to the pre-M4 rendering.
"""
# 1. Plan Checklist Section
output_parts = []
# Check if task_plan has actual subgoals (single-source checkbox grammar).
has_subgoals = bool(task_plan) and bool(parse_plan(task_plan).items)
if has_subgoals:
output_parts.append("--- Task Plan ---")
output_parts.append(task_plan)
output_parts.append("")
else:
# Print the plan fallback if not empty
if task_plan and task_plan != "No task plan yet.":
output_parts.extend(
[
"--- Task Plan ---",
task_plan,
"",
]
)
output_parts.append("--- Execution History ---")
# 2. Chronological Steps History Section
if not steps:
output_parts.append("No steps executed yet.")
else:
full_info_step_id = steps[-1]["step_id"]
visible_step_ids = set()
visible_step_ids.add(full_info_step_id)
# Calculate which steps are visible using milestone filtering or sliding window compression
if keep_subgoal_hashes is not None:
# Milestone-specific filtering (checker / diagnoser)
for step in steps:
meta = step.get("extra_metadata") or {}
step_subgoal_hash = meta.get("subgoal_hash")
if step_subgoal_hash in keep_subgoal_hashes:
visible_step_ids.add(step["step_id"])
else:
# Default sliding window compression or strict milestone pruning
if strict_milestone_pruning:
recent_step_ids = set()
actual_recent_n = min(len(steps), recent_window_size)
for step in steps[-actual_recent_n:]:
recent_step_ids.add(step["step_id"])
for i in range(len(steps) - 1, -1, -1):
step = steps[i]
step_id = step["step_id"]
if step_id == full_info_step_id:
continue
meta = step.get("extra_metadata") or {}
step_subgoal_hash = meta.get("subgoal_hash")
# Strict pruning: only keep if active subgoal OR in recent continuity window
if step_subgoal_hash == current_subgoal_hash or step_id in recent_step_ids:
visible_step_ids.add(step_id)
else:
summary_count = 0
for i in range(len(steps) - 1, -1, -1):
step = steps[i]
step_id = step["step_id"]
if step_id == full_info_step_id:
continue
meta = step.get("extra_metadata") or {}
step_subgoal_hash = meta.get("subgoal_hash")
# Keep steps under the active milestone, or slide back until min_summaries is met
if step_subgoal_hash == current_subgoal_hash or summary_count < min_summaries:
visible_step_ids.add(step_id)
summary_count += 1
detailed_step_ids = set()
# Pre-calculate steps that MUST be rendered in detailed mode (last N detailed)
if last_n_detailed > 0:
actual_n = min(len(steps), last_n_detailed)
for step in steps[-actual_n:]:
detailed_step_ids.add(step["step_id"])
# Ensure the last step is always in detailed_step_ids
detailed_step_ids.add(full_info_step_id)
# M4: chunk blocks replace the per-step lines of already-chunked
# ranges. Chunks only ever cover frozen (old) turns, so they are
# emitted first, in chronological order; steps that must render
# detailed (recent window / most-recent step) are never suppressed.
chunked_ranges: list[tuple[int, int]] = []
if chunks:
for chunk in sorted(chunks, key=lambda c: c.get("start_step_number") or 0):
text = chunk.get("text")
if not text:
continue
try:
start_n = int(chunk.get("start_step_number"))
end_n = int(chunk.get("end_step_number"))
except (TypeError, ValueError):
continue
chunked_ranges.append((start_n, end_n))
output_parts.append(text)
for step in steps:
step_id = step["step_id"]
# Skip steps that are compressed (not marked as visible)
if step_id not in visible_step_ids:
continue
if chunked_ranges and step_id not in detailed_step_ids and step_id != full_info_step_id:
step_number = step.get("step_number")
if isinstance(step_number, int) and any(
start_n <= step_number <= end_n for start_n, end_n in chunked_ranges
):
continue # represented by its chunk block above
relative_time = step.get("relative_time") or "N/A"
summary = step.get("summary")
action = step.get("action_taken")
thinking = step.get("operator_raw_thinking")
result = step.get("last_execution_result")
# Determine if this step should be detailed (e.g. current/recent step or missing summary)
render_as_detailed = (
all_detailed
or (step_id in detailed_step_ids)
or (not summary or summary == "Action executed.")
)
is_most_recent = step_id == full_info_step_id
if render_as_detailed:
step_line = _render_step_detailed(
step,
relative_time,
summary,
action,
result,
is_most_recent,
)
else:
step_line = f"- *Step {step['step_number']} (Start: {relative_time}): {summary}*"
if action:
action_clean = format_step_action_result(step)
step_line += f"\n *Action*: {action_clean}"
output_parts.append(step_line)
return "\n".join(output_parts)
def get_active_subgoal_hashes(task_plan: str) -> tuple[str, str | None]:
"""Parses task_plan content to find the active top-level subgoal hash.
Consolidates all active subtasks under their parent Level 1 subgoal,
returning (parent_hash, None). Falls back to the first pending subgoal if no
active subgoal is found and all are pending.
"""
if not task_plan:
return "default", None
try:
snapshot = parse_plan(task_plan)
# Bottom-most active item wins (most specific deeply nested subgoal)
active_item = snapshot.last_active()
if active_item is not None:
if not active_item.is_top_level:
parent = snapshot.parent_of(active_item)
if parent is not None:
return parent.key, None
return active_item.key, None
# No active subgoal found. Check if all are completed.
if all(item.is_done for item in snapshot.top_level):
return "default", None
# SAFE FALLBACK: if every item is untouched, the first pending one is active.
if all(item.is_pending for item in snapshot.top_level):
for item in snapshot.items:
if item.is_pending:
return item.key, None
except Exception as exc: # pylint: disable=broad-exception-caught
# Unparseable plan text falls back to the default bucket.
logger.debug("Active subgoal lookup fell back to default: %s", exc, exc_info=True)
return "default", None
def get_completed_subgoal_hashes(task_plan: str) -> set[str]:
"""Extracts MD5 hashes of all completed [x] top-level subgoals from task_plan."""
if not task_plan:
return set()
return {item.key for item in parse_plan(task_plan).top_level if item.is_done}
def get_all_subgoal_aliases(target_hash: str, base_dir: str | Path | None = None) -> set[str]:
"""Transitively resolves all older subgoal hashes that map to target_hash in subgoal_hash_chain.json."""
aliases = {target_hash}
if not base_dir or not isinstance(base_dir, (str, Path)):
return aliases
chain_path = Path(base_dir) / "notes" / "subgoal_hash_chain.json"
if not chain_path.exists():
return aliases
try:
chain = json.loads(chain_path.read_text(encoding="utf-8"))
except Exception:
return aliases
# Transitively resolve old_hash -> new_hash chains
for old_hash in chain:
curr = old_hash
visited = set()
while curr in chain and curr not in visited:
visited.add(curr)
curr = chain[curr]
if curr == target_hash:
aliases.add(old_hash)
return aliases
def get_recent_subgoal_hashes(
steps: list, current_subgoal_hash: str, base_dir: str | Path | None = None
) -> set[str]:
"""Helper to get a set of subgoal hashes containing the current active subgoal hash,
its historical failed/renamed aliases (resolved via
subgoal_hash_chain.json),
and the most recent completed subgoal hash as transition context.
"""
# 1. Resolve all alias hashes for the active subgoal
aliases = get_all_subgoal_aliases(current_subgoal_hash, base_dir)
keep_hashes = set(aliases)
# 2. Find the most recent completed subgoal in history
# (meaning a step whose hash is NOT an alias of the active subgoal)
recent_completed_hash = None
for step in reversed(steps):
meta = step.get("extra_metadata") or {}
s_hash = meta.get("subgoal_hash")
if s_hash and s_hash not in aliases:
recent_completed_hash = s_hash
break
if recent_completed_hash:
keep_hashes.add(recent_completed_hash)
return keep_hashes