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.
929 lines
36 KiB
Python
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
|