Files
Artemis-Eapil/artemis/utils/task_tree.py
T

1081 lines
41 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
from pathlib import Path
import re
from typing import Any
from artemis.utils.plan_grammar import parse_plan
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_result_clean(result_obj) -> str | None:
if not result_obj:
return None
if not isinstance(result_obj, dict):
return str(result_obj)
status = result_obj.get("status")
repair_status = result_obj.get("repair_status")
exec_list = result_obj.get("execution") or result_obj.get("executed_actions")
if isinstance(exec_list, list) and exec_list:
first_exec = exec_list[0]
if isinstance(first_exec, dict):
attempts = first_exec.get("attempts")
error = first_exec.get("error") or result_obj.get("error")
repair = first_exec.get("repair")
if repair:
if repair_status == "fixed":
return f"Repaired: {repair}"
else:
return f"Error: {repair}"
elif attempts and status == "failed":
return f"Error: {' | '.join(attempts)}"
elif error:
return f"Error: {error}"
elif status == "error":
return "Error"
else:
return None
error = result_obj.get("error")
if error:
return f"Error: {error}"
elif status == "error":
return "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 Exception:
pass
# Fallback to json.loads if literal_eval failed
try:
parsed = json.loads(cleaned)
if isinstance(parsed, (list, tuple)):
return list(parsed)
except Exception:
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",
"_exec_click",
"_exec_click_sequence",
"_exec_swipe",
"_exec_input_text",
"_exec_press_key",
"_exec_long_press",
"_exec_manage_app",
"_exec_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"
# Custom rendering for Operator call to failure_analyzer agent
if name == "failure_analyzer":
clean_args = {
k: v
for k, v in args.items()
if k
not in (
"state",
"tool_call_id",
"pre_screenshot",
"post_screenshot",
)
}
args_str = json.dumps(clean_args, ensure_ascii=False)
if len(args_str) > 120:
args_str = args_str[:120] + "..."
if isinstance(result, dict):
status = result.get("status", "unknown")
analysis = result.get("analysis") or result.get("reason") or "No analysis provided."
else:
status = args.get("status", "unknown")
analysis = args.get("analysis", "No analysis provided.")
if len(analysis) > 120:
analysis = analysis[:120].strip() + "..."
return f"`failure_analyzer({args_str})` -> Outcome: {status.upper()} (Analysis: {analysis})"
# Custom rendering for Failure Analyzer final report
if name == "report_failure_analysis":
status = args.get("status", "unknown")
analysis = args.get("analysis", "No analysis provided.")
if len(analysis) > 120:
analysis = analysis[:120].strip() + "..."
return f"Outcome: {status.upper()} (Analysis: {analysis})"
if name in ("read_note", "save_note", "update_note") and args.get("key") == "task_plan":
return None
# Custom rendering for all Action Tools (including _exec_ tools)
if name in ACTION_TOOLS:
action_name = name[6:] if name.startswith("_exec_") else 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 format_step_action_result(step: dict) -> str:
action = step.get("action_taken")
action_parsed = action[0] if isinstance(action, list) and action else action
action_clean = "No Action"
if action_parsed:
action_clean = format_action_clean(action_parsed)
intercepted = False
safety_net_detail = None
interleaved = step.get("interleaved_events") or []
for event in interleaved:
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]:
intercepted = True
if len(res_list) >= 3:
safety_net_detail = res_list[2]
break
if not intercepted:
result_obj = step.get("last_execution_result")
if isinstance(result_obj, dict):
exec_list = result_obj.get("execution") or []
if exec_list:
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
):
intercepted = True
safety_net_detail = attempts[0]
recovery_actions = []
for event in interleaved:
e_type = event.get("type")
name = event.get("name") or ""
if (
e_type == "tool_call"
and name.startswith("_exec_")
and name != "report_failure_analysis"
):
formatted = format_tool_call_clean(name, event.get("args") or {}, event.get("result"))
if formatted:
recovery_actions.append(formatted)
if not recovery_actions:
legacy_tool_calls = step.get("tool_calls") or []
for tc in legacy_tool_calls:
name = tc.get("name") or ""
if name.startswith("_exec_") and name != "report_failure_analysis":
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"
)
formatted = format_tool_call_clean(name, tc_args, tc_result)
if formatted:
recovery_actions.append(formatted)
output_parts = [action_clean]
if intercepted:
detail_msg = f": {str(safety_net_detail).strip()}" if safety_net_detail else ""
output_parts.append(f"(Action not executed{detail_msg})")
if not intercepted and not recovery_actions:
result_obj = step.get("last_execution_result")
if isinstance(result_obj, dict) and result_obj.get("status") == "failed":
error_msg = None
exec_list = result_obj.get("execution") or []
if exec_list and isinstance(exec_list[0], dict):
error_msg = exec_list[0].get("error")
if not error_msg:
error_msg = result_obj.get("error")
if error_msg:
output_parts.append(f"(Execution failed: {str(error_msg).strip()})")
if recovery_actions:
recovery_str = ", ".join(recovery_actions)
output_parts.append(f"Recovery Actions: [{recovery_str}]")
return " -> ".join(output_parts)
def _render_step_detailed(
step: dict,
relative_time: str,
summary: str,
action: Any,
result: Any,
is_most_recent: bool,
for_failure_analyzer: bool = False,
) -> str:
# 1. Step Header
if is_most_recent and for_failure_analyzer:
status_str = (
"Most Recent Step (Failed to execute, this is the step you need to"
" focus on and repair), "
)
else:
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 = False
safety_net_detail = None
for event in interleaved:
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]:
intercepted = True
if len(res_list) >= 3:
safety_net_detail = res_list[2]
break
# Fallback check on result structure for legacy/non-interleaved steps
if not intercepted and result:
exec_list = result.get("execution") or []
if exec_list:
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
):
intercepted = True
safety_net_detail = attempts[0]
# 3. Categorize events into Operator and Failure Analyzer
operator_events = []
failure_analyzer_events = []
# Internal tools list to filter out from Operator
INTERNAL_SYSTEM_TOOLS = {
"safety_net_validation",
"safety_net_pixel_validation",
"failure_analyzer",
"report_failure_analysis",
"hopper",
}
if interleaved:
for event in interleaved:
e_type = event.get("type")
name = event.get("name") or ""
content = event.get("content") or ""
# Skip the failure_analyzer agent call itself to prevent duplicates
if name == "failure_analyzer":
continue
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)
elif e_type in (
"failure_analyzer_thought",
"failure_analyzer_native_thought",
):
failure_analyzer_events.append(event)
elif e_type == "tool_call" and (
name.startswith("_exec_") or name == "report_failure_analysis"
):
failure_analyzer_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")
if name in INTERNAL_SYSTEM_TOOLS or name.startswith("_exec_"):
# If it's failure analyzer tools in legacy steps, place them in failure analyzer
if name.startswith("_exec_") or name == "report_failure_analysis":
# Convert to interleaved style format
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"
)
failure_analyzer_events.append(
{
"type": "tool_call",
"name": name,
"args": tc_args,
"result": tc_result,
}
)
else:
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 and (not for_failure_analyzer or is_most_recent):
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 result and isinstance(result, dict):
status = result.get("status")
if status == "failed" or status == "error":
exec_list = result.get("execution") or result.get("executed_actions")
if isinstance(exec_list, list) and exec_list and isinstance(exec_list[0], dict):
first_exec = exec_list[0]
attempts = first_exec.get("attempts")
if attempts:
exec_error = " | ".join(attempts)
else:
exec_error = first_exec.get("error") or result.get("error")
else:
exec_error = result.get("error")
# 5. Render Planned Action
if action:
action_clean = format_action_clean(action)
if intercepted:
step_line += (
f"\n * [Planned Action]: {action_clean} (Intercepted by Pre-Execution Safety Net)"
)
else:
if 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"(Action not executed: {str(safety_net_detail).strip()})"
if safety_net_detail
else "(Action not executed)"
)
step_line += f"\n - [Safety Net Check]: {detail_str}"
# 7. Render Failure Analyzer Recovery Loop
if failure_analyzer_events:
step_line += "\n * [Failure Analyzer Recovery Loop]:"
for event in failure_analyzer_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 (
"failure_analyzer_native_thought",
"failure_analyzer_thought",
"failure_analyzer_monologue",
):
cleaned = content.strip()
if cleaned:
step_line += f"\n - {cleaned}"
elif e_type == "tool_call":
if name == "report_failure_analysis":
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 Failure Analyzer was active and reported
has_fa = bool(failure_analyzer_events)
# We consider FA as reported if it called the report tool,
# OR if the step's last_execution_result already contains a legacy repair description!
has_repair_in_result = False
if result and isinstance(result, dict):
exec_list = result.get("execution") or result.get("executed_actions")
if isinstance(exec_list, list) and exec_list and isinstance(exec_list[0], dict):
if exec_list[0].get("repair"):
has_repair_in_result = True
fa_reported = (
any(
event.get("type") == "tool_call" and event.get("name") == "report_failure_analysis"
for event in failure_analyzer_events
)
or has_repair_in_result
)
# 8. Validator Execution Result
if has_fa and not fa_reported:
step_line += (
"\n * [Result]: Unknown repair outcome (Failure Analyzer did not"
" submit a final report)"
)
elif 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,
for_failure_analyzer: bool = False,
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 (e.g. starts with '- [')
has_subgoals = task_plan and any(
line.strip().startswith("- [") for line in task_plan.split("\n")
)
if has_subgoals:
output_parts.append("--- Task Plan ---")
output_parts.append(task_plan)
if for_failure_analyzer:
output_parts.append(
"*(Note: Provided for context only. Do not execute the remaining plan.)*"
)
output_parts.append("")
elif not for_failure_analyzer:
# For non-failure_analyzer context, print 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,
for_failure_analyzer,
)
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:
pass
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