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
+11 -1
View File
@@ -57,8 +57,15 @@ class AIPlanner(Planner):
return [
PlannedStep(
action=decision.tool_name,
description=f"AI planner: {decision.tool_name}({decision.arguments})",
description=(
f"AI planner: {decision.purpose}"
if decision.purpose
else f"AI planner: {decision.tool_name}({decision.arguments})"
),
args=dict(decision.arguments),
expected_text=decision.expected_outcome,
purpose=decision.purpose,
expected_outcome=decision.expected_outcome,
prompt=decision.user_prompt or user_prompt,
rationale=decision.text_output,
thinking=decision.thinking,
@@ -78,7 +85,10 @@ def _history_summary(world: "WorldState | None") -> list[dict[str, Any]]:
{
"page": event.page,
"action": event.action,
"arguments": dict(event.arguments),
"rationale": event.rationale,
"purpose": event.purpose,
"expected_outcome": event.expected_outcome,
"success": event.success,
}
for event in world.history
+3 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
from time import sleep
from typing import Any
@@ -28,6 +28,8 @@ class StepResult:
"description": self.step.description,
"args": dict(self.step.args),
"expected_text": self.step.expected_text,
"purpose": self.step.purpose,
"expected_outcome": self.step.expected_outcome,
},
"success": self.success,
"attempts": self.attempts,
+4
View File
@@ -16,6 +16,10 @@ class PlannedStep:
description: str
args: dict[str, Any] = field(default_factory=dict)
expected_text: str | None = None
# Structured action semantics, populated by AI planner tool calls and
# retained independently from executable arguments for later reuse.
purpose: str | None = None
expected_outcome: str | None = None
# The actual prompt sent to the LLM for this step (AI planners only).
# ``None`` for non-LLM planners; TaskRunner falls back to the task goal.
prompt: str | None = None
+2
View File
@@ -382,6 +382,8 @@ class TaskRunner:
"action": step.action,
"description": step.description,
"args": step.args,
"purpose": step.purpose,
"expected_outcome": step.expected_outcome,
},
result=result.to_dict()
if hasattr(result, "to_dict")
+41 -3
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from typing import Any, Protocol
from runtime.planner_config import PlannerConfig
from runtime.tool_specs import ToolSpec
from runtime.tool_specs import ACTION_TOOL_NAMES, ToolSpec
class ToolCallUnavailable(Exception):
@@ -36,6 +36,10 @@ class ToolCallDecision:
# Extended thinking block (Anthropic) or reasoning_content (OpenAI o-series).
# None when not enabled or not present in the response.
thinking: str | None = None
# Required structured metadata for device actions. These fields are removed
# from ``arguments`` before the Runtime invokes the physical device tool.
purpose: str | None = None
expected_outcome: str | None = None
class ToolCallingClient(Protocol):
@@ -352,14 +356,19 @@ def _decision_from_anthropic_response(
name = _value(block, "name")
arguments = _value(block, "input")
if isinstance(name, str) and isinstance(arguments, dict):
executable_arguments, purpose, expected_outcome = (
_split_action_metadata(name, arguments)
)
return ToolCallDecision(
tool_name=name,
arguments=arguments,
arguments=executable_arguments,
usage=_anthropic_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
text_output="\n".join(text_parts) if text_parts else None,
thinking=thinking,
purpose=purpose,
expected_outcome=expected_outcome,
)
raise ValueError("anthropic response did not include a tool_use block")
@@ -418,6 +427,9 @@ def _decision_from_openai_response(
if not isinstance(name, str):
raise ValueError("openai tool call missing a function name")
arguments = _decode_openai_arguments(_value(function, "arguments"))
executable_arguments, purpose, expected_outcome = _split_action_metadata(
name, arguments
)
# Extract reasoning_content from o-series models when present.
raw_reasoning = _value(message, "reasoning_content")
thinking: str | None = raw_reasoning if isinstance(raw_reasoning, str) else None
@@ -427,12 +439,14 @@ def _decision_from_openai_response(
text_output: str | None = raw_content if isinstance(raw_content, str) else None
return ToolCallDecision(
tool_name=name,
arguments=arguments,
arguments=executable_arguments,
usage=_openai_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
thinking=thinking,
text_output=text_output,
purpose=purpose,
expected_outcome=expected_outcome,
)
@@ -475,6 +489,30 @@ def _decode_openai_arguments(raw_arguments: Any) -> dict[str, Any]:
raise ValueError("openai tool call arguments must decode to a JSON object")
def _split_action_metadata(
tool_name: str,
arguments: dict[str, Any],
) -> tuple[dict[str, Any], str | None, str | None]:
executable_arguments = dict(arguments)
if tool_name not in ACTION_TOOL_NAMES:
return executable_arguments, None, None
return (
{
name: value
for name, value in executable_arguments.items()
if name not in {"purpose", "expected_outcome"}
},
_metadata_text(executable_arguments.get("purpose")),
_metadata_text(executable_arguments.get("expected_outcome")),
)
def _metadata_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
return value.strip() or None
def _value(source: Any, key: str) -> Any:
if isinstance(source, dict):
return source.get(key)
+59 -32
View File
@@ -11,18 +11,51 @@ class ToolSpec:
parameters: dict[str, Any]
ACTION_METADATA_PROPERTIES: dict[str, dict[str, Any]] = {
"purpose": {
"type": "string",
"minLength": 1,
"maxLength": 240,
"description": "Concise intent for this action, used by later reuse.",
},
"expected_outcome": {
"type": "string",
"minLength": 1,
"maxLength": 240,
"description": "Observable screen state expected after this action.",
},
}
def _action_parameters(
*,
required: list[str],
properties: dict[str, dict[str, Any]],
) -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"required": [*required, "purpose", "expected_outcome"],
"properties": {**properties, **ACTION_METADATA_PROPERTIES},
}
TAP_SPEC = ToolSpec(
name="tap",
description="Tap a point on the screen, given in Scene pixel coordinates.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["x", "y"],
"properties": {
"x": {"type": "number", "description": "X coordinate in Scene pixel space."},
"y": {"type": "number", "description": "Y coordinate in Scene pixel space."},
parameters=_action_parameters(
required=["x", "y"],
properties={
"x": {
"type": "number",
"description": "X coordinate in Scene pixel space.",
},
"y": {
"type": "number",
"description": "Y coordinate in Scene pixel space.",
},
},
},
),
)
SWIPE_SPEC = ToolSpec(
@@ -31,11 +64,9 @@ SWIPE_SPEC = ToolSpec(
"Swipe from a start point to an end point on the screen, given in "
"Scene pixel coordinates."
),
parameters={
"type": "object",
"additionalProperties": False,
"required": ["start_x", "start_y", "end_x", "end_y"],
"properties": {
parameters=_action_parameters(
required=["start_x", "start_y", "end_x", "end_y"],
properties={
"start_x": {"type": "number", "description": "Start X coordinate."},
"start_y": {"type": "number", "description": "Start Y coordinate."},
"end_x": {"type": "number", "description": "End X coordinate."},
@@ -46,52 +77,46 @@ SWIPE_SPEC = ToolSpec(
"default": 500,
},
},
},
),
)
INPUT_TEXT_SPEC = ToolSpec(
name="input_text",
description="Type text into the currently focused input field.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["text"],
"properties": {
parameters=_action_parameters(
required=["text"],
properties={
"text": {"type": "string", "description": "Text to type."},
},
},
),
)
LAUNCH_APP_SPEC = ToolSpec(
name="launch_app",
description="Launch (foreground) an app by its bundle/package identifier.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["app_id"],
"properties": {
parameters=_action_parameters(
required=["app_id"],
properties={
"app_id": {
"type": "string",
"description": "App bundle/package identifier.",
},
},
},
),
)
TERMINATE_APP_SPEC = ToolSpec(
name="terminate_app",
description="Terminate a running app by its bundle/package identifier.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["app_id"],
"properties": {
parameters=_action_parameters(
required=["app_id"],
properties={
"app_id": {
"type": "string",
"description": "App bundle/package identifier.",
},
},
},
),
)
FINISH_TASK_SPEC = ToolSpec(
@@ -126,4 +151,6 @@ ACTION_TOOL_SPECS: list[ToolSpec] = [
TERMINATE_APP_SPEC,
]
ACTION_TOOL_NAMES = frozenset(spec.name for spec in ACTION_TOOL_SPECS)
ALL_TOOL_SPECS: list[ToolSpec] = [*ACTION_TOOL_SPECS, FINISH_TASK_SPEC]