feat: harden agent verification and runtime contracts

Introduce two-gate checker settlement, explicit graph visibility and checkpoints, structured LLM reliability, centralized action specifications, and corresponding tests and design documentation.
This commit is contained in:
somew1nd committed 2026-08-31 20:42:24 -07:00
1 parent ddfa420945
commit cbe8f4f0c6
106 files changed
+11542 -4445

No files matched your search

+649
View File
@@ -0,0 +1,649 @@
# 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.
"""Plan-driven checkpoint scheduling, the append-only verdict ledger, and exit
settlement bookkeeping.
Three invariants from the redesign govern everything here:
1. **Append-only history**: the Operator's context is never rolled back or
switched; verdicts influence the future (status flips forward, findings
injected) but never rewrite the past.
2. **Release / verdict / wrap-up separation**: fail-open only affects the
release decision — the verdict value itself (``inconclusive``) is always
recorded verbatim; assert failures are never repaired but always produce a
machine-readable test result.
3. **Zero side effects inside check tasks**: state mutation happens only at
harvest points (``execution_check_node``) and at exit settlement.
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.plan_grammar import (
CHECKBOX_LINE_RE,
CheckItem,
PlanSnapshot,
parse_plan,
subgoal_hash,
)
logger = get_logger(__name__)
LEDGER_FILENAME = "check_ledger.jsonl"
RUN_OUTCOME_FILENAME = "run_outcome.json"
# --- Config accessors (all fail-safe against a missing execution_setup) --------------
def _setup(ctx) -> Any:
return getattr(ctx, "execution_setup", None)
def midway_checks_enabled(ctx) -> bool:
setup = _setup(ctx)
return bool(setup) and bool(getattr(setup, "midway_checks_enabled", False))
def final_check_enabled(ctx) -> bool:
setup = _setup(ctx)
return bool(setup) and bool(getattr(setup, "final_check_enabled", False))
def _setting(ctx, name: str, default):
setup = _setup(ctx)
value = getattr(setup, name, None) if setup else None
return default if value is None else value
# --- Data contracts ------------------------------------------------------------------
@dataclass(frozen=True)
class EvidenceAnchor:
"""Anchors a checkpoint to the evidence available at completion time."""
anchor_step_id: str | None
trigger_ts: float
plan_text: str
@dataclass(frozen=True)
class PendingCheckpoint:
"""A queued checkpoint: enqueue happens at plan-write time, spawn happens in
``execution_check_node`` after the turn's step is recorded."""
checkpoint_id: str
subgoal_text: str
check_items: tuple[CheckItem, ...]
plan_text: str
trigger_ts: float
@dataclass(frozen=True)
class CheckpointRun:
"""One in-flight (or finished) check attempt held in ``ctx.checkpoint_tasks``."""
attempt_id: str
task: asyncio.Task
checkpoint: PendingCheckpoint
class TestSummary(BaseModel):
passed: int = 0
failed: int = 0
inconclusive: int = 0
unchecked: int = 0
failed_items: list[dict] = Field(default_factory=list)
@property
def total(self) -> int:
return self.passed + self.failed + self.inconclusive + self.unchecked
class RunOutcome(BaseModel):
"""Machine-readable run outcome: goal-achievement axis x test-result axis."""
task_status: Literal["completed", "partial", "blocked"]
tests: TestSummary
# --- Ledger (append-only) ------------------------------------------------------------
def ledger_path(base_dir: str | Path) -> Path:
return Path(base_dir) / LEDGER_FILENAME
def append_ledger_record(base_dir: str | Path, record: dict) -> None:
"""Appends one verdict record. Records are immutable once written; any later
result for the same item can only be appended as a new record."""
record = dict(record)
record.setdefault("ts", time.time())
path = ledger_path(base_dir)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
logger.error(f"Failed to append check ledger record: {e}")
def read_ledger(base_dir: str | Path) -> list[dict]:
path = ledger_path(base_dir)
if not path.exists():
return []
records: list[dict] = []
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except Exception:
logger.warning(f"Skipping malformed ledger line: {line[:120]}")
except Exception as e:
logger.error(f"Failed to read check ledger: {e}")
return records
def has_ledger_records(base_dir: str | Path) -> bool:
path = ledger_path(base_dir)
try:
return path.exists() and path.stat().st_size > 0
except Exception:
return False
# --- Plan helpers --------------------------------------------------------------------
def _read_plan_text(ctx) -> str:
if not getattr(ctx, "data_engine", None):
return ""
path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
if not path.exists():
return ""
try:
return path.read_text(encoding="utf-8")
except Exception as e:
logger.error(f"Failed to read task plan: {e}")
return ""
def _subgoal_still_current(ctx, checkpoint_id: str) -> bool:
"""The anchored subgoal's text is unchanged iff an item with the same
content hash still exists in the current plan."""
snapshot = parse_plan(_read_plan_text(ctx))
return any(i.is_top_level and i.key == checkpoint_id for i in snapshot.items)
def revert_subgoal_status(ctx, subgoal_key: str) -> bool:
"""Flips the matching top-level subgoal from ``[x]`` back to ``[/]``.
This is a forward-looking state change (never a rollback of history) and is
written directly to the file, bypassing the wrapped note tools.
"""
if not getattr(ctx, "data_engine", None):
return False
path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
if not path.exists():
return False
try:
lines = path.read_text(encoding="utf-8").split("\n")
changed = False
for idx, line in enumerate(lines):
match = CHECKBOX_LINE_RE.match(line)
if not match or match.group("indent"):
continue
if subgoal_hash(match.group("text").strip()) == subgoal_key:
if match.group("status") == "x":
lines[idx] = re.sub(r"^(\s*-\s*\[)x(\])", r"\g<1>/\g<2>", line)
changed = True
break
if changed:
path.write_text("\n".join(lines), encoding="utf-8")
return changed
except Exception as e:
logger.error(f"Failed to revert subgoal status: {e}")
return False
# --- Queueing (called from _process_plan_write; never spawns) ------------------------
def queue_checkpoints(
ctx,
state,
after: PlanSnapshot,
new_completions: list[str],
plan_text: str,
) -> None:
"""Enqueues a pending checkpoint for every newly completed top-level subgoal
that carries ``on_complete`` check items.
Spawning is deliberately deferred to ``execution_check_node``: at this
moment the turn's step is not yet recorded, so an anchor taken here would
point at the previous turn.
"""
if not midway_checks_enabled(ctx):
return
if state is not None and getattr(state, "user_stop_requested", False):
return
items_by_key = {i.key: i for i in after.top_level}
for text in new_completions:
key = subgoal_hash(text)
item = items_by_key.get(key)
if item is None:
continue
check_items = tuple(ci for ci in after.check_items_of(item) if ci.when == "on_complete")
if not check_items:
continue
ctx.pending_checkpoints.append(
PendingCheckpoint(
checkpoint_id=key,
subgoal_text=text,
check_items=check_items,
plan_text=plan_text,
trigger_ts=time.time(),
)
)
logger.info(f"Queued checkpoint for completed subgoal: {text}")
# --- Harvest (§5.2) ------------------------------------------------------------------
def _record_verdicts(
ctx,
run: CheckpointRun,
verdicts: list,
anchor_step_id: str | None,
) -> None:
base_dir = ctx.data_engine.base_dir
when_by_text = {(ci.kind, ci.text): ci.when for ci in run.checkpoint.check_items}
for v in verdicts:
append_ledger_record(
base_dir,
{
"attempt_id": run.attempt_id,
"checkpoint_id": run.checkpoint.checkpoint_id,
"item_text": v.item_text,
"kind": v.kind,
"when": when_by_text.get((v.kind, v.item_text), "on_complete"),
"status": v.status,
"evidence": v.evidence,
"suggestion": getattr(v, "suggestion", ""),
"anchor_step_id": anchor_step_id,
},
)
def _record_attempt_failure(ctx, run: CheckpointRun, status: str, evidence: str) -> None:
base_dir = ctx.data_engine.base_dir
for ci in run.checkpoint.check_items:
append_ledger_record(
base_dir,
{
"attempt_id": run.attempt_id,
"checkpoint_id": run.checkpoint.checkpoint_id,
"item_text": ci.text,
"kind": ci.kind,
"when": ci.when,
"status": status,
"evidence": evidence,
"anchor_step_id": None,
},
)
def harvest_run(
ctx,
state,
run: CheckpointRun,
*,
allow_side_effects: bool,
cancelled_status: str = "superseded",
anchor_step_id: str | None = None,
) -> list[str]:
"""Books a finished (done/cancelled) attempt into the ledger and, when the
verdict is still applicable, applies its execution-state side effects.
Returns Operator-facing findings (injected as ``operator_feedback``). The
verdict values themselves are never altered by the release decision:
an errored verify is *released* (fail-open) but *recorded* inconclusive.
"""
task = run.task
findings: list[str] = []
checkpoint = run.checkpoint
if task.cancelled():
evidence = (
"attempt superseded by a newer completion of the same subgoal"
if cancelled_status == "superseded"
else "cancelled at exit settlement (settlement timeout)"
)
_record_attempt_failure(ctx, run, cancelled_status, evidence)
return findings
exc = task.exception()
if exc is not None:
status = "inconclusive"
reason = (
f"check timed out after {_setting(ctx, 'checkpoint_timeout', 180.0)}s"
if isinstance(exc, (asyncio.TimeoutError, TimeoutError))
else f"check raised an exception: {exc}"
)
logger.warning(
f"Checkpoint attempt {run.attempt_id} did not produce a verdict"
f" ({reason}); releasing (fail-open) but recording inconclusive."
)
_record_attempt_failure(ctx, run, status, reason)
return findings
report = task.result()
verdicts = list(getattr(report, "verdicts", []) or [])
_record_verdicts(ctx, run, verdicts, anchor_step_id)
# Applicability gate for side effects: the verdict must belong to the
# current attempt of its checkpoint AND the anchored subgoal's text must be
# unchanged in the current plan. Stale/mismatched verdicts are ledger-only.
applicable = allow_side_effects and _subgoal_still_current(ctx, checkpoint.checkpoint_id)
user_stopped = bool(state is not None and getattr(state, "user_stop_requested", False))
verify_failures = [v for v in verdicts if v.kind == "verify" and v.status == "failed"]
assert_failures = [v for v in verdicts if v.kind == "assert" and v.status == "failed"]
if verify_failures and applicable and not user_stopped:
max_repairs = int(_setting(ctx, "checkpoint_max_repairs", 2))
repairs = ctx.checkpoint_repairs.get(checkpoint.checkpoint_id, 0)
if repairs < max_repairs:
ctx.checkpoint_repairs[checkpoint.checkpoint_id] = repairs + 1
reverted = revert_subgoal_status(ctx, checkpoint.checkpoint_id)
for v in verify_failures:
suggestion = getattr(v, "suggestion", "") or ""
findings.append(
f"[verify failed] Subgoal '{checkpoint.subgoal_text}' —"
f" criterion '{v.item_text}': {v.evidence}"
+ (f" Suggestion: {suggestion}" if suggestion else "")
)
if reverted:
findings.append(
f"The subgoal '{checkpoint.subgoal_text}' has been set back to"
" in-progress ([/]). Address the findings above, then complete"
" it again."
)
else:
logger.warning(
f"Checkpoint {checkpoint.checkpoint_id} exhausted its repair"
f" quota ({max_repairs}); verdict stays failed, no further"
" repair loop."
)
if assert_failures:
policy = str(_setting(ctx, "assert_failure_policy", "continue"))
if policy == "halt":
ctx.assert_halt = True
logger.warning(
"Assert failure under 'halt' policy: latching halt flag for exit settlement."
)
return findings
def harvest_finished_checkpoints(ctx, state) -> list[str]:
"""Non-blocking harvest: books every ``done()`` attempt, never awaits a
running one. Returns Operator-facing findings."""
if not getattr(ctx, "data_engine", None):
return []
findings: list[str] = []
for cid, run in list(ctx.checkpoint_tasks.items()):
if not run.task.done():
continue
del ctx.checkpoint_tasks[cid]
findings.extend(harvest_run(ctx, state, run, allow_side_effects=True))
return findings
# --- Spawn (§5.1: after record_step) -------------------------------------------------
async def spawn_pending_checkpoints(ctx, state, anchor_step_id: str | None) -> list[str]:
"""Spawns queued checkpoints against the just-recorded step, honoring the
concurrency cap and superseding stale attempts without losing verdicts.
Returns findings harvested from superseded-but-finished attempts.
"""
findings: list[str] = []
if not getattr(ctx, "data_engine", None):
return findings
if not midway_checks_enabled(ctx):
return findings
if state is not None and getattr(state, "user_stop_requested", False):
return findings
from artemis.agents.checker.checker import run_checkpoint_check
max_concurrent = int(_setting(ctx, "max_concurrent_checkpoints", 3))
timeout = float(_setting(ctx, "checkpoint_timeout", 180.0))
goal = getattr(state, "initial_goal", "") if state is not None else ""
while ctx.pending_checkpoints and len(ctx.checkpoint_tasks) < max_concurrent:
pending: PendingCheckpoint = ctx.pending_checkpoints.pop(0)
cid = pending.checkpoint_id
old_run: CheckpointRun | None = ctx.checkpoint_tasks.pop(cid, None)
if old_run is not None:
if old_run.task.done():
# A finished-but-unharvested attempt: book it first (verdicts,
# including failures, are never dropped), ledger-only.
findings.extend(harvest_run(ctx, state, old_run, allow_side_effects=False))
else:
old_run.task.cancel()
try:
await old_run.task
except (asyncio.CancelledError, Exception):
pass
harvest_run(ctx, state, old_run, allow_side_effects=False)
seq = ctx.checkpoint_attempt_seq.get(cid, 0) + 1
ctx.checkpoint_attempt_seq[cid] = seq
attempt_id = f"{cid}#{seq}"
anchor = EvidenceAnchor(
anchor_step_id=anchor_step_id,
trigger_ts=pending.trigger_ts,
plan_text=pending.plan_text,
)
task = asyncio.create_task(
asyncio.wait_for(
run_checkpoint_check(
ctx,
check_items=pending.check_items,
anchor=anchor,
goal=goal,
subgoal_text=pending.subgoal_text,
),
timeout=timeout,
)
)
ctx.checkpoint_tasks[cid] = CheckpointRun(
attempt_id=attempt_id, task=task, checkpoint=pending
)
logger.info(f"Spawned checkpoint attempt {attempt_id} anchored at step {anchor_step_id}")
return findings
# --- Exit settlement barrier (§5.3 phase 1) ------------------------------------------
async def settle_all_checkpoints(ctx, state) -> None:
"""The one intentional blocking wait of the whole flow: collects every
outstanding checkpoint before exit, bounded by ``settlement_timeout``.
Attempts still running at the deadline are cancelled and recorded
``unchecked`` (settlement timeout). Runs unconditionally — a started check
must be booked before exit regardless of the final-check switch."""
if not getattr(ctx, "data_engine", None):
return
runs = list(ctx.checkpoint_tasks.values())
ctx.checkpoint_tasks.clear()
if not runs:
return
timeout = float(_setting(ctx, "settlement_timeout", 120.0))
tasks = [r.task for r in runs]
try:
_done, pending = await asyncio.wait(tasks, timeout=timeout)
except Exception as e:
logger.error(f"Settlement wait failed: {e}")
pending = {t for t in tasks if not t.done()}
for t in pending:
t.cancel()
for t in pending:
try:
await t
except (asyncio.CancelledError, Exception):
pass
for run in runs:
# Settlement is bookkeeping, not a repair point: side effects off.
harvest_run(
ctx,
state,
run,
allow_side_effects=False,
cancelled_status="unchecked",
)
# --- Run outcome ---------------------------------------------------------------------
_RESOLVABLE = ("passed", "failed", "inconclusive")
def resolve_item_status(kind: str, records: list[dict]) -> str:
"""Resolves one check item's final status from its ledger records.
Assert: the first failure is permanent — any failed record makes the item
failed. Verify: the latest substantive verdict wins (a repaired-then-passed
verify is passed). No substantive record at all -> unchecked.
"""
substantive = [r for r in records if r.get("status") in _RESOLVABLE]
if kind == "assert" and any(r.get("status") == "failed" for r in substantive):
return "failed"
if substantive:
return substantive[-1]["status"]
return "unchecked"
def compute_test_summary(check_items: list[CheckItem], records: list[dict]) -> TestSummary:
summary = TestSummary()
by_item: dict[tuple[str, str], list[dict]] = {}
for r in records:
by_item.setdefault((str(r.get("kind")), str(r.get("item_text"))), []).append(r)
seen: set[tuple[str, str]] = set()
all_items: list[tuple[str, str]] = []
for ci in check_items:
sig = (ci.kind, ci.text)
if sig not in seen:
seen.add(sig)
all_items.append(sig)
# Items that only ever existed in the ledger (e.g. their plan lines were
# legitimately revised by the Planner) still count.
for sig in by_item:
if sig not in seen:
seen.add(sig)
all_items.append(sig)
for kind, text in all_items:
status = resolve_item_status(kind, by_item.get((kind, text), []))
if status == "passed":
summary.passed += 1
elif status == "failed":
summary.failed += 1
failing = [r for r in by_item.get((kind, text), []) if r.get("status") == "failed"]
summary.failed_items.append(
{
"item_text": text,
"kind": kind,
"evidence": failing[-1].get("evidence", "") if failing else "",
}
)
elif status == "inconclusive":
summary.inconclusive += 1
else:
summary.unchecked += 1
return summary
def compute_run_outcome(
snapshot: PlanSnapshot,
records: list[dict],
*,
verify_blocked: bool,
) -> RunOutcome:
"""Assembles the machine-readable run outcome.
``verify_blocked`` marks the "verify unmet and budget exhausted" exit —
the goal-achievement axis then reads blocked even though the plan claims
completion. Assert failures never change ``task_status``: a finished task
with failed assertions is ``completed`` with ``tests.failed > 0``.
"""
tests = compute_test_summary(list(snapshot.all_check_items), records)
if verify_blocked:
task_status: Literal["completed", "partial", "blocked"] = "blocked"
elif snapshot.all_top_level_done:
task_status = "completed"
else:
task_status = "partial"
return RunOutcome(task_status=task_status, tests=tests)
def write_run_outcome(base_dir: str | Path, outcome: RunOutcome, extra: dict | None = None) -> None:
try:
payload = outcome.model_dump()
if extra:
payload.update(extra)
path = Path(base_dir) / RUN_OUTCOME_FILENAME
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception as e:
logger.error(f"Failed to write run outcome: {e}")
def read_run_outcome(base_dir: str | Path) -> dict | None:
path = Path(base_dir) / RUN_OUTCOME_FILENAME
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
+565 -514
View File
File diff suppressed because it is too large. Load diff
+21 -15
View File
@@ -22,6 +22,7 @@ from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import (
@@ -71,15 +72,23 @@ def _should_skip_settling(state: State) -> bool:
@trace(type="agent", name="perception")
def _check_injected_instruction_file(base_dir: str) -> str | None:
def _check_injected_instruction_file(base_dir: str) -> dict | None:
"""Consumes a pending injected-instruction payload, if any.
Returns the payload dict: `instruction` (str | None) plus the explicit
`release_loop` stop signal (bool) declared by the injecting caller.
"""
instruction_path = Path(base_dir) / "injected_instruction.json"
if instruction_path.exists():
with open(instruction_path, encoding="utf-8") as f:
data = json.load(f)
injected_instruction = data.get("instruction")
instruction_path.unlink()
logger.info(f"Read and deleted injected instruction: '{injected_instruction}'")
return injected_instruction
payload = {
"instruction": data.get("instruction"),
"release_loop": bool(data.get("release_loop")),
}
logger.info(f"Read and deleted injected instruction payload: {payload}")
return payload
return None
@@ -88,6 +97,7 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
Always executes at the start of an operator cycle.
"""
state = strict_state(state, "perception")
logger.info("Entering Perception Node...")
if not ctx.data_engine.current_step_id:
@@ -96,10 +106,14 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
# Check for injected instruction without blocking event loop
injected_instruction = None
user_stop_requested = False
if ctx.data_engine and ctx.data_engine.base_dir:
injected_instruction = await asyncio.to_thread(
injected_payload = await asyncio.to_thread(
_check_injected_instruction_file, str(ctx.data_engine.base_dir)
)
if injected_payload:
injected_instruction = injected_payload.get("instruction")
user_stop_requested = bool(injected_payload.get("release_loop"))
controller = UnifiedMobileController(ctx)
@@ -171,16 +185,6 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
)
logger.info(f"Offloaded perception data saving to background with image_name: {image_name}")
# Cache latest perception data on ArtemisContext for downstream zero-latency reuse (e.g. Checker)
ctx.latest_perception_data = {
"screenshot_b64": latest_screenshot_b64,
"latest_ui_hierarchy": fused_xml,
"xml_hierarchy": xml_hierarchy if xml_hierarchy is not None else [],
"ocr_results": ocr_results,
"width": device_data.width,
"height": device_data.height,
}
# Return state update
update = {
"latest_screenshot": screenshot_path,
@@ -193,5 +197,7 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
"height": device_data.height,
},
"injected_instruction": injected_instruction,
# sticky_or reducer: once latched True, later False updates keep it True
"user_stop_requested": user_stop_requested,
}
return update
+78 -64
View File
@@ -12,14 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Annotated, Any
from langchain_core.messages import AnyMessage
from langgraph.graph import add_messages
from artemis.config import AgentNode
from artemis.context import ArtemisContext
from artemis.utils.logger import get_logger
from typing import Annotated
from pydantic import BaseModel, ConfigDict
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -28,38 +26,91 @@ def take_last(a, b):
return b
def sticky_or(a, b):
"""Reducer that latches True: once set, the flag survives later updates."""
return bool(a) or bool(b)
class State(BaseModel):
"""Graph channel state.
Every field is explicit (``extra="forbid"``): an undeclared write or an
unknown constructor key fails loudly instead of becoming a silent ghost
key. Undeclared *reads* (``getattr(state, k, default)``) are guarded by the
node visibility manifest in ``artemis.graph.visibility`` instead.
"""
model_config = ConfigDict(
arbitrary_types_allowed=True,
extra="allow",
extra="forbid",
)
messages: Annotated[list[AnyMessage], "Sequential messages", add_messages]
remaining_steps: Annotated[int | None, "Remaining steps before the task is completed"] = None
# planner related keys
# ── Control plane (cross-turn signals & routing) ─────────────────────
initial_goal: Annotated[str, "Initial goal given by the user"]
injected_instruction: Annotated[
str | None,
"Injected instruction from user for micro-adjustment",
take_last,
] = None
user_stop_requested: Annotated[
bool,
"Latched True once the user externally signals stop (release_loop),"
" unlocking completion of [Loop:continuous] milestones",
sticky_or,
] = False
checker_success: Annotated[
bool | None,
"True if checker succeeded or not triggered, False if failed",
take_last,
] = None
operator_feedback: Annotated[
list[str] | None,
"Source-tagged findings ([checker]/[planner]/[final check]) injected"
" append-only into the Operator's next prompt (never switches the"
" prompt template)",
take_last,
] = None
run_outcome: Annotated[
dict | None,
"Machine-readable run outcome (task_status + test summary), populated"
" by exit settlement before END",
take_last,
] = None
exit_settlement_route: Annotated[
str | None,
"Routing decision produced by exit_settlement_node ('continue' | 'end')",
take_last,
] = None
# operator related keys (perception)
# ── Perception (moves behind PerceptionStore in Phase 2) ─────────────
latest_ui_hierarchy: Annotated[
list[dict] | None, "Latest UI hierarchy of the device", take_last
]
latest_screenshot: Annotated[str | None, "Path to the latest screenshot", take_last]
focused_app_info: Annotated[str | None, "Focused app info", take_last]
device_date: Annotated[str | None, "Date of the device", take_last]
] = None
latest_screenshot: Annotated[str | None, "Path to the latest screenshot", take_last] = None
operator_raw_data: Annotated[
dict | None,
"Raw perception data from operator (screenshot, xml, ocr)",
take_last,
] = None
indexed_points: Annotated[
list[list[int]] | None,
"Active coordinate mappings matching indexed text elements",
take_last,
] = None
indexed_elements: Annotated[
list[dict] | None,
"Active interactable elements matching indexed text elements,"
" containing text, bounds, class, and center coordinates.",
take_last,
] = None
# operator related keys (decisions)
# ── Turn products (previous turn's decisions & results) ──────────────
current_step_id: Annotated[str | None, "Current step ID from data engine", take_last] = None
structured_decisions: Annotated[
str | None,
"Structured decisions made by the operator, for the validator to follow",
take_last,
]
] = None
operator_raw_thinking: Annotated[
str | None,
"Raw thinking process of the operator",
@@ -75,60 +126,23 @@ class State(BaseModel):
"Short term memory / scratchpad for the operator",
take_last,
] = None
complete_subgoals_by_ids: Annotated[
list[str],
"List of subgoal IDs to complete",
take_last,
]
current_agent: Annotated[str | None, "Current active agent", take_last] = None
operator_replan_reason: Annotated[str | None, "Reason for operator replan", take_last] = None
subgoal_plan: Annotated[list[Any] | None, "Subgoal plan", take_last] = None
operator_tactical_plan: Annotated[list[Any] | None, "Operator tactical plan", take_last] = None
# validator related keys
validator_messages: Annotated[list[AnyMessage], "Sequential Validator messages", add_messages]
last_execution_result: Annotated[
dict | None, "Last execution result from validator", take_last
] = None
current_step_id: Annotated[str | None, "Current step ID from data engine", take_last] = None
operator_raw_data: Annotated[
dict | None,
"Raw perception data from operator (screenshot, xml, ocr)",
take_last,
] = None
checker_success: Annotated[
bool | None,
"True if checker succeeded or not triggered, False if failed",
take_last,
] = None
subagent_calls: Annotated[
list[str], "List of sub-agent calls in current session", take_last
] = []
operator_replied: Annotated[bool | None, "True if operator replied to checker", take_last] = (
None
)
operator_tool_limit_exceeded: Annotated[
bool | None,
"True if operator exceeded tool call limit in the previous turn",
take_last,
] = None
indexed_points: Annotated[
list[list[int]] | None,
"Active coordinate mappings matching indexed text elements",
take_last,
] = None
indexed_elements: Annotated[
list[dict] | None,
"Active interactable elements matching indexed text elements,"
" containing text, bounds, class, and center coordinates.",
take_last,
] = None
async def asanitize_update(
self,
ctx: ArtemisContext,
update: dict,
agent: AgentNode | None = None,
):
"""Sanitizes the state update to ensure it is valid and apply side effect logic where required."""
return update
@classmethod
def initial(cls, goal: str) -> "State":
"""Single source for the graph's initial state.
Every entrypoint (SDK, engine runners, tests) must construct the
first State through here so required fields stay in one place.
"""
return cls(initial_goal=goal)
+227
View File
@@ -0,0 +1,227 @@
# 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.
"""Per-node State visibility manifests with test-time enforcement.
``extra="forbid"`` on :class:`~artemis.graph.state.State` catches ghost
*writes*; ghost *reads* (``getattr(state, key, default)`` silently returning
the default) can only be caught by policing reads against a declared manifest.
The manifest below is the declared visible domain of every graph node — the
authoritative answer to "which State fields does this agent see / produce".
Enforcement is zero-cost in production: :func:`strict_state` returns the state
unchanged unless ``ARTEMIS_STRICT_STATE=1`` (set in tests), in which case field
reads outside the node's manifest raise :class:`VisibilityError`.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from artemis.graph.state import State
class VisibilityError(Exception):
"""A node touched a State field outside its declared visible domain."""
@dataclass(frozen=True)
class NodeVisibility:
reads: frozenset[str]
writes: frozenset[str]
@property
def visible(self) -> frozenset[str]:
return self.reads | self.writes
def _vis(reads: set[str], writes: set[str]) -> NodeVisibility:
return NodeVisibility(reads=frozenset(reads), writes=frozenset(writes))
#: Declared visible domain per node. Reads include fields touched by the tools
#: a node invokes with ``InjectedState`` (they run inside the node's turn).
#: Start generous, tighten over time — an entry here is a documented decision.
NODE_VISIBILITY: dict[str, NodeVisibility] = {
"perception": _vis(
reads={"structured_decisions"},
writes={
"latest_screenshot",
"latest_ui_hierarchy",
"operator_raw_data",
"injected_instruction",
"user_stop_requested",
},
),
"planner": _vis(
reads={"initial_goal", "latest_screenshot"},
# Mutates latest_screenshot in place when it captures its own frame
# (planner runs before perception on the first turn).
writes={"latest_screenshot"},
),
"operator": _vis(
reads={
"initial_goal",
"injected_instruction",
"user_stop_requested",
"operator_feedback",
"operator_raw_data",
"latest_ui_hierarchy",
"latest_screenshot",
"indexed_points",
"indexed_elements",
"short_term_memory",
"subagent_calls",
"current_step_id",
"operator_tool_limit_exceeded",
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
},
writes={
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
"short_term_memory",
"indexed_points",
"indexed_elements",
"current_step_id",
"subagent_calls",
"operator_tool_limit_exceeded",
},
),
"execution_check": _vis(
reads={
"operator_raw_data",
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
"user_stop_requested",
"initial_goal",
},
writes={
"checker_success",
"structured_decisions",
"current_step_id",
"operator_feedback",
"subagent_calls",
},
),
"validator": _vis(
reads={
"initial_goal",
"structured_decisions",
"latest_screenshot",
"latest_ui_hierarchy",
"current_step_id",
"operator_raw_data",
"operator_raw_thinking",
"operator_native_thinking",
"indexed_points",
"indexed_elements",
},
writes={
"last_execution_result",
# In-place turn-scoped mutations (formalized as TurnWorkspace in
# Phase 2): post-action screenshot and re-derived index mappings.
"latest_screenshot",
"indexed_points",
"indexed_elements",
},
),
"summarizer": _vis(
reads={
"current_step_id",
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
"last_execution_result",
},
writes=set(),
),
"convergence": _vis(
reads={"checker_success", "user_stop_requested"},
writes=set(),
),
"exit_settlement": _vis(
reads={"user_stop_requested", "initial_goal"},
writes={"exit_settlement_route", "operator_feedback", "run_outcome"},
),
}
_STATE_FIELDS = frozenset(State.model_fields.keys())
def strict_enabled() -> bool:
return os.environ.get("ARTEMIS_STRICT_STATE", "") == "1"
class StateView:
"""Read-policing proxy handed to nodes under ``ARTEMIS_STRICT_STATE=1``.
Only declared State *fields* are policed; methods and pydantic internals
pass through. Attribute writes delegate to the underlying State (in-place
turn mutations are Phase 2 scope) but are still checked against the
node's write set.
"""
__slots__ = ("_state", "_node", "_visibility")
def __init__(self, state: State, node: str):
object.__setattr__(self, "_state", state)
object.__setattr__(self, "_node", node)
object.__setattr__(self, "_visibility", NODE_VISIBILITY[node])
def __getattr__(self, name: str) -> Any:
if name in _STATE_FIELDS and name not in self._visibility.visible:
raise VisibilityError(
f"Node '{self._node}' read undeclared State field '{name}'."
" Declare it in NODE_VISIBILITY or stop reading it."
)
return getattr(self._state, name)
def __setattr__(self, name: str, value: Any) -> None:
if name in _STATE_FIELDS and name not in self._visibility.writes:
raise VisibilityError(
f"Node '{self._node}' mutated undeclared State field '{name}'."
" Declare it in NODE_VISIBILITY or stop writing it."
)
setattr(self._state, name, value)
def strict_state(state: State, node: str) -> State | StateView:
"""Wraps ``state`` in a policing view when strict mode is on (tests)."""
if strict_enabled() and isinstance(state, State):
return StateView(state, node)
return state
def check_update(node: str, update: dict | None) -> None:
"""Validates a node's returned update keys against its write manifest."""
if not update:
return
illegal = set(update) & _STATE_FIELDS - NODE_VISIBILITY[node].writes
if illegal:
raise VisibilityError(f"Node '{node}' returned undeclared update keys: {sorted(illegal)}.")
def validate_manifest() -> list[str]:
"""Returns problems in the manifest itself (ghost fields). Empty = clean."""
problems = []
for node, vis in NODE_VISIBILITY.items():
for field in vis.visible - _STATE_FIELDS:
problems.append(f"{node}: '{field}' is not a declared State field")
return problems