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:
1 parent
ddfa420945
commit
cbe8f4f0c6
106 files changed
+11542
-4445
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
+21
-15
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user