Fix queue cancellation, concurrency limits and worker status updates. Use direct Explorer imports, split the Diagnoser loop into helpers and remove unused tool registration and prompt-builder code. Correct model caching, endpoint selection and retry handling.
1016 lines
40 KiB
Python
1016 lines
40 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 asyncio
|
|
import base64
|
|
import functools
|
|
import json
|
|
from typing import Annotated, Literal
|
|
|
|
from langchain_core.messages import ToolMessage
|
|
from langchain_core.tools import StructuredTool
|
|
from langchain_core.tools.base import InjectedToolCallId
|
|
from langgraph.constants import END, START
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.graph.state import CompiledStateGraph
|
|
from langgraph.prebuilt import InjectedState
|
|
from pydantic import BaseModel, Field
|
|
|
|
from artemis.agents.checker.checker import run_final_check, verdicts_allow_release
|
|
from artemis.agents.operator.operator import OperatorNode
|
|
from artemis.agents.planner.planner import (
|
|
PlannerNode,
|
|
run_async_planner_validation,
|
|
)
|
|
from artemis.agents.summarizer.summarizer import SummarizerNode
|
|
from artemis.agents.validator.validator import ValidatorNode
|
|
from artemis.context import ArtemisContext
|
|
from artemis.graph.checkpoints import (
|
|
append_ledger_record,
|
|
attempt_trace_id,
|
|
compute_run_outcome,
|
|
final_check_enabled,
|
|
FINAL_CHECKPOINT_ID,
|
|
FINAL_SUBGOAL_TEXT,
|
|
harvest_finished_checkpoints,
|
|
has_ledger_records,
|
|
is_checker_note_key,
|
|
publish_checker_event,
|
|
queue_checkpoints,
|
|
read_ledger,
|
|
revert_subgoal_status,
|
|
settle_all_checkpoints,
|
|
spawn_pending_checkpoints,
|
|
sync_finding_lines,
|
|
verdict_payloads,
|
|
write_run_outcome,
|
|
)
|
|
from artemis.graph.perception import perception_node
|
|
from artemis.graph.state import State
|
|
from artemis.graph.visibility import strict_state
|
|
from artemis.tools.command_tool import (
|
|
HANG_GUIDANCE,
|
|
manage_task_wrapper,
|
|
run_adb_command_wrapper,
|
|
)
|
|
from artemis.tools.diagnostic_tool import ask_diagnoser_wrapper
|
|
from artemis.tools.explorer_tool import ask_explorer_wrapper
|
|
from artemis.tools.index import get_tools_from_wrappers
|
|
from artemis.tools.scratchpad import (
|
|
append_note_wrapper,
|
|
list_notes_wrapper,
|
|
read_note_wrapper,
|
|
save_note_wrapper,
|
|
update_note_wrapper,
|
|
)
|
|
from artemis.tools.tool_wrapper import invoke_tool_with_injection
|
|
from artemis.tools.video_tool import get_video_analyzer_tool
|
|
from artemis.utils.logger import get_logger
|
|
from artemis.utils.notes import (
|
|
SAVE_NOTE_ARG_CONTENT_DESC,
|
|
SAVE_NOTE_ARG_KEY_DESC,
|
|
UPDATE_NOTE_ARG_KEY_DESC,
|
|
UPDATE_NOTE_ARG_REPLACEMENT_DESC,
|
|
UPDATE_NOTE_ARG_TARGET_DESC,
|
|
get_note_file_path,
|
|
)
|
|
from artemis.utils.plan_grammar import (
|
|
milestones_changed,
|
|
new_top_level_completions,
|
|
parse_plan,
|
|
restore_missing_check_items,
|
|
subgoal_hash,
|
|
unintended_milestone_edits,
|
|
)
|
|
from artemis.utils.task_tree import get_active_subgoal_hashes
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def convergence_node(state: State):
|
|
"""Convergence point for parallel execution paths."""
|
|
return {}
|
|
|
|
|
|
def _notify_history_chunker(ctx: ArtemisContext, method: str, *args) -> None:
|
|
"""Best-effort event feed to the transcript's L2/L3 chunk manager (M3).
|
|
|
|
The chunker only exists when the transcript flag is on; every failure is
|
|
swallowed — history compression must never gate the execution path.
|
|
"""
|
|
try:
|
|
chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
|
|
if chunker is not None:
|
|
getattr(chunker, method)(*args)
|
|
except Exception as e:
|
|
logger.debug(f"History chunker notification '{method}' skipped: {e}")
|
|
|
|
|
|
async def execution_check_node(state: State, ctx: ArtemisContext):
|
|
state = strict_state(state, "execution_check")
|
|
logger.info("Starting execution_check_node")
|
|
|
|
if not ctx.data_engine:
|
|
return {"checker_success": True}
|
|
|
|
checker_success = True
|
|
|
|
def _record_turn(extra_metadata: dict) -> str | None:
|
|
"""Records this Operator turn in the DataEngine — unconditionally: every
|
|
turn (including planner-rejected ones) leaves a step record."""
|
|
raw_data = state.operator_raw_data
|
|
if not raw_data:
|
|
return None
|
|
try:
|
|
screenshot_b64 = raw_data.get("screenshot_b64")
|
|
xml_hierarchy = raw_data.get("xml_hierarchy")
|
|
ocr_results = raw_data.get("ocr_results")
|
|
|
|
screenshot_bytes = base64.b64decode(screenshot_b64)
|
|
|
|
action_taken = None
|
|
if state.structured_decisions:
|
|
try:
|
|
action_taken = json.loads(state.structured_decisions)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse structured decisions: {e}")
|
|
|
|
subgoal_hash_val, sub_subgoal_hash = _get_active_subgoal_hashes(ctx)
|
|
# The user's injected instruction is stamped verbatim on the step
|
|
# it reached: the chunk ledger (band ③) preserves it as a
|
|
# never-evicted line at every compression level (§3.3).
|
|
injected = getattr(state, "injected_instruction", None)
|
|
step_id = ctx.data_engine.record_step(
|
|
pre_screenshot_bytes=screenshot_bytes,
|
|
ui_tree=xml_hierarchy,
|
|
ocr_result=ocr_results,
|
|
action_taken=action_taken,
|
|
operator_raw_thinking=getattr(state, "operator_raw_thinking", None),
|
|
operator_native_thinking=getattr(state, "operator_native_thinking", None),
|
|
extra_metadata={
|
|
"subgoal_hash": subgoal_hash_val,
|
|
"sub_subgoal_hash": sub_subgoal_hash,
|
|
"width": raw_data.get("width"),
|
|
"height": raw_data.get("height"),
|
|
**({"injected_instruction": injected} if injected else {}),
|
|
**extra_metadata,
|
|
},
|
|
)
|
|
logger.info(f"Recorded step in DataEngine: {step_id}")
|
|
# Milestone-switch fact source: the stamped subgoal hash of every
|
|
# executed step, compared consecutively inside the chunk manager.
|
|
_notify_history_chunker(ctx, "on_step_stamped", str(step_id), subgoal_hash_val)
|
|
return str(step_id)
|
|
except Exception as e:
|
|
logger.error(f"Failed to record step in DataEngine: {e}")
|
|
return None
|
|
|
|
# Harvest finished checkpoint attempts (non-blocking: done() tasks only).
|
|
check_findings = harvest_finished_checkpoints(ctx, state)
|
|
turn_metadata: dict = {}
|
|
|
|
if hasattr(ctx, "planner_task") and ctx.planner_task:
|
|
logger.info("Waiting for planner validation task to complete...")
|
|
awaited_planner_task = ctx.planner_task
|
|
try:
|
|
planner_result = await awaited_planner_task
|
|
ctx.planner_task = None
|
|
|
|
flagged = bool(planner_result and planner_result.get("status") == "failed")
|
|
if flagged:
|
|
# Advisory outcome: the plan change stays applied and the
|
|
# turn's actions run. The Operator only receives the concern
|
|
# and its reason through operator_feedback — the one feedback
|
|
# channel its next prompt renders.
|
|
logger.warning("Planner validation flagged the task plan changes.")
|
|
feedback = planner_result.get(
|
|
"feedback", "The reviewer had a concern about this plan change."
|
|
)
|
|
check_findings.append(
|
|
"[planner] Advisory review of your recent top-level plan"
|
|
f" change: {feedback} The change was NOT rolled back —"
|
|
" weigh this concern against your own observations and"
|
|
" correct the plan only if you agree."
|
|
)
|
|
turn_metadata["planner_flagged"] = True
|
|
else:
|
|
logger.info("Planner validation approved the task plan changes.")
|
|
|
|
# Advance the ratchet baseline either way: the reviewed content is
|
|
# the new reference, so an already-flagged change is not re-flagged
|
|
# on every subsequent write.
|
|
if ctx.pending_validated_plan is not None:
|
|
ctx.last_validated_plan = ctx.pending_validated_plan
|
|
ctx.pending_validated_plan = None
|
|
except asyncio.CancelledError:
|
|
if not awaited_planner_task.cancelled():
|
|
raise # this node itself is being cancelled, not the awaited task
|
|
logger.info("Planner validation task was superseded by a newer plan write.")
|
|
ctx.planner_task = None
|
|
except Exception as e:
|
|
logger.error(f"Planner validation task failed: {e}")
|
|
|
|
# Every Operator turn is recorded — verification never gates history.
|
|
current_step_id = _record_turn(turn_metadata)
|
|
|
|
# Spawn queued checkpoints only now: the just-recorded step is the evidence
|
|
# anchor (its pre screenshot plus the previous turn's post state capture
|
|
# the completion moment). Superseded-but-finished attempts are booked here.
|
|
check_findings.extend(await spawn_pending_checkpoints(ctx, state, current_step_id))
|
|
|
|
update = {
|
|
"checker_success": checker_success,
|
|
"current_step_id": current_step_id,
|
|
"operator_feedback": check_findings or None,
|
|
}
|
|
if checker_success:
|
|
update["subagent_calls"] = []
|
|
return update
|
|
|
|
|
|
async def exit_settlement_node(state: State, ctx: ArtemisContext):
|
|
"""Two-phase exit: settlement (unconditional) then final review (gated).
|
|
|
|
Phase 1 — settlement barrier: the only intentional blocking wait in the
|
|
whole flow. Every started check attempt gets booked into the ledger before
|
|
exit, no matter which switches are on.
|
|
|
|
Phase 2 — final review (``disable_final_check`` off): audits the USER'S
|
|
ORIGINAL GOAL plus all declared check items. Unmet verify criteria route
|
|
back into the loop (bounded by ``final_check_max_attempts``); assert
|
|
failures never do.
|
|
"""
|
|
state = strict_state(state, "exit_settlement")
|
|
logger.info("Starting exit_settlement_node")
|
|
update: dict = {"exit_settlement_route": "end"}
|
|
if not ctx.data_engine:
|
|
return update
|
|
|
|
# Phase 1: settle every outstanding attempt (bounded by settlement_timeout).
|
|
await settle_all_checkpoints(ctx, state)
|
|
# Queued-but-never-spawned checkpoints are dropped here: their items simply
|
|
# remain unchecked and are reported as such.
|
|
ctx.pending_checkpoints.clear()
|
|
|
|
base_dir = ctx.data_engine.base_dir
|
|
plan_text = ""
|
|
plan_path = get_note_file_path(base_dir, "task_plan")
|
|
if plan_path.exists():
|
|
try:
|
|
plan_text = plan_path.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
logger.error(f"Failed to read task plan at settlement: {e}")
|
|
snapshot = parse_plan(plan_text)
|
|
|
|
setup = ctx.execution_setup
|
|
user_stopped = bool(getattr(state, "user_stop_requested", False))
|
|
halted = bool(getattr(ctx, "assert_halt", False))
|
|
max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3
|
|
|
|
verify_blocked = False
|
|
blocked_findings: list[str] = []
|
|
if final_check_enabled(ctx):
|
|
attempt_no = ctx.final_check_attempts + 1
|
|
ctx.final_check_attempts = attempt_no
|
|
final_attempt_id = f"final#{attempt_no}"
|
|
checkpoint_timeout = float(getattr(setup, "checkpoint_timeout", 180.0) or 180.0)
|
|
ledger = read_ledger(base_dir)
|
|
|
|
def _final_event(status: str, verdicts: list[dict], **extra) -> None:
|
|
publish_checker_event(
|
|
ctx,
|
|
{
|
|
"event": "attempt_finished",
|
|
"phase": "final",
|
|
"attempt_id": final_attempt_id,
|
|
"checkpoint_id": FINAL_CHECKPOINT_ID,
|
|
"subgoal_text": FINAL_SUBGOAL_TEXT,
|
|
"trace_id": attempt_trace_id(ctx, final_attempt_id),
|
|
"status": status,
|
|
"verdicts": verdicts,
|
|
**extra,
|
|
},
|
|
)
|
|
|
|
try:
|
|
report = await asyncio.wait_for(
|
|
run_final_check(
|
|
ctx,
|
|
goal=getattr(state, "initial_goal", ""),
|
|
plan_text=plan_text,
|
|
ledger=ledger,
|
|
check_items=list(snapshot.all_check_items),
|
|
attempt_id=final_attempt_id,
|
|
),
|
|
timeout=checkpoint_timeout,
|
|
)
|
|
except Exception as e:
|
|
# Fail-open: release, but the verdict value stays inconclusive.
|
|
logger.warning(f"Final check errored ({e}); releasing fail-open.")
|
|
error_verdicts: list[dict] = []
|
|
for ci in snapshot.all_check_items:
|
|
append_ledger_record(
|
|
base_dir,
|
|
{
|
|
"attempt_id": final_attempt_id,
|
|
"checkpoint_id": FINAL_CHECKPOINT_ID,
|
|
"subgoal_text": FINAL_SUBGOAL_TEXT,
|
|
"trace_id": attempt_trace_id(ctx, final_attempt_id),
|
|
"item_text": ci.text,
|
|
"kind": ci.kind,
|
|
"when": ci.when,
|
|
"status": "inconclusive",
|
|
"evidence": f"final check error: {e}",
|
|
"anchor_step_id": None,
|
|
},
|
|
)
|
|
error_verdicts.append(
|
|
{
|
|
"item_text": ci.text,
|
|
"kind": ci.kind,
|
|
"status": "inconclusive",
|
|
"evidence": f"final check error: {e}",
|
|
"suggestion": "",
|
|
}
|
|
)
|
|
_final_event("error", error_verdicts, error=str(e), route="end")
|
|
report = None
|
|
|
|
if report is not None:
|
|
for v in report.verdicts:
|
|
when = next(
|
|
(
|
|
ci.when
|
|
for ci in snapshot.all_check_items
|
|
if ci.kind == v.kind and ci.text == v.item_text
|
|
),
|
|
"at_end",
|
|
)
|
|
append_ledger_record(
|
|
base_dir,
|
|
{
|
|
"attempt_id": final_attempt_id,
|
|
"checkpoint_id": FINAL_CHECKPOINT_ID,
|
|
"subgoal_text": FINAL_SUBGOAL_TEXT,
|
|
"trace_id": attempt_trace_id(ctx, final_attempt_id),
|
|
"item_text": v.item_text,
|
|
"kind": v.kind,
|
|
"when": when,
|
|
"status": v.status,
|
|
"evidence": v.evidence,
|
|
"suggestion": v.suggestion,
|
|
"anchor_step_id": None,
|
|
},
|
|
)
|
|
|
|
passed = verdicts_allow_release(report) and not report.unmet_subgoals
|
|
if not passed:
|
|
if user_stopped or halted or attempt_no >= max_attempts:
|
|
verify_blocked = True
|
|
for v in report.verdicts:
|
|
if v.kind == "verify" and v.status == "failed":
|
|
blocked_findings.append(
|
|
f"[verify failed] '{v.item_text}': {v.evidence}"
|
|
)
|
|
for text in report.unmet_subgoals:
|
|
blocked_findings.append(f"[unmet subgoal] '{text}'")
|
|
logger.warning(
|
|
"Final check found unmet verify criteria but the retry"
|
|
" budget is exhausted (or a stop/halt is latched);"
|
|
" ending with a blocked outcome."
|
|
)
|
|
_final_event(
|
|
"done",
|
|
verdict_payloads(report.verdicts),
|
|
unmet_subgoals=list(report.unmet_subgoals),
|
|
findings=list(blocked_findings),
|
|
route="blocked",
|
|
)
|
|
else:
|
|
findings: list[str] = []
|
|
for text in report.unmet_subgoals:
|
|
if revert_subgoal_status(ctx, subgoal_hash(text)):
|
|
findings.append(
|
|
f"[final check] Subgoal '{text}' was set back to"
|
|
" in-progress: its acceptance criteria are not met."
|
|
)
|
|
for v in report.verdicts:
|
|
if v.kind == "verify" and v.status == "failed":
|
|
suggestion = f" Suggestion: {v.suggestion}" if v.suggestion else ""
|
|
findings.append(
|
|
f"[verify failed] '{v.item_text}': {v.evidence}{suggestion}"
|
|
)
|
|
update["operator_feedback"] = findings or None
|
|
update["exit_settlement_route"] = "continue"
|
|
_final_event(
|
|
"done",
|
|
verdict_payloads(report.verdicts),
|
|
unmet_subgoals=list(report.unmet_subgoals),
|
|
findings=list(findings),
|
|
route="continue",
|
|
)
|
|
return update
|
|
else:
|
|
_final_event(
|
|
"done",
|
|
verdict_payloads(report.verdicts),
|
|
unmet_subgoals=[],
|
|
findings=[],
|
|
route="end",
|
|
)
|
|
|
|
# END path: assemble the machine-readable run outcome.
|
|
records = read_ledger(base_dir)
|
|
outcome = compute_run_outcome(snapshot, records, verify_blocked=verify_blocked)
|
|
if snapshot.all_check_items or records or verify_blocked:
|
|
update["run_outcome"] = outcome.model_dump()
|
|
# BLOCKED/partial wrap-ups carry the last findings in the metadata file.
|
|
extra = {"last_findings": blocked_findings} if blocked_findings else None
|
|
write_run_outcome(base_dir, outcome, extra)
|
|
publish_checker_event(
|
|
ctx,
|
|
{
|
|
"event": "run_outcome",
|
|
"phase": "outcome",
|
|
**outcome.model_dump(),
|
|
"last_findings": list(blocked_findings),
|
|
},
|
|
)
|
|
logger.info(
|
|
f"Run outcome: task_status={outcome.task_status},"
|
|
f" tests(passed={outcome.tests.passed}, failed={outcome.tests.failed},"
|
|
f" inconclusive={outcome.tests.inconclusive},"
|
|
f" unchecked={outcome.tests.unchecked})"
|
|
)
|
|
return update
|
|
|
|
|
|
def exit_settlement_gate(state: State) -> Literal["continue", "end"]:
|
|
route = getattr(state, "exit_settlement_route", None)
|
|
return "continue" if route == "continue" else "end"
|
|
|
|
|
|
def execution_check_edge(
|
|
state: State,
|
|
) -> Literal["execute_decisions", "review_subgoals"]:
|
|
if state.checker_success:
|
|
if state.structured_decisions:
|
|
return "execute_decisions"
|
|
return "review_subgoals"
|
|
return "review_subgoals"
|
|
|
|
|
|
def _get_active_subgoal_hashes(ctx: ArtemisContext) -> tuple[str, str | None]:
|
|
"""Parses task_plan.md to find the active subgoal and sub-subgoal hashes."""
|
|
|
|
if not ctx.data_engine:
|
|
return "default", None
|
|
|
|
task_plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
|
|
|
|
if not task_plan_path.exists():
|
|
return "default", None
|
|
|
|
try:
|
|
content = task_plan_path.read_text(encoding="utf-8")
|
|
|
|
return get_active_subgoal_hashes(content)
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse active subgoal: {e}")
|
|
|
|
return "default", None
|
|
|
|
|
|
class _CyFunctionDetectorMeta(type):
|
|
def __instancecheck__(self, instance):
|
|
name = type(instance).__name__
|
|
return (
|
|
name
|
|
in (
|
|
"cyfunction",
|
|
"cython_function_or_method",
|
|
"builtin_function_or_method",
|
|
)
|
|
or "cyfunction" in name.lower()
|
|
)
|
|
|
|
|
|
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
|
|
pass
|
|
|
|
|
|
def check_plan_mutation_rejections(
|
|
content_before: str, content_after: str, state: State | None = None
|
|
) -> str | None:
|
|
"""Checks if a task plan modification violates the machine-channel safety rules."""
|
|
before = parse_plan(content_before)
|
|
after = parse_plan(content_after)
|
|
|
|
# 1. Incomplete nested subgoals check
|
|
if after.all_top_level_done and any(
|
|
(item.is_pending or item.is_active) and not item.is_top_level for item in after.items
|
|
):
|
|
return (
|
|
"Your changes to the task plan were not applied. Your changes would cause the"
|
|
" task to end completely, but there seem to be goals in the task plan"
|
|
" that have not been marked as completed. If you believe these goals"
|
|
" are already completed, please update their statuses."
|
|
)
|
|
|
|
# 2. Continuous monitoring protection: a [Loop:continuous] milestone can only
|
|
# transition to [x] (or disappear) after the user injects an explicit stop
|
|
# signal (user_stop_requested); intent is never inferred from wording.
|
|
user_stopped = bool(state is not None and getattr(state, "user_stop_requested", False))
|
|
|
|
if before.continuous_top_level and not user_stopped:
|
|
if not after.continuous_top_level:
|
|
return (
|
|
"Your changes to the task plan were rejected. You cannot delete an active"
|
|
" [Loop:continuous] continuous monitoring milestone or remove its tag."
|
|
" Please keep the milestone active (in progress [/]) and record each"
|
|
" check cycle as an indented subtask."
|
|
)
|
|
if any(item.is_done for item in after.continuous_top_level):
|
|
return (
|
|
"Your changes to the task plan were rejected. [Loop:continuous] continuous"
|
|
" monitoring milestones must remain in progress ([/]) and cannot be"
|
|
" unilaterally marked as completed [x] until the user injects an explicit"
|
|
" stop signal. Please keep it active ([/]) and record each polling check"
|
|
" as an indented subtask."
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def check_unintended_rewrite(content_before: str, content_after: str) -> str | None:
|
|
"""Detects the hand-slip signature of a full-file rewrite: top-level milestones
|
|
whose status did not change but whose text drifted. Declared edits via
|
|
update_note (explicit target/replacement) are exempt.
|
|
"""
|
|
edits = unintended_milestone_edits(parse_plan(content_before), parse_plan(content_after))
|
|
if not edits:
|
|
return None
|
|
|
|
listing = "\n".join(f'- "{b}"\n -> "{a}"' for b, a in edits)
|
|
return (
|
|
"Your changes to the task plan were not applied. Your rewrite reworded"
|
|
" top-level milestones whose status you did not change — this usually means"
|
|
" the plan was regenerated from memory and historical milestones drifted"
|
|
f" unintentionally:\n{listing}\nIf a rewording was intentional, apply it"
|
|
" explicitly with the update_note tool (target/replacement). Otherwise,"
|
|
" re-apply only the changes you actually intended."
|
|
)
|
|
|
|
|
|
def _reject_checker_note_write(key: str, tool_call_id: str | None) -> ToolMessage | None:
|
|
"""Guard for the system-authored checker note channel.
|
|
|
|
Note keys with the reserved ``checker-`` / ``checker:`` prefix are written
|
|
exclusively by the checkpoint verifier (repair logs); model-side writes are
|
|
rejected with guidance instead of silently colliding with the log.
|
|
"""
|
|
if not is_checker_note_key(key):
|
|
return None
|
|
return ToolMessage(
|
|
content=(
|
|
f"The note key '{key}' was not written: keys with the 'checker-'"
|
|
" prefix are reserved for system-authored verification repair"
|
|
" logs. Those notes are read-only for you — read them with"
|
|
" read_note. Record your own observations under a different key."
|
|
),
|
|
tool_call_id=tool_call_id or "",
|
|
status="success",
|
|
)
|
|
|
|
|
|
class NoteArgs(BaseModel):
|
|
model_config = {"ignored_types": (CyFunctionDetector,)}
|
|
key: str = Field(..., description=SAVE_NOTE_ARG_KEY_DESC)
|
|
content: str = Field(..., description=SAVE_NOTE_ARG_CONTENT_DESC)
|
|
|
|
|
|
async def _process_plan_write(
|
|
ctx: ArtemisContext,
|
|
state: State | None,
|
|
task_plan_path,
|
|
content_before: str,
|
|
content_after: str,
|
|
result,
|
|
declared_intent: bool,
|
|
):
|
|
"""Shared post-write pipeline for every task_plan mutation.
|
|
|
|
Constitution order: hard rejections with rollback (machine-channel rules
|
|
only), then ratchet-baseline planner validation, then checker trigger on
|
|
new top-level completions. ``declared_intent`` marks surgical update_note
|
|
edits, which are exempt from the full-rewrite hand-slip check.
|
|
"""
|
|
rejection_message = check_plan_mutation_rejections(content_before, content_after, state)
|
|
if rejection_message is None and not declared_intent:
|
|
rejection_message = check_unintended_rewrite(content_before, content_after)
|
|
if rejection_message:
|
|
logger.info(f"Rejected task plan update: {rejection_message}")
|
|
if task_plan_path.exists():
|
|
task_plan_path.write_text(content_before, encoding="utf-8")
|
|
|
|
# Guidance, not a tool failure: status stays "success" so the Operator
|
|
# loop treats it as feedback content (matching the legacy envelope
|
|
# behavior where the SystemMessage carried no error status).
|
|
return ToolMessage(content=rejection_message, tool_call_id="", status="success")
|
|
|
|
# Deterministic check-line guard (Operator-source writes only; the Planner
|
|
# node writes through unwrapped tools and retains revision authority):
|
|
# deleted/rewritten check lines are merged back in a pure text operation.
|
|
# This is content-driven and independent of any switch — a resumed plan
|
|
# with check lines stays protected even when checking is disabled.
|
|
merged = restore_missing_check_items(content_before, content_after)
|
|
if merged is not None:
|
|
logger.info(
|
|
"Plan write removed or rewrote declared check lines; merging them"
|
|
" back (check standards are protected deterministically)."
|
|
)
|
|
content_after = merged
|
|
try:
|
|
task_plan_path.write_text(content_after, encoding="utf-8")
|
|
except Exception as e:
|
|
logger.error(f"Failed to write merged check lines: {e}")
|
|
|
|
# Deterministic finding-line projection: unresolved verify findings are
|
|
# re-rendered from the checkpoint repair state on every plan write, so a
|
|
# model deletion grows back and a resolved finding is retired. Finding
|
|
# lines live outside the checkbox/check machine channel, so this rewrite
|
|
# never influences the parses below.
|
|
sync_finding_lines(ctx)
|
|
|
|
before = parse_plan(content_before)
|
|
after = parse_plan(content_after)
|
|
|
|
# Ratchet baseline: judge the current plan against the last *validated*
|
|
# plan rather than the previous write, so many small edits cannot drift
|
|
# the milestones below any per-edit trigger.
|
|
if ctx.last_validated_plan is None:
|
|
ctx.last_validated_plan = content_before
|
|
|
|
disable_planner_validation = (
|
|
ctx.execution_setup and ctx.execution_setup.disable_planner_validation
|
|
)
|
|
if not disable_planner_validation:
|
|
baseline = parse_plan(ctx.last_validated_plan)
|
|
if milestones_changed(baseline, after):
|
|
logger.info(
|
|
"Top-level task plan diverged from the validated baseline."
|
|
" Triggering async Planner Validation."
|
|
)
|
|
|
|
if ctx.planner_task and not ctx.planner_task.done():
|
|
# A newer write supersedes the in-flight validation.
|
|
ctx.planner_task.cancel()
|
|
|
|
ctx.pending_validated_plan = content_after
|
|
initial_goal = (
|
|
state.initial_goal if state and hasattr(state, "initial_goal") else "Unknown Goal"
|
|
)
|
|
|
|
ctx.planner_task = asyncio.create_task(
|
|
run_async_planner_validation(
|
|
ctx,
|
|
initial_goal,
|
|
ctx.last_validated_plan,
|
|
content_after,
|
|
getattr(state, "operator_raw_thinking", None),
|
|
getattr(state, "operator_native_thinking", None),
|
|
)
|
|
)
|
|
|
|
if isinstance(result, ToolMessage) and isinstance(result.content, str):
|
|
result.content = (
|
|
f"{result.content}\n\nWe have applied your changes"
|
|
" to the task plan. A lightweight background review"
|
|
" is checking the milestone change against the"
|
|
" original goal; if it has a concern you will get an"
|
|
" advisory hint with the reason (the change stays"
|
|
" applied either way)."
|
|
)
|
|
|
|
# Queue (never spawn) a checkpoint for EVERY newly completed top-level
|
|
# subgoal carrying on_complete check items. Spawning happens in
|
|
# execution_check_node after this turn's step is recorded, so the evidence
|
|
# anchor points at the correct step.
|
|
new_completions = new_top_level_completions(before, after)
|
|
if new_completions:
|
|
queue_checkpoints(ctx, state, after, new_completions, content_after)
|
|
# History chunking (M3): a top-level completion only queues an
|
|
# UNCONFIRMED boundary — the next stamped step confirms it via an
|
|
# actual subgoal-hash change (pseudo-switch protection, §3.3).
|
|
_notify_history_chunker(ctx, "queue_boundary_hint")
|
|
|
|
return result
|
|
|
|
|
|
async def _intercept_plan_write(
|
|
ctx: ArtemisContext,
|
|
original_tool,
|
|
args: dict,
|
|
tool_call_id,
|
|
state,
|
|
*,
|
|
declared_intent: bool,
|
|
content_after_fallback: str = "",
|
|
):
|
|
"""Run a note write and process any resulting task-plan changes."""
|
|
rejection = _reject_checker_note_write(args["key"], tool_call_id)
|
|
if rejection is not None:
|
|
return rejection
|
|
if args["key"] != "task_plan" or not ctx.data_engine:
|
|
return await invoke_tool_with_injection(
|
|
original_tool, args, tool_call_id, state, record_trace=False
|
|
)
|
|
|
|
task_plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
|
|
|
|
content_before = ""
|
|
if task_plan_path.exists():
|
|
content_before = task_plan_path.read_text(encoding="utf-8")
|
|
|
|
result = await invoke_tool_with_injection(
|
|
original_tool, args, tool_call_id, state, record_trace=False
|
|
)
|
|
|
|
content_after = content_after_fallback
|
|
if task_plan_path.exists():
|
|
content_after = task_plan_path.read_text(encoding="utf-8")
|
|
|
|
return await _process_plan_write(
|
|
ctx,
|
|
state,
|
|
task_plan_path,
|
|
content_before,
|
|
content_after,
|
|
result,
|
|
declared_intent=declared_intent,
|
|
)
|
|
|
|
|
|
def wrap_note_tool(ctx: ArtemisContext, original_tool):
|
|
|
|
async def wrapped_note_tool(
|
|
key: str,
|
|
content: str,
|
|
tool_call_id: Annotated[str, InjectedToolCallId] = None,
|
|
state: Annotated[State, InjectedState] = None,
|
|
):
|
|
"""Wrapped tool to intercept task plan updates."""
|
|
return await _intercept_plan_write(
|
|
ctx,
|
|
original_tool,
|
|
{"key": key, "content": content},
|
|
tool_call_id,
|
|
state,
|
|
declared_intent=False,
|
|
content_after_fallback=content,
|
|
)
|
|
|
|
return StructuredTool.from_function(
|
|
func=lambda *a, **kw: None,
|
|
coroutine=wrapped_note_tool,
|
|
name=original_tool.name,
|
|
description=original_tool.description,
|
|
args_schema=NoteArgs,
|
|
)
|
|
|
|
|
|
class UpdateNoteArgs(BaseModel):
|
|
model_config = {"ignored_types": (CyFunctionDetector,)}
|
|
key: str = Field(..., description=UPDATE_NOTE_ARG_KEY_DESC)
|
|
target: str = Field(..., description=UPDATE_NOTE_ARG_TARGET_DESC)
|
|
replacement: str = Field(..., description=UPDATE_NOTE_ARG_REPLACEMENT_DESC)
|
|
|
|
|
|
def wrap_update_note_tool(ctx: ArtemisContext, original_tool):
|
|
|
|
async def wrapped_update_note(
|
|
key: str,
|
|
target: str,
|
|
replacement: str,
|
|
tool_call_id: Annotated[str, InjectedToolCallId] = None,
|
|
state: Annotated[State, InjectedState] = None,
|
|
):
|
|
"""Wrapped tool to intercept task plan updates via update_note."""
|
|
return await _intercept_plan_write(
|
|
ctx,
|
|
original_tool,
|
|
{"key": key, "target": target, "replacement": replacement},
|
|
tool_call_id,
|
|
state,
|
|
declared_intent=True,
|
|
)
|
|
|
|
return StructuredTool.from_function(
|
|
func=lambda *a, **kw: None,
|
|
coroutine=wrapped_update_note,
|
|
name=original_tool.name,
|
|
description=original_tool.description,
|
|
args_schema=UpdateNoteArgs,
|
|
)
|
|
|
|
|
|
def convergence_gate(
|
|
state: State, ctx: ArtemisContext
|
|
) -> Literal["continue", "exit_settlement", "end"]:
|
|
logger.info("Starting convergence_gate")
|
|
|
|
if not ctx.data_engine:
|
|
return "end"
|
|
|
|
def _read_plan() -> str:
|
|
file_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
|
|
if not file_path.exists():
|
|
return ""
|
|
try:
|
|
return file_path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return ""
|
|
|
|
def _terminal_route() -> Literal["exit_settlement", "end"]:
|
|
"""Settlement and final review are decoupled from each other but share
|
|
the entry: any check item, in-flight/queued attempt, ledger record, or
|
|
an enabled final review forces the settlement node."""
|
|
plan_snapshot = parse_plan(_read_plan())
|
|
needs_settlement = (
|
|
bool(plan_snapshot.all_check_items)
|
|
or bool(getattr(ctx, "checkpoint_tasks", None))
|
|
or bool(getattr(ctx, "pending_checkpoints", None))
|
|
or has_ledger_records(ctx.data_engine.base_dir)
|
|
or final_check_enabled(ctx)
|
|
)
|
|
return "exit_settlement" if needs_settlement else "end"
|
|
|
|
# A halt latched by an assert failure (policy 'halt') terminates the run
|
|
# regardless of plan progress.
|
|
if getattr(ctx, "assert_halt", False):
|
|
logger.warning("Assert-halt latched; routing to exit settlement.")
|
|
return _terminal_route()
|
|
|
|
# Check planner-validation result. ``None`` means "not yet evaluated"
|
|
# (the first pass after the Planner node) — only an explicit False is a
|
|
# failure; otherwise the log would misreport the initial plan as rejected.
|
|
if getattr(state, "checker_success", None) is False:
|
|
logger.info("Plan validation failed, returning to operator for retry.")
|
|
return "continue"
|
|
|
|
file_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
|
|
if not file_path.exists():
|
|
logger.warning(f"Task plan file not found at {file_path}")
|
|
return "continue"
|
|
try:
|
|
task_plan_content = file_path.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
logger.error(f"Failed to read task plan: {e}")
|
|
return "continue"
|
|
|
|
snapshot = parse_plan(task_plan_content)
|
|
|
|
if not snapshot.has_top_level:
|
|
# An empty plan does not bypass an enabled final review: the review
|
|
# object is the user's original goal, not the plan.
|
|
logger.info("No subgoals found in file, ending")
|
|
return _terminal_route()
|
|
|
|
if snapshot.all_top_level_done:
|
|
user_stopped = bool(getattr(state, "user_stop_requested", False)) if state else False
|
|
if snapshot.continuous_top_level and not user_stopped:
|
|
logger.warning(
|
|
"All subgoals marked [x] but task plan has a [Loop:continuous]"
|
|
" milestone without a user stop signal. Continuing."
|
|
)
|
|
return "continue"
|
|
logger.info("All subgoals are completed, ending the goal")
|
|
return _terminal_route()
|
|
|
|
return "continue"
|
|
|
|
|
|
async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
|
|
graph_builder = StateGraph(State)
|
|
|
|
# Get native tools
|
|
native_wrappers = [
|
|
save_note_wrapper,
|
|
read_note_wrapper,
|
|
list_notes_wrapper,
|
|
update_note_wrapper,
|
|
append_note_wrapper,
|
|
]
|
|
|
|
native_tools = get_tools_from_wrappers(ctx, native_wrappers)
|
|
|
|
## Define nodes
|
|
graph_builder.add_node("planner", PlannerNode(ctx, tools=native_tools))
|
|
|
|
# Wrap native tools for Operator
|
|
operator_native_tools = []
|
|
for t in native_tools:
|
|
if t.name == "save_note":
|
|
operator_native_tools.append(wrap_note_tool(ctx, t))
|
|
elif t.name == "update_note":
|
|
operator_native_tools.append(wrap_update_note_tool(ctx, t))
|
|
elif t.name == "append_note":
|
|
operator_native_tools.append(wrap_note_tool(ctx, t))
|
|
else:
|
|
operator_native_tools.append(t)
|
|
|
|
# Get specialized tools for Operator
|
|
from artemis.tools.history_recall import recall_history_wrapper
|
|
|
|
operator_specialized_wrappers = [
|
|
ask_diagnoser_wrapper,
|
|
run_adb_command_wrapper,
|
|
manage_task_wrapper,
|
|
ask_explorer_wrapper,
|
|
recall_history_wrapper,
|
|
]
|
|
# ask_committee does not follow the Operator's pre-decision / turn-ending contract.
|
|
|
|
operator_specialized_tools = get_tools_from_wrappers(ctx, operator_specialized_wrappers)
|
|
|
|
# Customize descriptions for run_adb_command and manage_task to match exploratory role
|
|
for t in operator_specialized_tools:
|
|
if t.name == "run_adb_command":
|
|
t.description = (
|
|
"[SHELL] Executes a shell command directly on the Android"
|
|
" mobile device via ADB shell. Use it when the device action"
|
|
" tools cannot achieve the goal, or to start background"
|
|
" instrumentation (performance, power, traces); never to bypass"
|
|
" goals that the action tools can achieve.\nCan run synchronously"
|
|
" or transition to a background task if execution takes longer"
|
|
" than WaitMsBeforeAsync.\nSupports persistent environments"
|
|
" (environment variables) on the phone across invocations.\n" + HANG_GUIDANCE
|
|
)
|
|
elif t.name == "manage_task":
|
|
t.description = (
|
|
"[SHELL] Manage background ADB shell tasks launched via"
|
|
" run_adb_command to monitor or terminate ongoing background"
|
|
" commands."
|
|
)
|
|
|
|
operator_tools = operator_native_tools + operator_specialized_tools
|
|
|
|
if ctx.execution_setup and ctx.execution_setup.video_recording_tools_enabled:
|
|
operator_tools.append(get_video_analyzer_tool(ctx, role="operator"))
|
|
|
|
graph_builder.add_node("operator", OperatorNode(ctx, tools=operator_tools))
|
|
graph_builder.add_node("validator", ValidatorNode(ctx))
|
|
|
|
graph_builder.add_node("summarizer", SummarizerNode(ctx))
|
|
graph_builder.add_node("execution_check", functools.partial(execution_check_node, ctx=ctx))
|
|
graph_builder.add_node("perception", functools.partial(perception_node, ctx=ctx))
|
|
graph_builder.add_node(node="convergence", action=convergence_node, defer=True)
|
|
graph_builder.add_node("exit_settlement", functools.partial(exit_settlement_node, ctx=ctx))
|
|
|
|
## Linking nodes
|
|
graph_builder.add_edge(START, "planner")
|
|
graph_builder.add_edge("planner", "convergence")
|
|
graph_builder.add_edge("operator", "execution_check")
|
|
graph_builder.add_conditional_edges(
|
|
"execution_check",
|
|
execution_check_edge,
|
|
{
|
|
"review_subgoals": "convergence",
|
|
"execute_decisions": "validator",
|
|
},
|
|
)
|
|
graph_builder.add_edge("validator", "summarizer")
|
|
graph_builder.add_edge("summarizer", "convergence")
|
|
|
|
graph_builder.add_conditional_edges(
|
|
source="convergence",
|
|
path=functools.partial(convergence_gate, ctx=ctx),
|
|
path_map={
|
|
"continue": "perception",
|
|
"exit_settlement": "exit_settlement",
|
|
"end": END,
|
|
},
|
|
)
|
|
graph_builder.add_conditional_edges(
|
|
source="exit_settlement",
|
|
path=exit_settlement_gate,
|
|
path_map={
|
|
"continue": "perception",
|
|
"end": END,
|
|
},
|
|
)
|
|
graph_builder.add_edge("perception", "operator")
|
|
|
|
return graph_builder.compile()
|