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
@@ -12,10 +12,25 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from artemis.graph.graph import check_plan_mutation_rejections
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.context import ArtemisContext, ExecutionSetup
|
||||
from artemis.graph.graph import (
|
||||
check_plan_mutation_rejections,
|
||||
check_unintended_rewrite,
|
||||
wrap_note_tool,
|
||||
)
|
||||
from artemis.graph.state import State
|
||||
|
||||
CONTINUOUS_PLAN = (
|
||||
"- [x] Open app\n"
|
||||
"- [/] [Loop:continuous] Periodically monitor for new emails "
|
||||
"(Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
|
||||
|
||||
def test_check_plan_mutation_incomplete_nested():
|
||||
content_before = "- [/] Main task\n - [ ] Subtask 1"
|
||||
@@ -26,57 +41,52 @@ def test_check_plan_mutation_incomplete_nested():
|
||||
|
||||
|
||||
def test_check_plan_mutation_delete_continuous_loop():
|
||||
content_before = (
|
||||
"- [x] Open app\n"
|
||||
"- [/] [Loop] Periodically monitor for new emails "
|
||||
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
content_after = (
|
||||
"- [x] Open app\n"
|
||||
"- [x] Monitor for new emails\n"
|
||||
" - [x] Baseline established\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
res = check_plan_mutation_rejections(content_before, content_after)
|
||||
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after)
|
||||
assert res is not None
|
||||
assert "cannot delete an active [Loop]" in res
|
||||
assert "cannot delete an active [Loop:continuous]" in res
|
||||
|
||||
|
||||
def test_check_plan_mutation_mark_continuous_loop_completed():
|
||||
content_before = (
|
||||
"- [x] Open app\n"
|
||||
"- [/] [Loop] Periodically monitor for new emails "
|
||||
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
content_after = (
|
||||
"- [x] Open app\n"
|
||||
"- [x] [Loop] Periodically monitor for new emails "
|
||||
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
res = check_plan_mutation_rejections(content_before, content_after)
|
||||
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
|
||||
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after)
|
||||
assert res is not None
|
||||
assert "cannot be unilaterally marked as completed [x]" in res
|
||||
assert "cannot be" in res and "marked as completed [x]" in res
|
||||
|
||||
|
||||
def test_check_plan_mutation_user_stopped_allowed():
|
||||
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
|
||||
state = MagicMock(spec=State)
|
||||
state.user_stop_requested = True
|
||||
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_check_plan_mutation_stop_wording_is_not_a_signal():
|
||||
"""Natural-language 'stop' phrasing must NOT unlock the continuous loop —
|
||||
only the explicit release_loop signal (user_stop_requested) does."""
|
||||
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
|
||||
state = MagicMock(spec=State)
|
||||
state.user_stop_requested = False
|
||||
state.injected_instruction = "Please stop the task now."
|
||||
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state)
|
||||
assert res is not None
|
||||
|
||||
|
||||
def test_check_plan_mutation_bounded_loop_completion_allowed():
|
||||
"""Plain [Loop] milestones are bounded: the model may complete them."""
|
||||
content_before = (
|
||||
"- [x] Open app\n"
|
||||
"- [/] [Loop] Periodically monitor for new emails "
|
||||
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
"- [/] [Loop] Inspect candidate emails (Exit: all 5 candidates inspected)\n"
|
||||
" - [x] Candidate #5: Done"
|
||||
)
|
||||
content_after = (
|
||||
"- [x] Open app\n"
|
||||
"- [x] [Loop] Periodically monitor for new emails "
|
||||
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
|
||||
" - [x] Polling Check #1: Done"
|
||||
)
|
||||
state = MagicMock(spec=State)
|
||||
state.injected_instruction = "Please stop the task now."
|
||||
res = check_plan_mutation_rejections(content_before, content_after, state=state)
|
||||
content_after = content_before.replace("- [/] [Loop]", "- [x] [Loop]")
|
||||
res = check_plan_mutation_rejections(content_before, content_after)
|
||||
assert res is None
|
||||
|
||||
|
||||
@@ -85,3 +95,209 @@ def test_check_plan_mutation_normal_bounded_task():
|
||||
content_after = "- [x] Open Settings\n- [x] Toggle WiFi"
|
||||
res = check_plan_mutation_rejections(content_before, content_after)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_check_unintended_rewrite_detects_hand_slip():
|
||||
content_before = "- [x] Open Settings app\n- [/] Toggle the WiFi switch"
|
||||
# Full rewrite: milestone 1's status unchanged but text drifted
|
||||
content_after = "- [x] Open the Settings application\n- [/] Toggle the WiFi switch"
|
||||
res = check_unintended_rewrite(content_before, content_after)
|
||||
assert res is not None
|
||||
assert "Open Settings app" in res
|
||||
assert "update_note" in res
|
||||
|
||||
|
||||
def test_check_unintended_rewrite_allows_status_flips_and_appends():
|
||||
content_before = "- [x] Open Settings\n- [/] Toggle WiFi"
|
||||
content_after = "- [x] Open Settings\n- [x] Toggle WiFi\n - [x] Confirmed enabled"
|
||||
assert check_unintended_rewrite(content_before, content_after) is None
|
||||
|
||||
|
||||
def test_check_unintended_rewrite_ignores_structural_replans():
|
||||
# Milestone count changes are declared replans, judged by planner validation
|
||||
content_before = "- [x] Open Settings\n- [/] Toggle WiFi"
|
||||
content_after = "- [x] Open Settings\n- [/] Toggle WiFi\n- [ ] Verify connectivity"
|
||||
assert check_unintended_rewrite(content_before, content_after) is None
|
||||
|
||||
|
||||
# --- §4.3 deterministic check-line guard (independent of planner validation) ---------
|
||||
|
||||
GUARDED_PLAN = (
|
||||
"- [/] Create the alarm\n"
|
||||
" - verify: alarm exists in the list\n"
|
||||
" - assert: a toast appeared\n"
|
||||
"- [ ] Next milestone\n"
|
||||
)
|
||||
|
||||
|
||||
def _guard_ctx(tmp_path, **setup_kwargs):
|
||||
# Planner validation OFF by default: the guard must hold on its own.
|
||||
setup_kwargs.setdefault("disable_planner_validation", True)
|
||||
setup_kwargs.setdefault("disable_checker", False)
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.execution_setup = ExecutionSetup(**setup_kwargs)
|
||||
ctx.data_engine = MagicMock()
|
||||
ctx.data_engine.base_dir = str(tmp_path)
|
||||
ctx.planner_task = None
|
||||
ctx.last_validated_plan = None
|
||||
ctx.pending_validated_plan = None
|
||||
ctx.task_plan_content_before = None
|
||||
ctx.pending_checkpoints = []
|
||||
ctx.checkpoint_tasks = {}
|
||||
return ctx
|
||||
|
||||
|
||||
def _guard_plan_path(tmp_path, content=GUARDED_PLAN):
|
||||
notes_dir = tmp_path / "notes"
|
||||
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = notes_dir / "task_plan.md"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _save_invoke(path):
|
||||
async def fake(tool, args, tool_call_id, state, record_trace=None):
|
||||
path.write_text(args["content"], encoding="utf-8")
|
||||
return "Success"
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def _save_tool():
|
||||
tool = MagicMock()
|
||||
tool.name = "save_note"
|
||||
tool.description = "save_note tool"
|
||||
return tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_deleting_check_line_is_restored(tmp_path):
|
||||
path = _guard_plan_path(tmp_path)
|
||||
ctx = _guard_ctx(tmp_path)
|
||||
|
||||
rewritten = "- [/] Create the alarm\n - assert: a toast appeared\n- [ ] Next milestone\n"
|
||||
with patch(
|
||||
"artemis.graph.graph.invoke_tool_with_injection",
|
||||
side_effect=_save_invoke(path),
|
||||
):
|
||||
wrapped = wrap_note_tool(ctx, _save_tool())
|
||||
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "- verify: alarm exists in the list" in content
|
||||
assert "- assert: a toast appeared" in content
|
||||
# No planner validation was needed or spawned (guard is deterministic)
|
||||
assert ctx.planner_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_rewording_check_line_restores_original_keeps_new(tmp_path):
|
||||
path = _guard_plan_path(tmp_path)
|
||||
ctx = _guard_ctx(tmp_path)
|
||||
|
||||
rewritten = GUARDED_PLAN.replace(
|
||||
" - verify: alarm exists in the list\n",
|
||||
" - verify: alarm roughly configured\n",
|
||||
)
|
||||
with patch(
|
||||
"artemis.graph.graph.invoke_tool_with_injection",
|
||||
side_effect=_save_invoke(path),
|
||||
):
|
||||
wrapped = wrap_note_tool(ctx, _save_tool())
|
||||
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# Original standard restored; the Operator's new line survives as an addition
|
||||
assert "- verify: alarm exists in the list" in content
|
||||
assert "- verify: alarm roughly configured" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_parent_turns_check_into_task_level_at_end(tmp_path):
|
||||
path = _guard_plan_path(tmp_path)
|
||||
ctx = _guard_ctx(tmp_path)
|
||||
|
||||
# Whole parent subgoal removed
|
||||
rewritten = "- [ ] Next milestone\n"
|
||||
with patch(
|
||||
"artemis.graph.graph.invoke_tool_with_injection",
|
||||
side_effect=_save_invoke(path),
|
||||
):
|
||||
wrapped = wrap_note_tool(ctx, _save_tool())
|
||||
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "- verify@end: alarm exists in the list" in content
|
||||
assert "- assert@end: a toast appeared" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_adding_check_lines_is_allowed(tmp_path):
|
||||
path = _guard_plan_path(tmp_path)
|
||||
ctx = _guard_ctx(tmp_path)
|
||||
|
||||
extended = GUARDED_PLAN + " - verify: extra criterion\n"
|
||||
with patch(
|
||||
"artemis.graph.graph.invoke_tool_with_injection",
|
||||
side_effect=_save_invoke(path),
|
||||
):
|
||||
wrapped = wrap_note_tool(ctx, _save_tool())
|
||||
result = await wrapped.ainvoke(
|
||||
{"key": "task_plan", "content": extended, "tool_call_id": "t"}
|
||||
)
|
||||
|
||||
assert result == "Success"
|
||||
assert "- verify: extra criterion" in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guard_holds_with_planner_validation_disabled_and_checks_off(tmp_path):
|
||||
"""The guard is content-driven: it protects check lines even when every
|
||||
check switch AND planner validation are disabled."""
|
||||
path = _guard_plan_path(tmp_path)
|
||||
ctx = _guard_ctx(tmp_path, disable_checker=True, disable_planner_validation=True)
|
||||
|
||||
with patch(
|
||||
"artemis.graph.graph.invoke_tool_with_injection",
|
||||
side_effect=_save_invoke(path),
|
||||
):
|
||||
wrapped = wrap_note_tool(ctx, _save_tool())
|
||||
await wrapped.ainvoke(
|
||||
{
|
||||
"key": "task_plan",
|
||||
"content": "- [/] Create the alarm\n- [ ] Next milestone\n",
|
||||
"tool_call_id": "t",
|
||||
}
|
||||
)
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "- verify: alarm exists in the list" in content
|
||||
assert "- assert: a toast appeared" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_planner_node_writes_are_not_guarded():
|
||||
"""The Planner keeps revision authority over check standards: its node is
|
||||
wired with the UNWRAPPED note tools, so the guard never applies to it."""
|
||||
from artemis.context import DeviceContext, DevicePlatform
|
||||
from artemis.graph.graph import get_graph
|
||||
|
||||
device = DeviceContext(
|
||||
host_platform="LINUX",
|
||||
mobile_platform=DevicePlatform.ANDROID,
|
||||
device_id="dummy",
|
||||
device_width=1080,
|
||||
device_height=2400,
|
||||
)
|
||||
ctx = ArtemisContext(device=device, execution_setup=ExecutionSetup())
|
||||
graph = await get_graph(ctx)
|
||||
|
||||
planner_tools = {t.name: t for t in graph.nodes["planner"].bound.afunc.tools}
|
||||
operator_tools = {t.name: t for t in graph.nodes["operator"].bound.afunc.tools}
|
||||
|
||||
# Operator's plan-writing tools go through the guard wrapper...
|
||||
assert operator_tools["save_note"].coroutine.__name__ == "wrapped_note_tool"
|
||||
assert operator_tools["update_note"].coroutine.__name__ == "wrapped_update_note"
|
||||
# ...the Planner's do not.
|
||||
assert getattr(planner_tools["save_note"].coroutine, "__name__", "") != "wrapped_note_tool"
|
||||
assert getattr(planner_tools["update_note"].coroutine, "__name__", "") != "wrapped_update_note"
|
||||
Reference in new issue
Block a user