Files
Artemis-Eapil/artemis/agents/operator/prompts.py
T
somew1nd a45daf23f8 feat: add agentic video analysis and clarify action results
Report device actions as dispatched and preserve full failure details. Route
Flash through McpActionExecutor and use ToolFailure across helper tools and
agent loops instead of inferring failure from response text.

Require model-provided descriptions for coordinate targets. Keep observed
element metadata separate from those descriptions in safety checks, visual
summaries, history, and frontend labels.

Add Gemini Interactions API video analysis with configurable processing mode,
chunk size, and timeouts. Centralize Google provider capabilities and token
usage normalization, and update google-genai to 2.22.0.

Preserve system instructions on follow-up interactions, replan pending chunks
when falling back to static processing, and reject unanswered native video
and audio segments instead of caching placeholder results. Keep helper-tool
failure status intact through history tools and committee calls.

Validation: 1,762 Python unit tests passed (3 skipped), 40 frontend formatter
tests passed, and the production UI build completed. Ruff lint, formatting,
the quality ratchet, and git diff checks passed.
2026-09-08 17:05:54 -07:00

952 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 base64
from functools import lru_cache
import io
import json
from pathlib import Path
from jinja2 import Environment, StrictUndefined, Template
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.tools.history import HISTORY_TOOL_NAMES
from PIL import Image
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.command_tool import (
_format_long_output_response,
_is_output_long,
)
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.plan_grammar import parse_plan, render_plan_grammar_spec
from artemis.utils.task_tree import SELF_DESCRIBED_MARKER
logger = get_logger(__name__)
from artemis.agents.prompt_assembly import render_tool_enum, resolve_available
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER
@lru_cache(maxsize=1)
def load_operator_prompts() -> dict[str, str]:
"""Load and cache the Operator templates from operator.json."""
prompts_path = Path(__file__).with_name("operator.json")
return json.loads(prompts_path.read_text(encoding="utf-8"))
# --- Assembled tool references -------------------------------------------------------
# The operator.json templates carry availability slots rendered by
# ``apply_operator_prompt_contract``: ``[[ tool_enum(...) ]]`` expands an ordered,
# backticked enumeration limited to the available tool set, and
# ``[% if "x" in available_tools %]...[% endif %]`` gates a self-contained teaching
# segment. An unavailable tool therefore leaves no trace in the prompt at all. The
# slot delimiters are square-bracketed so the standard ``{{ ... }}`` context
# variables (initial_goal, plan_and_history, plan_grammar, ...) pass through
# untouched for the later context render.
#
# The orderings below reproduce the historical prompt wording exactly; with the full
# tool set the rendered output is byte-identical to the pre-assembly literals. See
# artemis/agents/prompt_assembly.py for the assembly rationale.
_PRE_DECISION_HELPER_TOOLS = ("ask_explorer", "ask_diagnoser", "video_analyzer")
_PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task")
_PRE_DECISION_MEMORY_TOOLS = (
"read_note",
"list_notes",
"search_history",
"replay_steps",
"get_step_screenshot",
)
_PRE_DECISION_ALL_TOOLS = (
_PRE_DECISION_HELPER_TOOLS + _PRE_DECISION_ADB_TOOLS + _PRE_DECISION_MEMORY_TOOLS
)
# The two device-action enumeration slots in operator.json (identical in both
# templates), in their historical orders. The physical order is the canonical
# manifest's shell-binding order; the turn-ending order is a prompt property.
_PHYSICAL_ACTIONS_ORDER = OPERATOR_SHELL_ORDER
_TURN_ENDING_ORDER = (
"click",
"swipe",
"input_text",
"long_press",
"press_key",
"manage_app",
"wait_for_delay",
)
#: Tool-loop ceiling per Operator turn (recited in the template, enforced in
#: ``OperatorNode._invoke_llm_loop``).
OPERATOR_MAX_TOOL_ITERATIONS = 20
#: Every tool name the operator prompt slots can reference. ``available_tools=None``
#: resolves to this set, preserving the historical output.
OPERATOR_PROMPT_TOOLSET: frozenset[str] = frozenset(
_PRE_DECISION_ALL_TOOLS + _PHYSICAL_ACTIONS_ORDER
)
#: Renders the availability slots in operator.json. Square-bracket delimiters keep
#: the standard ``{{ ... }}`` context placeholders inert during this phase.
_CONTRACT_ENV = Environment(
block_start_string="[%",
block_end_string="%]",
variable_start_string="[[",
variable_end_string="]]",
comment_start_string="[#",
comment_end_string="#]",
keep_trailing_newline=True,
undefined=StrictUndefined,
)
def apply_operator_prompt_contract(
prompt_template: str,
available_tools: frozenset[str] | None = None,
) -> str:
"""Renders a template's availability slots against the actually-available tools.
Args:
prompt_template: One of the raw operator.json templates.
available_tools: Tools that actually exist this run. ``None`` renders with the
full historical tool set, producing byte-identical output to the
pre-assembly contract. Unavailable tools disappear from every enumeration
slot, and instruction segments teaching them are removed wholesale.
"""
available = frozenset(resolve_available(available_tools, OPERATOR_PROMPT_TOOLSET))
def tool_enum(names, final_sep: str | None = None) -> str:
return render_tool_enum(tuple(names), available, final_sep=final_sep)
return _CONTRACT_ENV.from_string(prompt_template).render(
available_tools=available,
tool_enum=tool_enum,
pre_decision_tools=_PRE_DECISION_ALL_TOOLS,
helper_tools=_PRE_DECISION_HELPER_TOOLS,
adb_tools=_PRE_DECISION_ADB_TOOLS,
memory_tools=_PRE_DECISION_MEMORY_TOOLS,
physical_actions=_PHYSICAL_ACTIONS_ORDER,
turn_ending_actions=_TURN_ENDING_ORDER,
)
class PromptBuilder:
def __init__(self):
self.system_parts = []
self.human_parts = []
self.human_footer = None
def add_system_text(self, text: str):
self.system_parts.append(text)
def add_human_content(self, content: str | dict):
self.human_parts.append(content)
def set_human_footer(self, content: str):
self.human_footer = content
def build(self) -> list[SystemMessage | HumanMessage]:
system_content = "".join(self.system_parts)
human_content = []
for p in self.human_parts:
if isinstance(p, str):
human_content.append({"type": "text", "text": p})
else:
human_content.append(p)
if self.human_footer:
human_content.append({"type": "text", "text": self.human_footer})
return [
SystemMessage(content=system_content),
HumanMessage(content=human_content),
]
class PromptComponent:
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
raise NotImplementedError
def resolve_operator_prompt_tools(ctx: ArtemisContext) -> frozenset[str]:
"""The tool set the operator prompt may advertise for this run.
Extracted verbatim from the template component so the transcript static
prefix (M2) and the legacy per-turn render assemble against the identical
availability set.
"""
# video_analyzer is bound conditionally (graph.py gates it on
# video_recording_tools_enabled); the prompt must not advertise it when the
# tool is not actually available this run.
available = set(OPERATOR_PROMPT_TOOLSET)
setup = getattr(ctx, "execution_setup", None)
if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
available.discard("video_analyzer")
# The history tools need a DataEngine to read, and search_history is also
# config-gated; the prompt must not advertise a tool that is not bound.
if getattr(ctx, "data_engine", None) is None:
available.difference_update(HISTORY_TOOL_NAMES)
elif not _recall_enabled():
available.discard("search_history")
# Device actions the installed actuator backend does not implement disappear
# from the prompt in lockstep with their tool declarations.
actuator = getattr(ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
from artemis.mcp.action_manifest import (
DEVICE_ACTIONS,
available_device_actions,
)
available -= DEVICE_ACTIONS - available_device_actions(actuator)
except Exception as e:
logger.warning(f"Failed to assemble prompt against actuator: {e}")
return frozenset(available)
def _operator_grammar_flags(ctx: ArtemisContext) -> tuple[bool, bool]:
"""(include_checks, verification_active) for the template render."""
setup = getattr(ctx, "execution_setup", None)
# Grammar spec assembly is a function of configuration: with both check
# gates disabled, the check-line grammar never enters any prompt.
include_checks = bool(setup and getattr(setup, "checks_enabled", False))
# The rejection/finding diagnosis trigger only exists while a mechanism
# that can produce rejections or findings is active.
verification_active = include_checks or bool(
setup and not getattr(setup, "disable_planner_validation", True)
)
return include_checks, verification_active
# --- M2 template split -----------------------------------------------------------
# The only volatile span of operator.json's main_template is the plan+history
# section below. The transcript path replaces it with a static pointer so the
# whole system message becomes a byte-stable S region; the legacy path renders
# the untouched template and stays byte-identical.
#: The volatile section of ``main_template`` (must occur exactly once).
PLAN_HISTORY_TEMPLATE_SECTION = "## Current Plan & Execution History\n{{ plan_and_history }}"
#: Static replacement used by the transcript S region.
PLAN_HISTORY_STATIC_POINTER = (
"## Current Plan & Execution History\n"
"Provided in the conversation that follows: earlier turns carry the raw"
" step-by-step execution history (a restored-history block precedes them"
" after a process restart), and each turn's observation message recites"
" the current task plan."
)
def render_transcript_static_system(
prompts: dict,
ctx: ArtemisContext,
state: State,
template_name: str = "main_template",
) -> str:
"""Render the transcript path's byte-stable static system prompt (S region).
Same template, same availability assembly, and the same render inputs as
the legacy path — except the volatile plan+history section is swapped for
a static pointer, so the output never changes across the session.
"""
prompt_template = prompts.get(template_name)
if not prompt_template:
raise KeyError("Failed to format prompt, template not found in operator prompts config.")
if prompt_template.count(PLAN_HISTORY_TEMPLATE_SECTION) != 1:
# Hard dependency of the M2 split (redesign §9): without a clean
# section boundary the S region cannot be byte-stable.
raise ValueError(
"operator main_template no longer contains exactly one plan+history"
" section; the transcript static split cannot be applied."
)
static_template = prompt_template.replace(
PLAN_HISTORY_TEMPLATE_SECTION, PLAN_HISTORY_STATIC_POINTER
)
available = resolve_operator_prompt_tools(ctx)
static_template = apply_operator_prompt_contract(static_template, available_tools=available)
include_checks, verification_active = _operator_grammar_flags(ctx)
return Template(static_template).render(
initial_goal=state.initial_goal,
subgoals_status="",
plan_and_history="",
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
checks_active=include_checks,
transcript_history=True,
max_burst_actions=_max_burst_actions_for_prompt(),
max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
)
def _legacy_elapsed_suffix(ctx: ArtemisContext | None) -> str:
"""`` [T+mm:ss]`` for the legacy observation header (empty without a session clock)."""
import time
from artemis.memory.transcript import format_session_offset
engine = getattr(ctx, "data_engine", None)
start = getattr(engine, "session_start_time", None)
if not isinstance(start, (int, float)):
return ""
return f" [{format_session_offset(time.time() - start)}]"
class TemplatePromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
prompts = kwargs.get("prompts", {})
template_name = kwargs.get("template_name", "main_template")
prompt_template = prompts.get(template_name)
if not prompt_template:
raise KeyError(
"Failed to format prompt, template not found in operator prompts config."
)
available = resolve_operator_prompt_tools(ctx)
prompt_template = apply_operator_prompt_contract(prompt_template, available_tools=available)
plan_and_history = kwargs.get("plan_and_history", "No plan or history yet.")
include_checks, verification_active = _operator_grammar_flags(ctx)
full_prompt = Template(prompt_template).render(
initial_goal=state.initial_goal,
subgoals_status="",
plan_and_history=plan_and_history,
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
checks_active=include_checks,
transcript_history=False,
max_burst_actions=_max_burst_actions_for_prompt(),
max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
)
parts = full_prompt.split("# CURRENT OBSERVATION")
builder.add_system_text(parts[0] + f"# CURRENT OBSERVATION{_legacy_elapsed_suffix(ctx)}\n")
if len(parts) > 1:
builder.set_human_footer(parts[1])
class ObservationPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
minimal_list = kwargs.get("minimal_list")
builder.add_human_content("--- Current Screenshot ---")
builder.add_human_content(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{latest_screenshot_b64}"},
}
)
builder.add_human_content(f"--- Visible UI Elements ---\n{minimal_list}")
class PlanRecitationPromptComponent(PromptComponent):
"""Per-turn task-plan recitation for the transcript tail (M2).
With the plan+history section moved out of the (now static) system
message, every observation recites the live task plan; the scrub edge
strips the copies from older turns at depth 1.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
from artemis.memory.transcript import PLAN_RECITATION_MARKER
task_plan = kwargs.get("task_plan") or "No task plan yet."
builder.add_human_content(f"{PLAN_RECITATION_MARKER}\n{task_plan}")
class FeedbackPromptComponent(PromptComponent):
"""Append-only injection of source-tagged findings.
Reads ``state.operator_feedback`` (written by ``execution_check_node`` at
harvest / planner rejection and by exit settlement on a bounce-back).
Renders nothing when there are no findings — the prompt template itself
is never switched.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
findings = getattr(state, "operator_feedback", None)
if not findings:
return
lines = "\n".join(f"- {f}" for f in findings)
builder.add_human_content(
"--- Verification Findings ---\n"
f"{lines}\n"
"Each finding is tagged with its source. Checker verdicts"
" ([verify failed], [final check], [unmet subgoal]) are independent judgments —"
" address any reverted subgoal accordingly; do not re-litigate"
" them. [planner] findings are advisory: a lightweight reviewer"
" had a concern about a plan change that stayed applied — weigh"
" the reason against your own observations."
)
#: Header of the execution-incident block in the observation tail.
EXECUTION_INCIDENT_MARKER = "--- Execution Incident (OPEN) ---"
def _max_burst_actions_for_prompt() -> int:
"""The configured fast-action burst ceiling, recited in the operator template."""
try:
from artemis.config import load_agent_config
return int(load_agent_config().pro.execution.max_burst_actions)
except Exception:
return 4
def _last_successful_action_before(steps: list, step_number: int | None) -> tuple[str, int] | None:
"""The most recent step before ``step_number`` whose terminal action was dispatched.
Returns ``(clean action description, step number)`` or None. This is the
Operator's best candidate for the *trigger* of a transient state: the
action that summoned the UI the failed target belonged to.
"""
from artemis.utils.task_tree import format_actions_clean
candidates = []
for step in steps or []:
number = step.get("step_number")
if not isinstance(number, int):
continue
if step_number is not None and number >= step_number:
continue
action = step.get("action_taken")
if not action:
continue
result = step.get("last_execution_result")
if isinstance(result, dict) and result.get("status") not in (None, "dispatched"):
continue
candidates.append((number, format_actions_clean(action)))
if not candidates:
return None
number, description = max(candidates, key=lambda c: c[0])
return description, number
def _incident_target_label(incident: dict) -> str:
action = incident.get("action") or {}
# The Operator only ever sees 0–1000 coordinates: the recorded pixel
# target is the controller's business and is never rendered here.
normalized = action.get("normalized_coordinates")
parts = []
if normalized:
parts.append(f"normalized {list(normalized)}")
if action.get("target_text"):
parts.append(f'"{action["target_text"]}"')
elif action.get("target_description"):
parts.append(f'"{action["target_description"]}" {SELF_DESCRIBED_MARKER}')
return " ".join(parts) if parts else "the recorded target"
def render_execution_incident(incident: dict, steps: list) -> str:
"""The Operator-facing explanation of an open execution incident.
Structure (deliberately the same every turn so it reads as one continuing
incident, not a new alarm): facts -> category-specific evidence. The
response protocol is stated once, in the static system prompt.
"""
kind = incident.get("kind") or "exec_error"
category = str(incident.get("category") or "general")
consecutive = int(incident.get("consecutive_failures") or 1)
description = incident.get("action_description") or "the planned 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)
evidence = incident.get("evidence") or {}
step_number = incident.get("step_number")
target_label = _incident_target_label(incident)
prior = _last_successful_action_before(steps, step_number)
lines = [EXECUTION_INCIDENT_MARKER]
opened = f"Opened at Step {step_number}" if step_number else "Opened last turn"
lines.append(f"{opened}; consecutive failed turns: {consecutive}.")
# --- What happened -------------------------------------------------------------
if burst_size > 1:
remaining = burst_size - index - 1
skipped = (
f" The {remaining} action(s) after it were NOT executed, so the device may"
" be mid-sequence."
if remaining > 0
else ""
)
lines.append(
f"What happened: action {index + 1} of your {burst_size}-action fast burst,"
f" `{description}`, failed: {reason}.{skipped}"
)
elif kind == "safety_net":
lines.append(
f"What happened: your planned action `{description}` was NOT executed. The"
f" pre-execution safety net refused it: {reason}"
)
else:
lines.append(
f"What happened: your planned action `{description}` was dispatched, but the"
f" device/executor reported: {reason}"
)
# --- Category-specific evidence -------------------------------------------------
if category == "target_shifted":
location = evidence.get("new_location")
bounds = evidence.get("new_bounds")
where = f" at normalized {location}" if location else ""
bounds_str = f" (bounds {bounds})" if bounds else ""
lines.append(
f"Evidence: the same element still exists but has moved{where}{bounds_str};"
" the shift exceeded the safety net's auto-correction tolerance."
)
elif category == "target_occupied":
occupant = evidence.get("occupant") or "a different element"
lines.append(
f"Evidence: the target position is now covered by {occupant}. Something"
" appeared on top of your target (dialog, sheet, banner, keyboard, or a"
" re-laid-out screen)."
)
elif category in ("target_disappeared", "pixel_target_disappeared"):
prior_str = (
f" Your last dispatched action was `{prior[0]}` (Step {prior[1]});"
" if the vanished target belonged to a state that action summoned, that action"
" is the trigger."
if prior
else ""
)
lines.append(
"Evidence: the element you saw in the previous screenshot is no longer on"
f" screen. Its recorded target was {target_label}.{prior_str}"
)
# The response protocol lives in the static system prompt (Execution Incident).
return "\n".join(lines)
def render_closed_incident(closed: dict) -> str:
"""One-turn notice after an incident closes: settle the original intent."""
opened = closed.get("step_number")
closed_at = closed.get("closed_at_step")
description = closed.get("action_description") or "the blocked action"
header = (
f"--- Execution Incident (CLOSED at Step {closed_at}) ---"
if closed_at
else "--- Execution Incident (CLOSED) ---"
)
opened_str = f" opened at Step {opened}" if opened else ""
return (
f"{header}\n"
f"The incident{opened_str} on `{description}` closed because your last Turn-Ending"
" Action executed. Settle its original intent against the plan and the live screen:"
" already served, still pending, or no longer needed."
)
class ExecutionIncidentPromptComponent(PromptComponent):
"""Renders the open execution incident until a terminal action succeeds,
then a one-turn CLOSED notice asking the Operator to settle the intent.
Reads ``state.open_incident`` / ``state.last_closed_incident`` (both written
by the Validator). The block is its own text part of the observation tail,
so the transcript ledger keeps it verbatim: the whole resolution effort
stays legible across turns.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
incident = getattr(state, "open_incident", None)
if isinstance(incident, dict) and incident.get("reason"):
builder.add_human_content(
render_execution_incident(incident, kwargs.get("steps") or [])
)
return
closed = getattr(state, "last_closed_incident", None)
if isinstance(closed, dict) and closed.get("kind"):
builder.add_human_content(render_closed_incident(closed))
class CheckItemsExplainerPromptComponent(PromptComponent):
"""Behavioral guidance for check lines, rendered iff the CURRENT plan
actually contains check lines (content-driven, not switch-driven: a resumed
plan carrying check lines still gets the explanation)."""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if not ctx or not getattr(ctx, "data_engine", None):
return
try:
task_plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
if not task_plan_path.exists():
return
snapshot = parse_plan(task_plan_path.read_text(encoding="utf-8"))
except Exception as e:
logger.error(f"Failed to parse plan for check-items explainer: {e}")
return
if not snapshot.all_check_items:
return
setup = getattr(ctx, "execution_setup", None)
max_repairs = getattr(setup, "checkpoint_max_repairs", None)
if not isinstance(max_repairs, int):
max_repairs = 2
builder.add_human_content(
"--- About the plan's check lines ---\n"
"The task plan declares `- verify:` / `- assert:` check lines (see Task"
" Plan Grammar). Use `verify:` lines to confirm your work is complete,"
" but never declare a check passed yourself or record conclusions on"
" its behalf. Take NO extra actions for `assert:` lines and never"
" construct or fake state to satisfy one. Check lines must not be"
" deleted or reworded (deletions are automatically restored by the"
" system); keep them verbatim when rewriting the plan — adding new ones"
" is allowed. Simply mark completions per the plan grammar as usual;"
" checking runs asynchronously in the background and does not block you."
f" A failed `verify:` reopens its milestone at most {max_repairs} time(s);"
" once that repair budget is exhausted, its standing `finding:` line"
" disappears and the failure stands as the recorded result — do not"
" keep repairing it."
)
class BackgroundTasksPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
active_tasks = kwargs.get("active_background_tasks", [])
if active_tasks:
lines = [
"--- Active Background ADB Tasks ---",
]
for task in active_tasks:
lines.append(
f"- TaskId: {task['task_id']}\n Command:"
f" `{task['command']}`\n Cwd: `{task['cwd']}`\n "
f" TerminalID: `{task['terminal_id']}`\n Accumulated"
f" Output: {task['output_line_count']} lines of logs"
)
builder.add_human_content("\n".join(lines) + "\n")
newly_finished_tasks = kwargs.get("newly_finished_tasks", [])
if newly_finished_tasks:
lines = [
"--- NEWLY FINISHED ADB TASKS (Since last step) ---",
]
for task in newly_finished_tasks:
task_id = task["task_id"]
command = task["command"]
status = task["status"]
output_text = task.get("output_text", "")
intro = f"- TaskId: {task_id}\n Command: `{command[:60]}...`\n Status: {status}"
if _is_output_long(output_text):
formatted = _format_long_output_response(task_id, output_text, intro)
# format_long_output_response is multi-line, let's indent it nicely
indented = "\n".join(f" {line}" for line in formatted.splitlines())
lines.append(indented)
else:
lines.append(f"{intro}\n Final Output:\n {output_text.strip()}")
builder.add_human_content("\n".join(lines) + "\n")
_PLAN_NOTE_TOOLS = ("update_note", "save_note", "append_note")
def step_updated_task_plan(step: dict) -> bool:
"""Whether a recorded step's tool calls wrote the ``task_plan`` note."""
for tc in step.get("tool_calls", []):
if tc.get("name") not in _PLAN_NOTE_TOOLS:
continue
args = tc.get("args", {})
note_key = args.get("key") or args.get("name")
if note_key == "task_plan":
return True
return False
def unwritten_action_streak(steps: list[dict]) -> int:
"""Count trailing action steps since the last ``task_plan`` write."""
streak = 0
for step in reversed(steps or []):
if step_updated_task_plan(step):
break
if step.get("action_taken"):
streak += 1
return streak
def _clip(text: str, limit: int = 80) -> str:
text = " ".join(text.split())
return text if len(text) <= limit else text[: limit - 1] + "\u2026"
_LEDGER_BOUNCE_TAIL = (
" with `update_note` (key `task_plan`), then re-issue the action in the same"
" tool-call list (Live Sub-goal Ledger)."
)
def render_plan_ledger_bounce(task_plan: str, streak: int, stale_turns: int) -> str | None:
"""Return a plan-ledger validation error, if any."""
snapshot = parse_plan(task_plan or "")
if not snapshot.has_top_level or snapshot.all_top_level_done:
return None
milestone = snapshot.active_milestone()
if milestone is None:
return (
"Not executed: no milestone is marked in progress. Mark the one you are"
" executing `[/]` and open its `[/]` sub-goal" + _LEDGER_BOUNCE_TAIL
)
leaf = snapshot.active_leaf(milestone)
if leaf is None:
return (
f'Not executed: the active milestone "{_clip(milestone.text)}" has no'
" in-progress `[/]` sub-goal beneath it. Open one for what you are doing now"
+ _LEDGER_BOUNCE_TAIL
)
turns = streak + 1
if stale_turns > 0 and turns >= stale_turns:
return (
f'Not executed: the sub-goal "{_clip(leaf.text)}" has stayed in progress for'
f" {turns} action turns without any plan change. Settle it (`[x]` / `[!]`) or"
" record its progress and open the next leaf" + _LEDGER_BOUNCE_TAIL
)
return None
class ToolLimitWarningPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if getattr(state, "operator_tool_limit_exceeded", False):
builder.add_human_content(
"\nWarning: your last turn used up its tool-call budget without a"
" Turn-Ending Action. Re-examine the task goal and your plan;"
" the current approach may not be the right path. If the cause"
" is unclear, ask_diagnoser can help."
)
class InjectedInstructionPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
injected = getattr(state, "injected_instruction", None)
if injected:
builder.add_human_content(
f"\n--- User Guidance ---\n"
f"The user observing your progress has provided the following"
f" feedback or correction:\n"
f'"{injected}"\n\n'
f"Please review this guidance, evaluate it against your current"
f" screen state and recent history, "
f"and integrate it into your reasoning. Use it to refine your"
f" task plan and determine the "
f"most appropriate next action."
)
class ScreenshotSimilarityPromptComponent(PromptComponent):
"""Background check: compare current screen against post-action screenshots of the last few steps.
Inject a note if any are identical. This helps detect if the same screen
keeps reappearing unexpectedly.
"""
NUM_STEPS_BACK: int = 3
MAX_ALLOWED_DIFF_PIXELS: int = 3
COLOR_TOLERANCE: int = 8
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if not ctx or not ctx.data_engine:
return
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
steps = kwargs.get("steps") or []
if not latest_screenshot_b64 or not steps:
return
# 1. Load current live image and re-encode to JPEG for symmetric compression matching
try:
# Strip data URI header if present
if "," in latest_screenshot_b64:
latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
curr_img_bytes = base64.b64decode(latest_screenshot_b64)
raw_img = Image.open(io.BytesIO(curr_img_bytes)).convert("RGB")
# Symmetric in-memory re-encoding to match JPEG compression artifacts
jpeg_buf = io.BytesIO()
raw_img.save(jpeg_buf, format="JPEG", quality=75)
jpeg_buf.seek(0)
curr_img = Image.open(jpeg_buf).convert("RGB")
except Exception:
return
# 2. Get last steps with a recorded post_image_name or pre_image_name
history_steps = [s for s in steps if s.get("post_image_name") or s.get("pre_image_name")]
recent_steps = history_steps[-self.NUM_STEPS_BACK :]
matched_step_nums = []
images_dir = Path(ctx.data_engine.global_base_dir) / "images"
# 3. Compare current image with each past step's post-action screenshot
for step_rec in recent_steps:
step_num = step_rec.get("step_number")
if step_num is None:
continue
image_name = step_rec.get("post_image_name") or step_rec.get("pre_image_name")
if not image_name:
image_name = step_rec.get("post_image_name")
if not image_name:
continue
image_path = images_dir / f"{image_name}.jpg"
if not image_path.exists():
continue
try:
past_img = Image.open(image_path).convert("RGB")
# Must be same dimensions to compare pixel-by-pixel
if curr_img.size != past_img.size:
continue
# Count differing pixels
diff_count = self._count_differing_pixels(
curr_img, past_img, max_allowed=self.MAX_ALLOWED_DIFF_PIXELS
)
if diff_count <= self.MAX_ALLOWED_DIFF_PIXELS:
matched_step_nums.append(str(step_num))
except Exception:
continue
# 4. Inject note if any identical screenshots are found
if matched_step_nums:
steps_str = ", ".join(matched_step_nums)
note_text = f"Note: the screen is unchanged since step {steps_str} (pixel-identical)."
builder.add_human_content(note_text)
def _count_differing_pixels(
self,
img1: Image.Image,
img2: Image.Image,
max_allowed: int | None = None,
) -> int:
"""Count pixels that differ between two RGB images.
Early-exits if diff_count exceeds max_allowed for performance.
"""
if max_allowed is None:
max_allowed = self.MAX_ALLOWED_DIFF_PIXELS
data1 = img1.getdata()
data2 = img2.getdata()
diff_count = 0
tolerance = self.COLOR_TOLERANCE
for p1, p2 in zip(data1, data2):
if (
abs(p1[0] - p2[0]) > tolerance
or abs(p1[1] - p2[1]) > tolerance
or abs(p1[2] - p2[2]) > tolerance
):
diff_count += 1
if diff_count > max_allowed:
break
return diff_count
def _recall_enabled() -> bool:
"""Whether the search_history tool is enabled by configuration."""
try:
from artemis.config import load_agent_config
return bool(load_agent_config().memory.recall.enabled)
except Exception:
return True
class HistoricalStateHintPromptComponent(PromptComponent):
"""Local historical-state hint from stored perceptual hashes (M4).
Compares the current screenshot's dHash against the post-action hashes
stamped on every recorded step (``extra_metadata["post_image_dhash"]``,
including steps already chunk-compressed out of the visible transcript) —
an O(n) integer scan, no model call, no historical image bytes. On a
close match to a step *older* than the recent window it injects a one-line
hint pointing at ``search_history``.
Division of labor with :class:`ScreenshotSimilarityPromptComponent`: that
component's pixel-exact 3-step look-back covers "stuck on the same
screen"; this one covers "returned to a much earlier state". When any of
the last :attr:`RECENT_SILENT_STEPS` steps also matches, this hint stays
silent so the two never fire on the same regime.
"""
#: Matches within this many most-recent steps stay silent (the pixel
#: same-screen note owns that window).
RECENT_SILENT_STEPS: int = 3
#: Upper bound on how many historical steps are scanned (most recent first).
SCAN_CAP: int = 500
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
steps = kwargs.get("steps") or []
if not latest_screenshot_b64 or len(steps) <= self.RECENT_SILENT_STEPS:
return
max_distance = 8
try:
from artemis.config import load_agent_config
transcript_cfg = load_agent_config().memory.transcript
if not getattr(transcript_cfg, "similarity_hint", True):
return
max_distance = int(getattr(transcript_cfg, "similarity_max_distance", 8))
except Exception as exc:
logger.debug(
"Transcript similarity config unavailable; using max_distance=%s: %s",
max_distance,
exc,
exc_info=True,
)
try:
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
if "," in latest_screenshot_b64:
latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
current_hash = dhash_hex(base64.b64decode(latest_screenshot_b64))
except Exception:
return
if not current_hash:
return
def _step_hash(step: dict) -> str | None:
meta = step.get("extra_metadata") or {}
return meta.get("post_image_dhash") or meta.get("pre_image_dhash")
# Silence rule first: a close match inside the recent window belongs
# to the pixel-level same-screen note, not this hint.
for step in steps[-self.RECENT_SILENT_STEPS :]:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is not None and distance <= max_distance:
return
older_steps = steps[: -self.RECENT_SILENT_STEPS][-self.SCAN_CAP :]
best_step_number = None
best_distance = None
for step in older_steps:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is None or distance > max_distance:
continue
if best_distance is None or distance <= best_distance:
# <= keeps the most recent step on ties.
best_distance = distance
best_step_number = step.get("step_number")
if best_step_number is not None:
builder.add_human_content(
f"Historical state hint: current screen closely resembles the"
f" post-action screen from Step {best_step_number}. Use"
" search_history / replay_steps only if its details are needed."
)