feat(planner): persist reusable action semantics
Tests / Test passed: 879

This commit is contained in:
2026-07-15 18:14:28 +08:00
parent 361dada276
commit d69be48f96
41 changed files with 733 additions and 116 deletions
+36 -4
View File
@@ -57,7 +57,12 @@ def _context() -> TaskContext:
def test_ai_planner_returns_single_planned_step_for_action_decision() -> None:
client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
purpose="Open the send control.",
expected_outcome="The message composer is focused.",
)
)
planner = AIPlanner(client=client)
@@ -66,8 +71,11 @@ def test_ai_planner_returns_single_planned_step_for_action_decision() -> None:
assert len(steps) == 1
step = steps[0]
assert step.action == "tap"
assert step.description == "AI planner: tap({'x': 1, 'y': 2})"
assert step.description == "AI planner: Open the send control."
assert step.args == {"x": 1, "y": 2}
assert step.purpose == "Open the send control."
assert step.expected_outcome == "The message composer is focused."
assert step.expected_text == "The message composer is focused."
def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
@@ -204,6 +212,8 @@ def test_ai_planner_propagates_rationale_and_thinking_to_planned_step() -> None:
arguments={"x": 1, "y": 2},
text_output="Previous step opened settings. Now tapping account.",
thinking="I need to navigate deeper.",
purpose="Open account settings.",
expected_outcome="The account settings page is visible.",
)
)
planner = AIPlanner(client=client)
@@ -212,6 +222,8 @@ def test_ai_planner_propagates_rationale_and_thinking_to_planned_step() -> None:
assert steps[0].rationale == "Previous step opened settings. Now tapping account."
assert steps[0].thinking == "I need to navigate deeper."
assert steps[0].purpose == "Open account settings."
assert steps[0].expected_outcome == "The account settings page is visible."
def test_history_summary_returns_compact_format() -> None:
@@ -226,12 +238,16 @@ def test_history_summary_returns_compact_format() -> None:
action="tap",
success=True,
rationale="Opened settings.",
arguments={"x": 1, "y": 2},
purpose="Open settings.",
expected_outcome="Settings is visible.",
page="Home",
),
WorldEvent(
action="swipe",
success=False,
rationale=None,
arguments={"start_y": 700, "end_y": 200},
page="Settings",
),
]
@@ -241,8 +257,24 @@ def test_history_summary_returns_compact_format() -> None:
summary = _history_summary(state)
assert summary == [
{"page": "Home", "action": "tap", "rationale": "Opened settings.", "success": True},
{"page": "Settings", "action": "swipe", "rationale": None, "success": False},
{
"page": "Home",
"action": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "Opened settings.",
"purpose": "Open settings.",
"expected_outcome": "Settings is visible.",
"success": True,
},
{
"page": "Settings",
"action": "swipe",
"arguments": {"start_y": 700, "end_y": 200},
"rationale": None,
"purpose": None,
"expected_outcome": None,
"success": False,
},
]
# Must not contain scene element data
for entry in summary:
+12 -1
View File
@@ -193,7 +193,12 @@ def test_multi_step_timeline_records_actual_per_step_prompts(tmp_path) -> None:
"""When AIPlanner is used, each timeline step's prompt is the real
per-step user prompt (containing scene JSON), not the bare task goal."""
decisions = [
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}),
ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
purpose="Open the send control.",
expected_outcome="The message composer is focused.",
),
ToolCallDecision(tool_name="finish_task", arguments={"success": True}),
]
client = ScriptedToolCallingClient(decisions)
@@ -228,6 +233,12 @@ def test_multi_step_timeline_records_actual_per_step_prompts(tmp_path) -> None:
assert "Current Scene (JSON)" in prompt
assert "Call exactly one tool" in prompt
assert "tap the button" in prompt
assert records[0]["tool_call"]["args"] == {"x": 1, "y": 2}
assert records[0]["tool_call"]["purpose"] == "Open the send control."
assert (
records[0]["tool_call"]["expected_outcome"]
== "The message composer is focused."
)
def test_non_ai_planner_falls_back_to_task_goal_for_prompt(tmp_path) -> None:
+28 -3
View File
@@ -96,7 +96,14 @@ def _decision_payload(**overrides: object) -> dict[str, object]:
def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
decision=ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
text_output="The login button is visible. Opening it.",
thinking="The next screen should be the account page.",
purpose="Open the login screen.",
expected_outcome="The login form is visible.",
)
)
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
@@ -107,7 +114,14 @@ def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
)
assert response.status_code == 200
assert response.json() == {"tool_name": "tap", "arguments": {"x": 1, "y": 2}}
assert response.json() == {
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "The login button is visible. Opening it.",
"thinking": "The next screen should be the account page.",
"purpose": "Open the login screen.",
"expected_outcome": "The login form is visible.",
}
assert len(fake_client.calls) == 1
assert fake_client.calls[0]["system_prompt"] == "you are a planner"
assert fake_client.calls[0]["timeout"] == 30.0
@@ -232,7 +246,14 @@ def _seed_attempt(pool: DevicePool, task_id: str, host_id: str = "host-a") -> No
def test_successful_decision_is_persisted_with_correct_fields(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={"x": 10, "y": 20})
decision=ToolCallDecision(
tool_name="tap",
arguments={"x": 10, "y": 20},
text_output="The settings tab is visible. Opening it.",
thinking="A tap should navigate to settings.",
purpose="Open settings.",
expected_outcome="The settings page is visible.",
)
)
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
_seed_attempt(pool, "task-log-1")
@@ -259,6 +280,10 @@ def test_successful_decision_is_persisted_with_correct_fields(tmp_path) -> None:
assert row.tool_name == "tap"
assert '"x": 10' in row.arguments_json
assert '"y": 20' in row.arguments_json
assert row.rationale == "The settings tab is visible. Opening it."
assert row.thinking == "A tap should navigate to settings."
assert row.purpose == "Open settings."
assert row.expected_outcome == "The settings page is visible."
def test_failed_decision_persists_nothing(tmp_path) -> None:
+16 -3
View File
@@ -1773,12 +1773,21 @@ def test_record_planner_decision_stores_rationale_and_thinking(
now=now,
rationale="Previous step opened settings. Now tapping account.",
thinking="I need to navigate to account settings.",
purpose="Open account settings.",
expected_outcome="The account settings page is visible.",
)
decisions = database.repository.list_planner_decisions(task_id=task_id, attempt=1)
decisions = database.repository.list_planner_decisions(
task_id=task_id, attempt=1
)
assert len(decisions) == 1
assert decisions[0].rationale == "Previous step opened settings. Now tapping account."
assert (
decisions[0].rationale
== "Previous step opened settings. Now tapping account."
)
assert decisions[0].thinking == "I need to navigate to account settings."
assert decisions[0].purpose == "Open account settings."
assert decisions[0].expected_outcome == "The account settings page is visible."
finally:
database.close()
@@ -1804,9 +1813,13 @@ def test_record_planner_decision_stores_null_rationale_and_thinking(
# rationale and thinking omitted (default None)
)
decisions = database.repository.list_planner_decisions(task_id=task_id, attempt=1)
decisions = database.repository.list_planner_decisions(
task_id=task_id, attempt=1
)
assert len(decisions) == 1
assert decisions[0].rationale is None
assert decisions[0].thinking is None
assert decisions[0].purpose is None
assert decisions[0].expected_outcome is None
finally:
database.close()
+43 -4
View File
@@ -94,6 +94,44 @@ def test_unknown_task_id_returns_404(tmp_path) -> None:
assert resp.status_code == 404, resp.text
def test_planner_decision_history_returns_reusable_action_metadata(tmp_path) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
task_id = scheduler.submit(goal="open settings")
pool.store.record_planner_decision(
host_id="host-a",
task_id=task_id,
attempt=0,
system_prompt="system",
user_prompt="open settings",
tool_name="tap",
arguments_json='{"x": 12, "y": 34}',
now=datetime.now(UTC),
rationale="The settings tab is visible. Opening it.",
thinking="A tap should navigate to settings.",
purpose="Open settings.",
expected_outcome="The settings page is visible.",
)
response = _client_for(app).get(f"/v1/tasks/{task_id}/planner-decisions?attempt=0")
assert response.status_code == 200, response.text
assert response.json()["items"] == [
{
"step_index": 1,
"attempt": 0,
"system_prompt": "system",
"user_prompt": "open settings",
"tool_name": "tap",
"arguments": {"x": 12, "y": 34},
"rationale": "The settings tab is visible. Opening it.",
"thinking": "A tap should navigate to settings.",
"purpose": "Open settings.",
"expected_outcome": "The settings page is visible.",
"created_at": response.json()["items"][0]["created_at"],
}
]
def test_task_status_exposes_distributed_metadata_without_lease_secret(
tmp_path,
) -> None:
@@ -299,7 +337,10 @@ def test_submit_with_explicit_target_is_listed_and_not_rerouted(tmp_path) -> Non
assert task["assigned_host_id"] == "host-b"
assert task["assigned_device_id"] == "device-b"
listed = client.get("/v1/tasks").json()["items"]
assert next(item for item in listed if item["id"] == task_id)["target_host_id"] == "host-b"
assert (
next(item for item in listed if item["id"] == task_id)["target_host_id"]
== "host-b"
)
def test_submit_rejects_incomplete_or_foreign_target(tmp_path) -> None:
@@ -429,9 +470,7 @@ def test_list_tasks_returns_summary_with_pagination_and_status_filter(
assert [item["id"] for item in queued_only["items"]] == [second_id]
assert all(item["status"] == "queued" for item in queued_only["items"])
assigned_only = client.get(
"/v1/tasks", params={"status": "assigned"}
).json()
assigned_only = client.get("/v1/tasks", params={"status": "assigned"}).json()
assert assigned_only["total"] == 1
assert [item["id"] for item in assigned_only["items"]] == [assigned_id]
+28
View File
@@ -24,3 +24,31 @@ def test_executor_retries_until_transient_tool_succeeds() -> None:
assert result.attempts == 3
assert result.result == {"ok": True}
def test_executor_keeps_action_metadata_out_of_device_tool_arguments() -> None:
calls: list[tuple[int, int]] = []
def tap(*, x: int, y: int) -> dict[str, bool]:
calls.append((x, y))
return {"ok": True}
executor = Executor(
tools={"tap": tap},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
step = PlannedStep(
action="tap",
description="Open settings.",
args={"x": 12, "y": 34},
purpose="Open settings.",
expected_outcome="The settings page is visible.",
)
result = executor.execute(step)
assert result.success is True
assert calls == [(12, 34)]
assert result.to_dict()["step"]["purpose"] == "Open settings."
assert (
result.to_dict()["step"]["expected_outcome"] == "The settings page is visible."
)
+44 -5
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
SkillMetadata,
skill_embedding_text,
)
from skills_learning.store import SkillStore
from skills_learning.synthesis import extract_tool_calls, synthesize_flow_skill
@@ -10,12 +15,19 @@ def _record(
args: dict[str, object] | None = None,
*,
result: dict[str, object] | None = None,
purpose: str | None = None,
expected_outcome: str | None = None,
) -> dict[str, object]:
tool_call: dict[str, object] = {
"action": action,
"args": args or {},
}
if purpose is not None:
tool_call["purpose"] = purpose
if expected_outcome is not None:
tool_call["expected_outcome"] = expected_outcome
return {
"tool_call": {
"action": action,
"args": args or {},
},
"tool_call": tool_call,
"result": result or {},
}
@@ -50,6 +62,33 @@ def test_first_time_synthesis_has_literal_steps_and_no_parameters() -> None:
assert skill.parameters == {}
def test_synthesis_preserves_action_metadata_for_reuse_and_embedding() -> None:
skill = synthesize_flow_skill(
"open settings",
[
_record(
"tap",
{"x": 12, "y": 34},
purpose="Open the settings tab.",
expected_outcome="The settings page is visible.",
)
],
)
assert skill.steps == [
FlowStep(
"tap",
{"x": 12, "y": 34},
purpose="Open the settings tab.",
expected_outcome="The settings page is visible.",
)
]
assert FlowStep.from_dict(skill.steps[0].to_dict()) == skill.steps[0]
embedding_text = skill_embedding_text(skill)
assert "Open the settings tab." in embedding_text
assert "The settings page is visible." in embedding_text
def test_second_execution_promotes_differing_argument_to_parameter() -> None:
store = SkillStore()
store.create_version(
+66
View File
@@ -632,6 +632,36 @@ def test_anthropic_client_tool_only_response_has_none_thinking_and_text_output()
assert decision.text_output is None
def test_anthropic_client_separates_required_action_metadata_from_arguments() -> None:
messages = FakeMessages(
response={
"content": [
{
"type": "tool_use",
"name": "tap",
"input": {
"x": 10,
"y": 20,
"purpose": "Open the account screen.",
"expected_outcome": "The account screen is visible.",
},
}
]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.arguments == {"x": 10, "y": 20}
assert decision.purpose == "Open the account screen."
assert decision.expected_outcome == "The account screen is visible."
def test_openai_client_captures_reasoning_content() -> None:
completions = FakeCompletions(
response={
@@ -691,6 +721,42 @@ def test_openai_client_captures_message_content_as_text_output() -> None:
assert decision.text_output == "Previous step succeeded. Now tapping login."
def test_openai_client_separates_required_action_metadata_from_arguments() -> None:
completions = FakeCompletions(
response={
"choices": [
{
"message": {
"tool_calls": [
{
"function": {
"name": "tap",
"arguments": (
'{"x": 5, "y": 6, '
'"purpose": "Open settings.", '
'"expected_outcome": "Settings is visible."}'
),
}
}
]
}
}
]
}
)
client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.arguments == {"x": 5, "y": 6}
assert decision.purpose == "Open settings."
assert decision.expected_outcome == "Settings is visible."
def test_openai_client_no_reasoning_content_gives_none_thinking() -> None:
completions = FakeCompletions(
response={
+48 -8
View File
@@ -13,7 +13,9 @@ from runtime.tool_specs import (
)
def test_action_tool_specs_has_five_entries_and_all_tool_specs_adds_finish_task() -> None:
def test_action_tool_specs_has_five_entries_and_all_tool_specs_adds_finish_task() -> (
None
):
assert len(ACTION_TOOL_SPECS) == 5
assert len(ALL_TOOL_SPECS) == 6
assert ALL_TOOL_SPECS == [*ACTION_TOOL_SPECS, FINISH_TASK_SPEC]
@@ -33,32 +35,70 @@ def test_every_tool_spec_schema_forbids_additional_properties() -> None:
def test_tap_spec_requires_x_and_y() -> None:
assert TAP_SPEC.parameters["required"] == ["x", "y"]
assert set(TAP_SPEC.parameters["properties"]) == {"x", "y"}
assert TAP_SPEC.parameters["required"] == ["x", "y", "purpose", "expected_outcome"]
assert set(TAP_SPEC.parameters["properties"]) == {
"x",
"y",
"purpose",
"expected_outcome",
}
def test_swipe_spec_requires_coordinates_and_makes_duration_optional() -> None:
assert SWIPE_SPEC.parameters["required"] == ["start_x", "start_y", "end_x", "end_y"]
assert SWIPE_SPEC.parameters["required"] == [
"start_x",
"start_y",
"end_x",
"end_y",
"purpose",
"expected_outcome",
]
assert set(SWIPE_SPEC.parameters["properties"]) == {
"start_x",
"start_y",
"end_x",
"end_y",
"duration_ms",
"purpose",
"expected_outcome",
}
assert "duration_ms" not in SWIPE_SPEC.parameters["required"]
assert SWIPE_SPEC.parameters["properties"]["duration_ms"]["default"] == 500
def test_input_text_spec_requires_text() -> None:
assert INPUT_TEXT_SPEC.parameters["required"] == ["text"]
assert set(INPUT_TEXT_SPEC.parameters["properties"]) == {"text"}
assert INPUT_TEXT_SPEC.parameters["required"] == [
"text",
"purpose",
"expected_outcome",
]
assert set(INPUT_TEXT_SPEC.parameters["properties"]) == {
"text",
"purpose",
"expected_outcome",
}
def test_launch_and_terminate_app_specs_require_app_id() -> None:
for spec in (LAUNCH_APP_SPEC, TERMINATE_APP_SPEC):
assert spec.parameters["required"] == ["app_id"]
assert set(spec.parameters["properties"]) == {"app_id"}
assert spec.parameters["required"] == [
"app_id",
"purpose",
"expected_outcome",
]
assert set(spec.parameters["properties"]) == {
"app_id",
"purpose",
"expected_outcome",
}
def test_action_metadata_is_required_nonempty_text() -> None:
for spec in ACTION_TOOL_SPECS:
for field_name in ("purpose", "expected_outcome"):
field = spec.parameters["properties"][field_name]
assert field["type"] == "string"
assert field["minLength"] == 1
def test_finish_task_spec_requires_success_and_reason() -> None:
+22 -1
View File
@@ -61,7 +61,9 @@ def test_observe_leaves_current_page_unchanged_without_semantic_scene() -> None:
assert model.state.current_page == "Chat"
def test_observe_updates_current_app_for_successful_launch_and_clear_for_terminate() -> None:
def test_observe_updates_current_app_for_successful_launch_and_clear_for_terminate() -> (
None
):
model = WorldModel(config=WorldConfig(history_size=2))
launch = PlannedStep(
action="launch_app",
@@ -188,3 +190,22 @@ def test_observe_appends_semantic_or_raw_scene_history_with_eviction() -> None:
assert [event.action for event in model.state.history] == ["second", "third"]
assert model.state.history[0].scene_summary.to_dict() == _scene().to_dict()
assert model.state.history.maxlen == 2
def test_observe_records_executed_action_arguments_and_metadata() -> None:
model = WorldModel(config=WorldConfig(history_size=2))
step = PlannedStep(
action="tap",
description="Open settings.",
args={"x": 12, "y": 34},
purpose="Open settings.",
expected_outcome="The settings page is visible.",
)
model.observe(_scene(), _semantic_scene("Settings"), step, _result(step))
event = model.state.history[0]
assert event.action == "tap"
assert event.arguments == {"x": 12, "y": 34}
assert event.purpose == "Open settings."
assert event.expected_outcome == "The settings page is visible."
+9
View File
@@ -66,12 +66,18 @@ def test_world_event_with_rationale_thinking_page() -> None:
success=True,
rationale="Previous step opened settings. Now tapping account.",
thinking="I should navigate to account settings.",
arguments={"x": 12, "y": 34},
purpose="Open account settings.",
expected_outcome="The account settings page is visible.",
page="Settings",
)
data = event.to_dict()
assert data["rationale"] == "Previous step opened settings. Now tapping account."
assert data["thinking"] == "I should navigate to account settings."
assert data["arguments"] == {"x": 12, "y": 34}
assert data["purpose"] == "Open account settings."
assert data["expected_outcome"] == "The account settings page is visible."
assert data["page"] == "Settings"
assert data["scene_summary"] is None
@@ -82,6 +88,9 @@ def test_world_event_all_optional_fields_none() -> None:
assert data["rationale"] is None
assert data["thinking"] is None
assert data["arguments"] == {}
assert data["purpose"] is None
assert data["expected_outcome"] is None
assert data["page"] is None
assert data["scene_summary"] is None
assert data["action"] == "swipe"