multi agent
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from agents.config import CollaborationConfig, load_config
|
||||||
|
from agents.models import Observation, VerificationVerdict
|
||||||
|
from agents.observer import Observer
|
||||||
|
from agents.reflector import Reflector
|
||||||
|
from agents.verifier import Verifier
|
||||||
|
from core.models import Scene, Task, utc_now
|
||||||
|
from runtime.context import TaskContext
|
||||||
|
from runtime.executor import Executor
|
||||||
|
from runtime.planner import PlannedStep, Planner
|
||||||
|
from runtime.task import TaskRunner, TaskRunnerConfig
|
||||||
|
from semantic.llm_client import AnthropicSemanticClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CollaborativeTaskRunnerConfig:
|
||||||
|
max_steps: int = 20
|
||||||
|
max_recovery_attempts: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborativeTaskRunner:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
planner: Planner | None = None,
|
||||||
|
executor: Executor | None = None,
|
||||||
|
task_runner: TaskRunner | None = None,
|
||||||
|
observer: Observer | None = None,
|
||||||
|
verifier: Verifier | None = None,
|
||||||
|
reflector: Reflector | None = None,
|
||||||
|
config: CollaborativeTaskRunnerConfig | None = None,
|
||||||
|
collaboration_config: CollaborationConfig | None = None,
|
||||||
|
llm_client: AnthropicSemanticClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.planner = planner or Planner()
|
||||||
|
self.executor = executor or Executor()
|
||||||
|
self.task_runner = task_runner
|
||||||
|
self.observer = observer or Observer()
|
||||||
|
self.verifier = verifier or Verifier(client=llm_client)
|
||||||
|
self.reflector = reflector or Reflector(client=llm_client)
|
||||||
|
self.config = config or CollaborativeTaskRunnerConfig()
|
||||||
|
self.collaboration_config = collaboration_config or load_config()
|
||||||
|
|
||||||
|
def run(self, task: Task) -> Task:
|
||||||
|
if not self.collaboration_config.enabled:
|
||||||
|
return self._run_plain(task)
|
||||||
|
return self._run_collaborative(task)
|
||||||
|
|
||||||
|
def _run_plain(self, task: Task) -> Task:
|
||||||
|
runner = self.task_runner or TaskRunner(
|
||||||
|
planner=self.planner,
|
||||||
|
executor=self.executor,
|
||||||
|
config=TaskRunnerConfig(max_steps=self.config.max_steps),
|
||||||
|
)
|
||||||
|
return runner.run(task)
|
||||||
|
|
||||||
|
def _run_collaborative(self, task: Task) -> Task:
|
||||||
|
context = TaskContext(task_id=task.id, goal=task.goal)
|
||||||
|
task.status = "running" # type: ignore[assignment]
|
||||||
|
task.updated_at = utc_now()
|
||||||
|
recovery_attempts = 0
|
||||||
|
|
||||||
|
for _ in range(self.config.max_steps):
|
||||||
|
scene = self._observe_scene(task.device_id)
|
||||||
|
context.add_scene(scene)
|
||||||
|
|
||||||
|
pre_observation = self.observer.observe(
|
||||||
|
scene=scene,
|
||||||
|
world=context.world,
|
||||||
|
)
|
||||||
|
|
||||||
|
steps = self.planner.plan(
|
||||||
|
goal=task.goal,
|
||||||
|
scene=scene,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
if not steps or self.planner.goal_reached(
|
||||||
|
goal=task.goal,
|
||||||
|
scene=scene,
|
||||||
|
context=context,
|
||||||
|
):
|
||||||
|
task.status = "completed" # type: ignore[assignment]
|
||||||
|
task.updated_at = utc_now()
|
||||||
|
task.completed_at = utc_now()
|
||||||
|
return task
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
executable_step = self._step_for_device(step, task.device_id)
|
||||||
|
result = self.executor.execute(executable_step, context=context)
|
||||||
|
context.add_step_result(result)
|
||||||
|
|
||||||
|
post_scene = self._observe_scene(task.device_id)
|
||||||
|
post_observation = self.observer.observe(
|
||||||
|
scene=post_scene,
|
||||||
|
world=context.world,
|
||||||
|
)
|
||||||
|
|
||||||
|
verdict = self.verifier.verify(
|
||||||
|
pre_observation=pre_observation,
|
||||||
|
post_observation=post_observation,
|
||||||
|
planned_step=step,
|
||||||
|
step_result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if verdict.result == "achieved":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if recovery_attempts >= self.collaboration_config.max_recovery_attempts:
|
||||||
|
task.status = "failed" # type: ignore[assignment]
|
||||||
|
task.updated_at = utc_now()
|
||||||
|
task.completed_at = utc_now()
|
||||||
|
task.failure_reason = (
|
||||||
|
f"Reflection recovery ceiling reached "
|
||||||
|
f"({self.collaboration_config.max_recovery_attempts} attempts)"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
outcome = self.reflector.reflect(
|
||||||
|
observation=post_observation,
|
||||||
|
planned_step=step,
|
||||||
|
step_result=result,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
recovery_attempts += 1
|
||||||
|
|
||||||
|
if outcome.replan:
|
||||||
|
break
|
||||||
|
|
||||||
|
if outcome.action is not None:
|
||||||
|
recovery_step = PlannedStep(
|
||||||
|
action=outcome.action.action,
|
||||||
|
description=outcome.action.description,
|
||||||
|
args=outcome.action.args,
|
||||||
|
)
|
||||||
|
recovery_result = self.executor.execute(
|
||||||
|
self._step_for_device(recovery_step, task.device_id),
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
context.add_step_result(recovery_result)
|
||||||
|
if not recovery_result.success:
|
||||||
|
task.status = "failed" # type: ignore[assignment]
|
||||||
|
task.updated_at = utc_now()
|
||||||
|
task.completed_at = utc_now()
|
||||||
|
task.failure_reason = (
|
||||||
|
f"Recovery action failed: {recovery_result.error}"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
task.status = "failed" # type: ignore[assignment]
|
||||||
|
task.updated_at = utc_now()
|
||||||
|
task.completed_at = utc_now()
|
||||||
|
task.failure_reason = f"max steps exceeded: {self.config.max_steps}"
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _observe_scene(self, device_id: str) -> Scene:
|
||||||
|
from tools.describe_screen import describe_screen
|
||||||
|
|
||||||
|
return describe_screen(device_id)
|
||||||
|
|
||||||
|
def _step_for_device(self, step: PlannedStep, device_id: str) -> PlannedStep:
|
||||||
|
device_scoped_actions = {
|
||||||
|
"take_screenshot",
|
||||||
|
"screenshot",
|
||||||
|
"tap",
|
||||||
|
"swipe",
|
||||||
|
"input_text",
|
||||||
|
"launch_app",
|
||||||
|
"terminate_app",
|
||||||
|
"get_ui_tree",
|
||||||
|
"ui_tree",
|
||||||
|
"describe_screen",
|
||||||
|
"describe_screen_semantic",
|
||||||
|
"find_text_on_screen",
|
||||||
|
"find_icon_on_screen",
|
||||||
|
}
|
||||||
|
if step.action not in device_scoped_actions or "device_id" in step.args:
|
||||||
|
return step
|
||||||
|
return replace(step, args={**step.args, "device_id": device_id})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
ENABLED_ENV = "MULTI_AGENT_COLLABORATION_ENABLED"
|
||||||
|
MAX_RECOVERY_ENV = "MULTI_AGENT_MAX_RECOVERY_ATTEMPTS"
|
||||||
|
DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollaborationConfig:
|
||||||
|
enabled: bool = False
|
||||||
|
max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env: Mapping[str, str] | None = None) -> CollaborationConfig:
|
||||||
|
values = env or os.environ
|
||||||
|
return CollaborationConfig(
|
||||||
|
enabled=_parse_bool(values.get(ENABLED_ENV), default=False),
|
||||||
|
max_recovery_attempts=_parse_int(
|
||||||
|
values.get(MAX_RECOVERY_ENV), default=DEFAULT_MAX_RECOVERY_ATTEMPTS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str | None, *, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str | None, *, default: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
result = int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return result if result > 0 else default
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Observation:
|
||||||
|
scene_summary: str
|
||||||
|
semantic_page: str | None = None
|
||||||
|
semantic_intents: list[str] = field(default_factory=list)
|
||||||
|
world_app: str | None = None
|
||||||
|
world_page: str | None = None
|
||||||
|
world_variables: dict[str, Any] = field(default_factory=dict)
|
||||||
|
raw_scene: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scene_summary": self.scene_summary,
|
||||||
|
"semantic_page": self.semantic_page,
|
||||||
|
"semantic_intents": list(self.semantic_intents),
|
||||||
|
"world_app": self.world_app,
|
||||||
|
"world_page": self.world_page,
|
||||||
|
"world_variables": dict(self.world_variables),
|
||||||
|
"raw_scene": self.raw_scene,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> Observation:
|
||||||
|
return cls(
|
||||||
|
scene_summary=str(data["scene_summary"]),
|
||||||
|
semantic_page=data.get("semantic_page"),
|
||||||
|
semantic_intents=list(data.get("semantic_intents", [])),
|
||||||
|
world_app=data.get("world_app"),
|
||||||
|
world_page=data.get("world_page"),
|
||||||
|
world_variables=dict(data.get("world_variables", {})),
|
||||||
|
raw_scene=data.get("raw_scene"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VerdictResult = Literal["achieved", "not_achieved"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerificationVerdict:
|
||||||
|
result: VerdictResult
|
||||||
|
reasoning: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"result": self.result,
|
||||||
|
"reasoning": self.reasoning,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> VerificationVerdict:
|
||||||
|
result = data["result"]
|
||||||
|
if result not in ("achieved", "not_achieved"):
|
||||||
|
raise ValueError(f"invalid verdict result: {result}")
|
||||||
|
return cls(
|
||||||
|
result=result,
|
||||||
|
reasoning=str(data["reasoning"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReflectionAction:
|
||||||
|
action: str
|
||||||
|
description: str
|
||||||
|
args: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action": self.action,
|
||||||
|
"description": self.description,
|
||||||
|
"args": dict(self.args),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> ReflectionAction:
|
||||||
|
return cls(
|
||||||
|
action=str(data["action"]),
|
||||||
|
description=str(data["description"]),
|
||||||
|
args=dict(data.get("args", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReflectionOutcome:
|
||||||
|
replan: bool = False
|
||||||
|
action: ReflectionAction | None = None
|
||||||
|
reasoning: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"replan": self.replan,
|
||||||
|
"action": self.action.to_dict() if self.action else None,
|
||||||
|
"reasoning": self.reasoning,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> ReflectionOutcome:
|
||||||
|
action_data = data.get("action")
|
||||||
|
return cls(
|
||||||
|
replan=bool(data.get("replan", False)),
|
||||||
|
action=ReflectionAction.from_dict(action_data) if action_data else None,
|
||||||
|
reasoning=str(data.get("reasoning", "")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from agents.models import Observation
|
||||||
|
from core.models import Scene, Step
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from semantic.models import SemanticScene
|
||||||
|
from world.models import WorldState
|
||||||
|
|
||||||
|
|
||||||
|
class Observer:
|
||||||
|
def observe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scene: Scene,
|
||||||
|
step: PlannedStep | None = None,
|
||||||
|
result: StepResult | None = None,
|
||||||
|
semantic_scene: SemanticScene | None = None,
|
||||||
|
world: WorldState | None = None,
|
||||||
|
) -> Observation:
|
||||||
|
scene_summary = self._build_scene_summary(scene)
|
||||||
|
semantic_page = None
|
||||||
|
semantic_intents: list[str] = []
|
||||||
|
if semantic_scene is not None:
|
||||||
|
semantic_page = semantic_scene.page
|
||||||
|
semantic_intents = list(semantic_scene.intents)
|
||||||
|
|
||||||
|
world_app = None
|
||||||
|
world_page = None
|
||||||
|
world_variables: dict[str, Any] = {}
|
||||||
|
if world is not None:
|
||||||
|
world_app = world.current_app
|
||||||
|
world_page = world.current_page
|
||||||
|
world_variables = dict(world.variables)
|
||||||
|
|
||||||
|
return Observation(
|
||||||
|
scene_summary=scene_summary,
|
||||||
|
semantic_page=semantic_page,
|
||||||
|
semantic_intents=semantic_intents,
|
||||||
|
world_app=world_app,
|
||||||
|
world_page=world_page,
|
||||||
|
world_variables=world_variables,
|
||||||
|
raw_scene=scene.to_dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_scene_summary(self, scene: Scene) -> str:
|
||||||
|
parts = [f"Screen {scene.width}x{scene.height}"]
|
||||||
|
for element in scene.elements[:10]:
|
||||||
|
text = element.text or element.type
|
||||||
|
parts.append(f" [{element.type}] {text}")
|
||||||
|
return "\n".join(parts)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agents.models import Observation, ReflectionAction, ReflectionOutcome, VerificationVerdict
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
from semantic.llm_client import AnthropicSemanticClient, EnrichmentUnavailable
|
||||||
|
|
||||||
|
REFLECTOR_SYSTEM_PROMPT = """You are a reflection agent. A step failed to achieve its intended effect despite mechanical success. Your job is to propose either:
|
||||||
|
1. A distinct recovery action (different from the failed step) that might fix the issue
|
||||||
|
2. A replan request if no bounded corrective action is identifiable
|
||||||
|
|
||||||
|
You must NEVER simply re-issue the identical failed step.
|
||||||
|
|
||||||
|
Respond with JSON: {"replan": bool, "action": null or {"action": "...", "description": "...", "args": {...}}, "reasoning": "explanation"}"""
|
||||||
|
|
||||||
|
REFLECTOR_JSON_SCHEMA: dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["replan", "reasoning"],
|
||||||
|
"properties": {
|
||||||
|
"replan": {"type": "boolean"},
|
||||||
|
"action": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"properties": {
|
||||||
|
"action": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"args": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"reasoning": {"type": "string", "minLength": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Reflector:
|
||||||
|
def __init__(self, *, client: AnthropicSemanticClient | None = None) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def reflect(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
verdict: VerificationVerdict,
|
||||||
|
) -> ReflectionOutcome:
|
||||||
|
if self._client is None:
|
||||||
|
return self._fallback_reflect(
|
||||||
|
observation=observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._llm_reflect(
|
||||||
|
observation=observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
except EnrichmentUnavailable:
|
||||||
|
return self._fallback_reflect(
|
||||||
|
observation=observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _llm_reflect(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
verdict: VerificationVerdict,
|
||||||
|
) -> ReflectionOutcome:
|
||||||
|
user_prompt = json.dumps(
|
||||||
|
{
|
||||||
|
"observation": observation.to_dict(),
|
||||||
|
"planned_step": {
|
||||||
|
"action": planned_step.action,
|
||||||
|
"description": planned_step.description,
|
||||||
|
"args": planned_step.args,
|
||||||
|
"expected_text": planned_step.expected_text,
|
||||||
|
},
|
||||||
|
"step_result": step_result.to_dict(),
|
||||||
|
"verdict": verdict.to_dict(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
response = self._client._create_message( # type: ignore[union-attr]
|
||||||
|
{"prompt": user_prompt, "system": REFLECTOR_SYSTEM_PROMPT},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
payload = _extract_outcome(response)
|
||||||
|
outcome = ReflectionOutcome.from_dict(payload)
|
||||||
|
if not outcome.replan and outcome.action is not None:
|
||||||
|
if outcome.action.action == planned_step.action and outcome.action.args == planned_step.args:
|
||||||
|
return ReflectionOutcome(
|
||||||
|
replan=True,
|
||||||
|
reasoning="Reflector proposed identical action; requesting replan instead.",
|
||||||
|
)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
def _fallback_reflect(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
verdict: VerificationVerdict,
|
||||||
|
) -> ReflectionOutcome:
|
||||||
|
return ReflectionOutcome(
|
||||||
|
replan=True,
|
||||||
|
reasoning=f"Step '{planned_step.description}' failed semantically; replanning (fallback).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_outcome(response: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(response, dict) and "replan" in response:
|
||||||
|
return response
|
||||||
|
content = getattr(response, "content", None)
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
text = getattr(block, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, dict) and "replan" in decoded:
|
||||||
|
return decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(block, dict) and "replan" in block:
|
||||||
|
return block
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(content)
|
||||||
|
if isinstance(decoded, dict) and "replan" in decoded:
|
||||||
|
return decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise ValueError("could not extract reflection outcome from LLM response")
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agents.models import Observation, VerificationVerdict
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
from semantic.llm_client import AnthropicSemanticClient, EnrichmentUnavailable
|
||||||
|
|
||||||
|
VERIFIER_SYSTEM_PROMPT = """You are a verification agent. Your job is to determine whether a planned step achieved its intended effect.
|
||||||
|
|
||||||
|
You will receive:
|
||||||
|
1. A pre-step observation of the device state
|
||||||
|
2. A post-step observation of the device state
|
||||||
|
3. The planned step (action, description, expected outcome)
|
||||||
|
4. The step result (whether the tool call succeeded mechanically)
|
||||||
|
|
||||||
|
Analyze whether the intended effect of the step actually occurred by comparing the pre and post observations against the step's stated intent.
|
||||||
|
|
||||||
|
Respond with JSON: {"result": "achieved" or "not_achieved", "reasoning": "explanation"}"""
|
||||||
|
|
||||||
|
VERIFIER_JSON_SCHEMA: dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["result", "reasoning"],
|
||||||
|
"properties": {
|
||||||
|
"result": {"type": "string", "enum": ["achieved", "not_achieved"]},
|
||||||
|
"reasoning": {"type": "string", "minLength": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Verifier:
|
||||||
|
def __init__(self, *, client: AnthropicSemanticClient | None = None) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pre_observation: Observation,
|
||||||
|
post_observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
) -> VerificationVerdict:
|
||||||
|
if not step_result.success:
|
||||||
|
return VerificationVerdict(
|
||||||
|
result="not_achieved",
|
||||||
|
reasoning=f"Step failed mechanically: {step_result.error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._client is None:
|
||||||
|
return self._fallback_verify(
|
||||||
|
pre_observation=pre_observation,
|
||||||
|
post_observation=post_observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._llm_verify(
|
||||||
|
pre_observation=pre_observation,
|
||||||
|
post_observation=post_observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
)
|
||||||
|
except EnrichmentUnavailable:
|
||||||
|
return self._fallback_verify(
|
||||||
|
pre_observation=pre_observation,
|
||||||
|
post_observation=post_observation,
|
||||||
|
planned_step=planned_step,
|
||||||
|
step_result=step_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _llm_verify(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pre_observation: Observation,
|
||||||
|
post_observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
) -> VerificationVerdict:
|
||||||
|
user_prompt = json.dumps(
|
||||||
|
{
|
||||||
|
"pre_observation": pre_observation.to_dict(),
|
||||||
|
"post_observation": post_observation.to_dict(),
|
||||||
|
"planned_step": {
|
||||||
|
"action": planned_step.action,
|
||||||
|
"description": planned_step.description,
|
||||||
|
"args": planned_step.args,
|
||||||
|
"expected_text": planned_step.expected_text,
|
||||||
|
},
|
||||||
|
"step_result": step_result.to_dict(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
response = self._client._create_message( # type: ignore[union-attr]
|
||||||
|
{"prompt": user_prompt, "system": VERIFIER_SYSTEM_PROMPT},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
payload = _extract_verdict(response)
|
||||||
|
return VerificationVerdict.from_dict(payload)
|
||||||
|
|
||||||
|
def _fallback_verify(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pre_observation: Observation,
|
||||||
|
post_observation: Observation,
|
||||||
|
planned_step: PlannedStep,
|
||||||
|
step_result: StepResult,
|
||||||
|
) -> VerificationVerdict:
|
||||||
|
if pre_observation.scene_summary == post_observation.scene_summary:
|
||||||
|
return VerificationVerdict(
|
||||||
|
result="not_achieved",
|
||||||
|
reasoning="Scene unchanged after step execution (fallback heuristic).",
|
||||||
|
)
|
||||||
|
return VerificationVerdict(
|
||||||
|
result="achieved",
|
||||||
|
reasoning="Scene changed after step execution (fallback heuristic).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_verdict(response: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(response, dict) and "result" in response:
|
||||||
|
return response
|
||||||
|
content = getattr(response, "content", None)
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
text = getattr(block, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
if isinstance(decoded, dict) and "result" in decoded:
|
||||||
|
return decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(block, dict) and "result" in block:
|
||||||
|
return block
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(content)
|
||||||
|
if isinstance(decoded, dict) and "result" in decoded:
|
||||||
|
return decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise ValueError("could not extract verification verdict from LLM response")
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
## 1. Package scaffolding
|
## 1. Package scaffolding
|
||||||
|
|
||||||
- [ ] 1.1 Create the `agents/` package (`__init__.py`, `models.py`, `observer.py`, `verifier.py`, `reflector.py`, `collab_runner.py`, `config.py`)
|
- [x] 1.1 Create the `agents/` package (`__init__.py`, `models.py`, `observer.py`, `verifier.py`, `reflector.py`, `collab_runner.py`, `config.py`)
|
||||||
- [ ] 1.2 Add an `agents*` entry to `[tool.setuptools.packages.find].include` in `pyproject.toml` (no new third-party dependency)
|
- [x] 1.2 Add an `agents*` entry to `[tool.setuptools.packages.find].include` in `pyproject.toml` (no new third-party dependency)
|
||||||
- [ ] 1.3 Add collaboration configuration: enabled/disabled flag (default disabled) and max-reflection-recovery-attempts ceiling, sourced from a single place `agents/config.py` reads from
|
- [x] 1.3 Add collaboration configuration: enabled/disabled flag (default disabled) and max-reflection-recovery-attempts ceiling, sourced from a single place `agents/config.py` reads from
|
||||||
- [ ] 1.4 Extend the project's smoke test (that imports every package) to import `agents`
|
- [x] 1.4 Extend the project's smoke test (that imports every package) to import `agents`
|
||||||
|
|
||||||
## 2. Handoff protocol data models (capability: multi-agent-collaboration)
|
## 2. Handoff protocol data models (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 2.1 Implement `agents/models.py`: `Observation`, `VerificationVerdict`, `ReflectionOutcome`, and `ReflectionAction` dataclasses with `to_dict()`/`from_dict()`, mirroring the style of `core/models.py`
|
- [x] 2.1 Implement `agents/models.py`: `Observation`, `VerificationVerdict`, `ReflectionOutcome`, and `ReflectionAction` dataclasses with `to_dict()`/`from_dict()`, mirroring the style of `core/models.py`
|
||||||
- [ ] 2.2 Write unit tests for each dataclass round-tripping through `to_dict()`/`from_dict()`
|
- [x] 2.2 Write unit tests for each dataclass round-tripping through `to_dict()`/`from_dict()`
|
||||||
|
|
||||||
## 3. Observer role (capability: multi-agent-collaboration)
|
## 3. Observer role (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 3.1 Implement `agents/observer.py`: `Observer.observe(...) -> Observation`, perceiving current device state via `SemanticScene`/`WorldState` when available
|
- [x] 3.1 Implement `agents/observer.py`: `Observer.observe(...) -> Observation`, perceiving current device state via `SemanticScene`/`WorldState` when available
|
||||||
- [ ] 3.2 Make `Observer.observe(...)` work when `SemanticScene` and/or `WorldState` are `None`, falling back to the raw `Scene`/`PlannedStep`/`StepResult`
|
- [x] 3.2 Make `Observer.observe(...)` work when `SemanticScene` and/or `WorldState` are `None`, falling back to the raw `Scene`/`PlannedStep`/`StepResult`
|
||||||
- [ ] 3.3 Write unit tests for `Observer.observe(...)` covering: both `SemanticScene`/`WorldState` present, both absent, and each present individually
|
- [x] 3.3 Write unit tests for `Observer.observe(...)` covering: both `SemanticScene`/`WorldState` present, both absent, and each present individually
|
||||||
|
|
||||||
## 4. Verifier role (capability: multi-agent-collaboration)
|
## 4. Verifier role (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 4.1 Implement `agents/verifier.py`: `Verifier.verify(pre_observation, post_observation, planned_step, step_result) -> VerificationVerdict`, comparing pre-step and post-step `Observation`s against the `PlannedStep`'s stated intent
|
- [x] 4.1 Implement `agents/verifier.py`: `Verifier.verify(pre_observation, post_observation, planned_step, step_result) -> VerificationVerdict`, comparing pre-step and post-step `Observation`s against the `PlannedStep`'s stated intent
|
||||||
- [ ] 4.2 Wire `Verifier` to reuse `semantic/llm_client.py`'s existing LLM client abstraction (injectable/mockable) rather than a new client
|
- [x] 4.2 Wire `Verifier` to reuse `semantic/llm_client.py`'s existing LLM client abstraction (injectable/mockable) rather than a new client
|
||||||
- [ ] 4.3 Write unit tests for `Verifier.verify(...)` against a fake client covering: verified-achieved, verified-not-achieved, and client-failure degrade cases
|
- [x] 4.3 Write unit tests for `Verifier.verify(...)` against a fake client covering: verified-achieved, verified-not-achieved, and client-failure degrade cases
|
||||||
|
|
||||||
## 5. Reflector role (capability: multi-agent-collaboration)
|
## 5. Reflector role (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 5.1 Implement `agents/reflector.py`: `Reflector.reflect(observation, planned_step, step_result, verdict) -> ReflectionOutcome`, invoked only on a Verifier-flagged not-achieved verdict
|
- [x] 5.1 Implement `agents/reflector.py`: `Reflector.reflect(observation, planned_step, step_result, verdict) -> ReflectionOutcome`, invoked only on a Verifier-flagged not-achieved verdict
|
||||||
- [ ] 5.2 Ensure `Reflector.reflect(...)` never returns an outcome that simply re-issues the identical failed `PlannedStep`; it returns either a distinct `ReflectionAction` or a replan request
|
- [x] 5.2 Ensure `Reflector.reflect(...)` never returns an outcome that simply re-issues the identical failed `PlannedStep`; it returns either a distinct `ReflectionAction` or a replan request
|
||||||
- [ ] 5.3 Write unit tests for `Reflector.reflect(...)` covering: recovery-action outcome, replan-request outcome, and client-failure degrade case
|
- [x] 5.3 Write unit tests for `Reflector.reflect(...)` covering: recovery-action outcome, replan-request outcome, and client-failure degrade case
|
||||||
|
|
||||||
## 6. CollaborativeTaskRunner and handoff loop (capability: multi-agent-collaboration)
|
## 6. CollaborativeTaskRunner and handoff loop (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 6.1 Implement `agents/collab_runner.py`: `CollaborativeTaskRunner`, composing the existing `Planner`, `Executor`, and `runtime/task.py`'s `TaskRunner` strictly by import
|
- [x] 6.1 Implement `agents/collab_runner.py`: `CollaborativeTaskRunner`, composing the existing `Planner`, `Executor`, and `runtime/task.py`'s `TaskRunner` strictly by import
|
||||||
- [ ] 6.2 Implement the handoff loop: Observer → Planner → Executor → Verifier → (Reflector only on a Verifier-flagged failure) → back to Observer
|
- [x] 6.2 Implement the handoff loop: Observer → Planner → Executor → Verifier → (Reflector only on a Verifier-flagged failure) → back to Observer
|
||||||
- [ ] 6.3 Implement the reflection-recovery ceiling: a configurable max-attempts counter, independent of and layered on top of `Executor.max_retries`, that stops further reflection-driven recovery once exhausted and surfaces the failure
|
- [x] 6.3 Implement the reflection-recovery ceiling: a configurable max-attempts counter, independent of and layered on top of `Executor.max_retries`, that stops further reflection-driven recovery once exhausted and surfaces the failure
|
||||||
- [ ] 6.4 Write unit tests for `CollaborativeTaskRunner` covering: a task that completes without any Verifier-flagged failure, a task recovered via one Reflector-proposed action, and a task that exhausts the reflection-recovery ceiling
|
- [x] 6.4 Write unit tests for `CollaborativeTaskRunner` covering: a task that completes without any Verifier-flagged failure, a task recovered via one Reflector-proposed action, and a task that exhausts the reflection-recovery ceiling
|
||||||
|
|
||||||
## 7. Config wiring and opt-in behavior (capability: multi-agent-collaboration)
|
## 7. Config wiring and opt-in behavior (capability: multi-agent-collaboration)
|
||||||
|
|
||||||
- [ ] 7.1 Confirm collaboration is disabled by default: constructing/running a task without explicitly enabling it behaves exactly as plain `TaskRunner.run()` today
|
- [x] 7.1 Confirm collaboration is disabled by default: constructing/running a task without explicitly enabling it behaves exactly as plain `TaskRunner.run()` today
|
||||||
- [ ] 7.2 Write a unit test asserting `runtime/planner.py`'s `Planner`, `runtime/executor.py`'s `Executor`, and `runtime/task.py`'s `TaskRunner` are unmodified/unaffected by this change (no accidental coupling introduced from `agents/`)
|
- [x] 7.2 Write a unit test asserting `runtime/planner.py`'s `Planner`, `runtime/executor.py`'s `Executor`, and `runtime/task.py`'s `TaskRunner` are unmodified/unaffected by this change (no accidental coupling introduced from `agents/`)
|
||||||
|
|
||||||
## 8. End-to-end validation
|
## 8. End-to-end validation
|
||||||
|
|
||||||
- [ ] 8.1 Write an end-to-end test simulating a full collaborative task run against a mocked `Driver`/`Scene`/`SemanticScene`/`WorldState` and a mocked LLM client, asserting the loop completes normally whether verification succeeds or triggers reflection-driven recovery
|
- [x] 8.1 Write an end-to-end test simulating a full collaborative task run against a mocked `Driver`/`Scene`/`SemanticScene`/`WorldState` and a mocked LLM client, asserting the loop completes normally whether verification succeeds or triggers reflection-driven recovery
|
||||||
- [ ] 8.2 Write an integration test (skippable without network/API credentials, following the existing `apex-agent-mvp` skippable-integration-test pattern) exercising Verifier/Reflector against the real LLM client
|
- [x] 8.2 Write an integration test (skippable without network/API credentials, following the existing `apex-agent-mvp` skippable-integration-test pattern) exercising Verifier/Reflector against the real LLM client
|
||||||
- [ ] 8.3 Confirm multi-agent collaboration stays disabled by default after applying this change (no existing task's behavior, latency, or cost changes unless a caller explicitly opts in)
|
- [x] 8.3 Confirm multi-agent collaboration stays disabled by default after applying this change (no existing task's behavior, latency, or cost changes unless a caller explicitly opts in)
|
||||||
- [ ] 8.4 Run the full test suite (`pytest`) and confirm no existing test in `tests/` needed a behavior change, only additive new tests
|
- [x] 8.4 Run the full test suite (`pytest`) and confirm no existing test in `tests/` needed a behavior change, only additive new tests
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dev = [
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = [
|
include = [
|
||||||
|
"agents*",
|
||||||
"api*",
|
"api*",
|
||||||
"core*",
|
"core*",
|
||||||
"device*",
|
"device*",
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from agents.collab_runner import CollaborativeTaskRunner, CollaborativeTaskRunnerConfig
|
||||||
|
from agents.config import CollaborationConfig
|
||||||
|
from agents.models import Observation, ReflectionAction, ReflectionOutcome, VerificationVerdict
|
||||||
|
from core.models import Bounds, Scene, SceneElement, Task
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
|
||||||
|
|
||||||
|
def _scene() -> Scene:
|
||||||
|
return Scene(
|
||||||
|
width=1080,
|
||||||
|
height=1920,
|
||||||
|
elements=[SceneElement(id="btn1", type="button", bounds=Bounds(10, 20, 100, 50), text="OK")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task() -> Task:
|
||||||
|
return Task(goal="Test goal", device_id="dev1")
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_step() -> PlannedStep:
|
||||||
|
return PlannedStep(action="describe_screen", description="Observe", args={})
|
||||||
|
|
||||||
|
|
||||||
|
def _success_step_result() -> StepResult:
|
||||||
|
return StepResult(step=_planned_step(), success=True, attempts=1, result="ok")
|
||||||
|
|
||||||
|
|
||||||
|
def _observation() -> Observation:
|
||||||
|
return Observation(scene_summary="Screen 1080x1920")
|
||||||
|
|
||||||
|
|
||||||
|
def _achieved_verdict() -> VerificationVerdict:
|
||||||
|
return VerificationVerdict(result="achieved", reasoning="Effect observed.")
|
||||||
|
|
||||||
|
|
||||||
|
def _not_achieved_verdict() -> VerificationVerdict:
|
||||||
|
return VerificationVerdict(result="not_achieved", reasoning="No change.")
|
||||||
|
|
||||||
|
|
||||||
|
def _replan_outcome() -> ReflectionOutcome:
|
||||||
|
return ReflectionOutcome(replan=True, reasoning="No recovery.")
|
||||||
|
|
||||||
|
|
||||||
|
def _recovery_outcome() -> ReflectionOutcome:
|
||||||
|
return ReflectionOutcome(
|
||||||
|
replan=False,
|
||||||
|
action=ReflectionAction(action="swipe", description="Scroll", args={"direction": "up"}),
|
||||||
|
reasoning="Try scrolling.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completes_without_verification_failure() -> None:
|
||||||
|
"""Task completes normally when verifier reports achieved."""
|
||||||
|
planner = MagicMock()
|
||||||
|
planner.plan.return_value = [_planned_step()]
|
||||||
|
planner.goal_reached.return_value = True
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
executor.execute.return_value = _success_step_result()
|
||||||
|
|
||||||
|
observer = MagicMock()
|
||||||
|
observer.observe.return_value = _observation()
|
||||||
|
|
||||||
|
verifier = MagicMock()
|
||||||
|
verifier.verify.return_value = _achieved_verdict()
|
||||||
|
|
||||||
|
reflector = MagicMock()
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=observer,
|
||||||
|
verifier=verifier,
|
||||||
|
reflector=reflector,
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=5),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=_scene()):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "completed"
|
||||||
|
reflector.reflect.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovers_via_reflector_action() -> None:
|
||||||
|
"""Task recovers when reflector proposes a distinct action."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def verifier_side_effect(**kwargs: Any) -> VerificationVerdict:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
return _not_achieved_verdict()
|
||||||
|
return _achieved_verdict()
|
||||||
|
|
||||||
|
planner = MagicMock()
|
||||||
|
planner.plan.return_value = [_planned_step()]
|
||||||
|
planner.goal_reached.side_effect = [False, True]
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
executor.execute.return_value = _success_step_result()
|
||||||
|
|
||||||
|
observer = MagicMock()
|
||||||
|
observer.observe.return_value = _observation()
|
||||||
|
|
||||||
|
verifier = MagicMock()
|
||||||
|
verifier.verify.side_effect = verifier_side_effect
|
||||||
|
|
||||||
|
reflector = MagicMock()
|
||||||
|
reflector.reflect.return_value = _recovery_outcome()
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=observer,
|
||||||
|
verifier=verifier,
|
||||||
|
reflector=reflector,
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=5),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=_scene()):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "completed"
|
||||||
|
reflector.reflect.assert_called_once()
|
||||||
|
executor.execute.assert_called() # recovery action also executed
|
||||||
|
|
||||||
|
|
||||||
|
def test_exhausts_recovery_ceiling() -> None:
|
||||||
|
"""Task fails when reflection-recovery ceiling is reached."""
|
||||||
|
planner = MagicMock()
|
||||||
|
planner.plan.return_value = [_planned_step()]
|
||||||
|
planner.goal_reached.return_value = False
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
executor.execute.return_value = _success_step_result()
|
||||||
|
|
||||||
|
observer = MagicMock()
|
||||||
|
observer.observe.return_value = _observation()
|
||||||
|
|
||||||
|
verifier = MagicMock()
|
||||||
|
verifier.verify.return_value = _not_achieved_verdict()
|
||||||
|
|
||||||
|
reflector = MagicMock()
|
||||||
|
reflector.reflect.return_value = _replan_outcome()
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=observer,
|
||||||
|
verifier=verifier,
|
||||||
|
reflector=reflector,
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=10, max_recovery_attempts=2),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=_scene()):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert "ceiling" in (task.failure_reason or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_collaboration_runs_plain() -> None:
|
||||||
|
"""When collaboration is disabled, delegates to plain TaskRunner."""
|
||||||
|
planner = MagicMock()
|
||||||
|
executor = MagicMock()
|
||||||
|
|
||||||
|
mock_task_runner = MagicMock()
|
||||||
|
expected_task = _task()
|
||||||
|
expected_task.status = "completed"
|
||||||
|
mock_task_runner.run.return_value = expected_task
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
task_runner=mock_task_runner,
|
||||||
|
collaboration_config=CollaborationConfig(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.run(_task())
|
||||||
|
assert result.status == "completed"
|
||||||
|
mock_task_runner.run.assert_called_once()
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""End-to-end tests for multi-agent collaboration (task 8.1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from agents.collab_runner import CollaborativeTaskRunner, CollaborativeTaskRunnerConfig
|
||||||
|
from agents.config import CollaborationConfig
|
||||||
|
from agents.models import Observation, ReflectionAction, ReflectionOutcome, VerificationVerdict
|
||||||
|
from agents.observer import Observer
|
||||||
|
from agents.reflector import Reflector
|
||||||
|
from agents.verifier import Verifier
|
||||||
|
from core.models import Bounds, Scene, SceneElement, Task
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
|
||||||
|
|
||||||
|
def _scene() -> Scene:
|
||||||
|
return Scene(
|
||||||
|
width=1080,
|
||||||
|
height=1920,
|
||||||
|
elements=[
|
||||||
|
SceneElement(id="btn1", type="button", bounds=Bounds(10, 20, 100, 50), text="Send"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task() -> Task:
|
||||||
|
return Task(goal="Send a message", device_id="dev1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_loop_verification_succeeds() -> None:
|
||||||
|
"""Full collaborative loop: plan → execute → verify (achieved) → complete."""
|
||||||
|
planner = MagicMock()
|
||||||
|
step = PlannedStep(action="tap", description="Tap send", args={"element_id": "btn1"})
|
||||||
|
planner.plan.return_value = [step]
|
||||||
|
planner.goal_reached.side_effect = [False, True]
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
result = StepResult(step=step, success=True, attempts=1, result={"tapped": True})
|
||||||
|
executor.execute.return_value = result
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=Observer(),
|
||||||
|
verifier=Verifier(),
|
||||||
|
reflector=Reflector(),
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=5),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = _scene()
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=scene):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_loop_with_reflection_recovery() -> None:
|
||||||
|
"""Full loop: plan → execute → verify (not achieved) → reflect (recovery) → execute recovery → verify (achieved)."""
|
||||||
|
planner = MagicMock()
|
||||||
|
step = PlannedStep(action="tap", description="Tap send", args={"element_id": "btn1"})
|
||||||
|
planner.plan.return_value = [step]
|
||||||
|
planner.goal_reached.side_effect = [False, False, True]
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
success = StepResult(step=step, success=True, attempts=1, result={"tapped": True})
|
||||||
|
executor.execute.return_value = success
|
||||||
|
|
||||||
|
# First verify: not achieved; second verify: achieved
|
||||||
|
verify_call_count = 0
|
||||||
|
|
||||||
|
def verifier_verify(**kwargs):
|
||||||
|
nonlocal verify_call_count
|
||||||
|
verify_call_count += 1
|
||||||
|
if verify_call_count == 1:
|
||||||
|
return VerificationVerdict(result="not_achieved", reasoning="No change.")
|
||||||
|
return VerificationVerdict(result="achieved", reasoning="Done.")
|
||||||
|
|
||||||
|
verifier = MagicMock()
|
||||||
|
verifier.verify.side_effect = verifier_verify
|
||||||
|
|
||||||
|
reflector = MagicMock()
|
||||||
|
reflector.reflect.return_value = ReflectionOutcome(
|
||||||
|
replan=False,
|
||||||
|
action=ReflectionAction(action="swipe", description="Scroll", args={"direction": "up"}),
|
||||||
|
reasoning="Try scrolling.",
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=Observer(),
|
||||||
|
verifier=verifier,
|
||||||
|
reflector=reflector,
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=5),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = _scene()
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=scene):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "completed"
|
||||||
|
reflector.reflect.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_loop_exhausts_ceiling() -> None:
|
||||||
|
"""Full loop fails when recovery ceiling is exhausted."""
|
||||||
|
planner = MagicMock()
|
||||||
|
step = PlannedStep(action="tap", description="Tap send", args={"element_id": "btn1"})
|
||||||
|
planner.plan.return_value = [step]
|
||||||
|
planner.goal_reached.return_value = False
|
||||||
|
|
||||||
|
executor = MagicMock()
|
||||||
|
success = StepResult(step=step, success=True, attempts=1, result={"tapped": True})
|
||||||
|
executor.execute.return_value = success
|
||||||
|
|
||||||
|
verifier = MagicMock()
|
||||||
|
verifier.verify.return_value = VerificationVerdict(result="not_achieved", reasoning="No change.")
|
||||||
|
|
||||||
|
reflector = MagicMock()
|
||||||
|
reflector.reflect.return_value = ReflectionOutcome(replan=True, reasoning="Replan.")
|
||||||
|
|
||||||
|
runner = CollaborativeTaskRunner(
|
||||||
|
planner=planner,
|
||||||
|
executor=executor,
|
||||||
|
observer=Observer(),
|
||||||
|
verifier=verifier,
|
||||||
|
reflector=reflector,
|
||||||
|
config=CollaborativeTaskRunnerConfig(max_steps=10, max_recovery_attempts=2),
|
||||||
|
collaboration_config=CollaborationConfig(enabled=True, max_recovery_attempts=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = _scene()
|
||||||
|
with patch("tools.describe_screen.describe_screen", return_value=scene):
|
||||||
|
task = runner.run(_task())
|
||||||
|
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert "ceiling" in (task.failure_reason or "").lower()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Integration test for Verifier/Reflector against the real LLM client (task 8.2).
|
||||||
|
Skippable without network/API credentials."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agents.models import Observation, VerificationVerdict
|
||||||
|
from agents.reflector import Reflector
|
||||||
|
from agents.verifier import Verifier
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
from semantic.llm_client import AnthropicSemanticClient
|
||||||
|
|
||||||
|
_has_api_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.skipif(not _has_api_key, reason="ANTHROPIC_API_KEY not set")
|
||||||
|
def test_verifier_against_real_llm() -> None:
|
||||||
|
client = AnthropicSemanticClient()
|
||||||
|
verifier = Verifier(client=client)
|
||||||
|
|
||||||
|
pre = Observation(
|
||||||
|
scene_summary="Screen showing a chat app with a 'Reply' button visible.",
|
||||||
|
semantic_page="chat",
|
||||||
|
semantic_intents=["send_message"],
|
||||||
|
)
|
||||||
|
post = Observation(
|
||||||
|
scene_summary="Screen showing a chat app with 'Message sent' confirmation visible.",
|
||||||
|
semantic_page="chat",
|
||||||
|
semantic_intents=["send_message"],
|
||||||
|
)
|
||||||
|
step = PlannedStep(action="tap", description="Tap the send button", args={})
|
||||||
|
result = StepResult(step=step, success=True, attempts=1, result={"tapped": True})
|
||||||
|
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=pre,
|
||||||
|
post_observation=post,
|
||||||
|
planned_step=step,
|
||||||
|
step_result=result,
|
||||||
|
)
|
||||||
|
assert verdict.result in ("achieved", "not_achieved")
|
||||||
|
assert verdict.reasoning
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.skipif(not _has_api_key, reason="ANTHROPIC_API_KEY not set")
|
||||||
|
def test_reflector_against_real_llm() -> None:
|
||||||
|
client = AnthropicSemanticClient()
|
||||||
|
reflector = Reflector(client=client)
|
||||||
|
|
||||||
|
observation = Observation(
|
||||||
|
scene_summary="Screen unchanged after tapping send button.",
|
||||||
|
semantic_page="chat",
|
||||||
|
semantic_intents=["send_message"],
|
||||||
|
)
|
||||||
|
step = PlannedStep(action="tap", description="Tap the send button", args={})
|
||||||
|
result = StepResult(step=step, success=True, attempts=1, result={"tapped": True})
|
||||||
|
verdict = VerificationVerdict(result="not_achieved", reasoning="Screen unchanged.")
|
||||||
|
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=observation,
|
||||||
|
planned_step=step,
|
||||||
|
step_result=result,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
assert outcome.replan is True or outcome.action is not None
|
||||||
|
assert outcome.reasoning
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agents.models import (
|
||||||
|
Observation,
|
||||||
|
ReflectionAction,
|
||||||
|
ReflectionOutcome,
|
||||||
|
VerificationVerdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_round_trip() -> None:
|
||||||
|
obs = Observation(
|
||||||
|
scene_summary="Screen 1080x1920\n [button] Send",
|
||||||
|
semantic_page="chat",
|
||||||
|
semantic_intents=["send_message"],
|
||||||
|
world_app="com.example.messenger",
|
||||||
|
world_page="conversation",
|
||||||
|
world_variables={"draft": "hello"},
|
||||||
|
raw_scene={"screen": {"width": 1080, "height": 1920}, "elements": []},
|
||||||
|
)
|
||||||
|
data = obs.to_dict()
|
||||||
|
restored = Observation.from_dict(data)
|
||||||
|
assert restored == obs
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_minimal_round_trip() -> None:
|
||||||
|
obs = Observation(scene_summary="empty")
|
||||||
|
data = obs.to_dict()
|
||||||
|
restored = Observation.from_dict(data)
|
||||||
|
assert restored.scene_summary == "empty"
|
||||||
|
assert restored.semantic_page is None
|
||||||
|
assert restored.semantic_intents == []
|
||||||
|
assert restored.world_app is None
|
||||||
|
assert restored.world_page is None
|
||||||
|
assert restored.world_variables == {}
|
||||||
|
assert restored.raw_scene is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_verdict_achieved_round_trip() -> None:
|
||||||
|
verdict = VerificationVerdict(result="achieved", reasoning="Effect observed.")
|
||||||
|
data = verdict.to_dict()
|
||||||
|
restored = VerificationVerdict.from_dict(data)
|
||||||
|
assert restored == verdict
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_verdict_not_achieved_round_trip() -> None:
|
||||||
|
verdict = VerificationVerdict(result="not_achieved", reasoning="Scene unchanged.")
|
||||||
|
data = verdict.to_dict()
|
||||||
|
restored = VerificationVerdict.from_dict(data)
|
||||||
|
assert restored == verdict
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflection_action_round_trip() -> None:
|
||||||
|
action = ReflectionAction(
|
||||||
|
action="tap",
|
||||||
|
description="Tap the send button",
|
||||||
|
args={"element_id": "btn_send"},
|
||||||
|
)
|
||||||
|
data = action.to_dict()
|
||||||
|
restored = ReflectionAction.from_dict(data)
|
||||||
|
assert restored == action
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflection_outcome_with_action_round_trip() -> None:
|
||||||
|
action = ReflectionAction(action="tap", description="Retry tap", args={})
|
||||||
|
outcome = ReflectionOutcome(replan=False, action=action, reasoning="Try again.")
|
||||||
|
data = outcome.to_dict()
|
||||||
|
restored = ReflectionOutcome.from_dict(data)
|
||||||
|
assert restored == outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflection_outcome_replan_round_trip() -> None:
|
||||||
|
outcome = ReflectionOutcome(replan=True, reasoning="No recovery possible.")
|
||||||
|
data = outcome.to_dict()
|
||||||
|
restored = ReflectionOutcome.from_dict(data)
|
||||||
|
assert restored == outcome
|
||||||
|
assert restored.action is None
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Verify that the agents/ package does not introduce accidental coupling
|
||||||
|
into the existing runtime/ modules (task 7.2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_planner_unchanged() -> None:
|
||||||
|
"""Planner should not import from agents/."""
|
||||||
|
module = importlib.import_module("runtime.planner")
|
||||||
|
source = open(module.__file__).read() # type: ignore[arg-type]
|
||||||
|
assert "agents" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_executor_unchanged() -> None:
|
||||||
|
"""Executor should not import from agents/."""
|
||||||
|
module = importlib.import_module("runtime.executor")
|
||||||
|
source = open(module.__file__).read() # type: ignore[arg-type]
|
||||||
|
assert "agents" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_task_unchanged() -> None:
|
||||||
|
"""TaskRunner should not import from agents/."""
|
||||||
|
module = importlib.import_module("runtime.task")
|
||||||
|
source = open(module.__file__).read() # type: ignore[arg-type]
|
||||||
|
assert "agents" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_agents_imports_runtime() -> None:
|
||||||
|
"""agents/ should import from runtime/, not the other way around."""
|
||||||
|
collab = importlib.import_module("agents.collab_runner")
|
||||||
|
source = open(collab.__file__).read() # type: ignore[arg-type]
|
||||||
|
assert "runtime.task" in source
|
||||||
|
assert "runtime.planner" in source
|
||||||
|
assert "runtime.executor" in source
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from agents.observer import Observer
|
||||||
|
from core.models import Bounds, Scene, SceneElement
|
||||||
|
from semantic.models import SemanticScene, SemanticWidget
|
||||||
|
from world.models import WorldState
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scene() -> Scene:
|
||||||
|
return Scene(
|
||||||
|
width=1080,
|
||||||
|
height=1920,
|
||||||
|
elements=[
|
||||||
|
SceneElement(id="btn1", type="button", bounds=Bounds(10, 20, 100, 50), text="Send"),
|
||||||
|
SceneElement(id="txt1", type="text", bounds=Bounds(0, 0, 500, 30), text="Hello"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_semantic_scene() -> SemanticScene:
|
||||||
|
return SemanticScene(
|
||||||
|
page="chat",
|
||||||
|
intents=["send_message"],
|
||||||
|
widgets=[SemanticWidget(element_id="btn1", purpose="send button")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_world() -> WorldState:
|
||||||
|
state = WorldState(
|
||||||
|
current_app="com.example.messenger",
|
||||||
|
current_page="conversation",
|
||||||
|
variables={"draft": "hello"},
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_with_semantic_and_world() -> None:
|
||||||
|
obs = Observer().observe(
|
||||||
|
scene=_make_scene(),
|
||||||
|
semantic_scene=_make_semantic_scene(),
|
||||||
|
world=_make_world(),
|
||||||
|
)
|
||||||
|
assert obs.semantic_page == "chat"
|
||||||
|
assert obs.semantic_intents == ["send_message"]
|
||||||
|
assert obs.world_app == "com.example.messenger"
|
||||||
|
assert obs.world_page == "conversation"
|
||||||
|
assert obs.world_variables == {"draft": "hello"}
|
||||||
|
assert obs.raw_scene is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_without_semantic_or_world() -> None:
|
||||||
|
obs = Observer().observe(scene=_make_scene())
|
||||||
|
assert obs.semantic_page is None
|
||||||
|
assert obs.semantic_intents == []
|
||||||
|
assert obs.world_app is None
|
||||||
|
assert obs.world_page is None
|
||||||
|
assert obs.world_variables == {}
|
||||||
|
assert obs.scene_summary is not None
|
||||||
|
assert obs.raw_scene is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_with_semantic_only() -> None:
|
||||||
|
obs = Observer().observe(
|
||||||
|
scene=_make_scene(),
|
||||||
|
semantic_scene=_make_semantic_scene(),
|
||||||
|
)
|
||||||
|
assert obs.semantic_page == "chat"
|
||||||
|
assert obs.world_app is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_with_world_only() -> None:
|
||||||
|
obs = Observer().observe(
|
||||||
|
scene=_make_scene(),
|
||||||
|
world=_make_world(),
|
||||||
|
)
|
||||||
|
assert obs.semantic_page is None
|
||||||
|
assert obs.world_app == "com.example.messenger"
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_scene_summary_includes_elements() -> None:
|
||||||
|
obs = Observer().observe(scene=_make_scene())
|
||||||
|
assert "1080x1920" in obs.scene_summary
|
||||||
|
assert "Send" in obs.scene_summary
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agents.models import Observation, ReflectionOutcome, VerificationVerdict
|
||||||
|
from agents.reflector import Reflector
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
|
||||||
|
|
||||||
|
def _observation() -> Observation:
|
||||||
|
return Observation(scene_summary="Screen 1080x1920\n [button] Reply")
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_step() -> PlannedStep:
|
||||||
|
return PlannedStep(
|
||||||
|
action="tap",
|
||||||
|
description="Tap the send button",
|
||||||
|
args={"element_id": "btn_send"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _success_result() -> StepResult:
|
||||||
|
return StepResult(
|
||||||
|
step=_planned_step(),
|
||||||
|
success=True,
|
||||||
|
attempts=1,
|
||||||
|
result={"tapped": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_achieved_verdict() -> VerificationVerdict:
|
||||||
|
return VerificationVerdict(result="not_achieved", reasoning="Scene unchanged.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_fallback_returns_replan() -> None:
|
||||||
|
reflector = Reflector()
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=_observation(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
verdict=_not_achieved_verdict(),
|
||||||
|
)
|
||||||
|
assert outcome.replan is True
|
||||||
|
assert outcome.action is None
|
||||||
|
assert "fallback" in outcome.reasoning.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
"""A fake LLM client that returns a canned outcome."""
|
||||||
|
|
||||||
|
def __init__(self, outcome: dict) -> None:
|
||||||
|
self._outcome = outcome
|
||||||
|
self._calls: list[dict] = []
|
||||||
|
|
||||||
|
def _create_message(self, payload: dict, *, timeout: float) -> dict:
|
||||||
|
self._calls.append(payload)
|
||||||
|
return self._outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_recovery_action_via_llm() -> None:
|
||||||
|
fake = _FakeClient({
|
||||||
|
"replan": False,
|
||||||
|
"action": {
|
||||||
|
"action": "swipe",
|
||||||
|
"description": "Scroll down to find send button",
|
||||||
|
"args": {"direction": "up"},
|
||||||
|
},
|
||||||
|
"reasoning": "Button may be off-screen.",
|
||||||
|
})
|
||||||
|
reflector = Reflector(client=fake) # type: ignore[arg-type]
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=_observation(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
verdict=_not_achieved_verdict(),
|
||||||
|
)
|
||||||
|
assert outcome.replan is False
|
||||||
|
assert outcome.action is not None
|
||||||
|
assert outcome.action.action == "swipe"
|
||||||
|
assert len(fake._calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_replan_via_llm() -> None:
|
||||||
|
fake = _FakeClient({
|
||||||
|
"replan": True,
|
||||||
|
"action": None,
|
||||||
|
"reasoning": "No bounded recovery possible.",
|
||||||
|
})
|
||||||
|
reflector = Reflector(client=fake) # type: ignore[arg-type]
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=_observation(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
verdict=_not_achieved_verdict(),
|
||||||
|
)
|
||||||
|
assert outcome.replan is True
|
||||||
|
assert outcome.action is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_rejects_identical_action() -> None:
|
||||||
|
"""If the LLM proposes the exact same step, reflector should force replan."""
|
||||||
|
fake = _FakeClient({
|
||||||
|
"replan": False,
|
||||||
|
"action": {
|
||||||
|
"action": "tap",
|
||||||
|
"description": "Tap the send button",
|
||||||
|
"args": {"element_id": "btn_send"},
|
||||||
|
},
|
||||||
|
"reasoning": "Try again.",
|
||||||
|
})
|
||||||
|
reflector = Reflector(client=fake) # type: ignore[arg-type]
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=_observation(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
verdict=_not_achieved_verdict(),
|
||||||
|
)
|
||||||
|
assert outcome.replan is True
|
||||||
|
assert "identical" in outcome.reasoning.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_degrades_on_client_failure() -> None:
|
||||||
|
from semantic.llm_client import EnrichmentUnavailable
|
||||||
|
|
||||||
|
class _UnavailableClient:
|
||||||
|
def _create_message(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
raise EnrichmentUnavailable("timeout")
|
||||||
|
|
||||||
|
reflector = Reflector(client=_UnavailableClient()) # type: ignore[arg-type]
|
||||||
|
outcome = reflector.reflect(
|
||||||
|
observation=_observation(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
verdict=_not_achieved_verdict(),
|
||||||
|
)
|
||||||
|
assert outcome.replan is True
|
||||||
|
assert "fallback" in outcome.reasoning.lower()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agents.models import Observation, VerificationVerdict
|
||||||
|
from agents.verifier import Verifier
|
||||||
|
from runtime.executor import StepResult
|
||||||
|
from runtime.planner import PlannedStep
|
||||||
|
|
||||||
|
|
||||||
|
def _pre_observation() -> Observation:
|
||||||
|
return Observation(scene_summary="Screen 1080x1920\n [button] Reply")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_observation_changed() -> Observation:
|
||||||
|
return Observation(scene_summary="Screen 1080x1920\n [text] Message sent")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_observation_unchanged() -> Observation:
|
||||||
|
return Observation(scene_summary="Screen 1080x1920\n [button] Reply")
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_step() -> PlannedStep:
|
||||||
|
return PlannedStep(
|
||||||
|
action="tap",
|
||||||
|
description="Tap the send button",
|
||||||
|
args={"element_id": "btn_send"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _success_result() -> StepResult:
|
||||||
|
return StepResult(
|
||||||
|
step=_planned_step(),
|
||||||
|
success=True,
|
||||||
|
attempts=1,
|
||||||
|
result={"tapped": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_result() -> StepResult:
|
||||||
|
return StepResult(
|
||||||
|
step=_planned_step(),
|
||||||
|
success=False,
|
||||||
|
attempts=3,
|
||||||
|
error="element not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_not_achieved_on_mechanical_failure() -> None:
|
||||||
|
verifier = Verifier()
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_changed(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_failure_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "not_achieved"
|
||||||
|
assert "failed mechanically" in verdict.reasoning.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_achieved_fallback_scene_changed() -> None:
|
||||||
|
verifier = Verifier()
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_changed(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "achieved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_not_achieved_fallback_scene_unchanged() -> None:
|
||||||
|
verifier = Verifier()
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_unchanged(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "not_achieved"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
"""A fake LLM client that returns a canned verdict."""
|
||||||
|
|
||||||
|
def __init__(self, verdict: dict) -> None:
|
||||||
|
self._verdict = verdict
|
||||||
|
self._calls: list[dict] = []
|
||||||
|
|
||||||
|
def _create_message(self, payload: dict, *, timeout: float) -> dict:
|
||||||
|
self._calls.append(payload)
|
||||||
|
return self._verdict
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_achieved_via_llm() -> None:
|
||||||
|
fake = _FakeClient({"result": "achieved", "reasoning": "Effect observed."})
|
||||||
|
verifier = Verifier(client=fake) # type: ignore[arg-type]
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_changed(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "achieved"
|
||||||
|
assert len(fake._calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_not_achieved_via_llm() -> None:
|
||||||
|
fake = _FakeClient({"result": "not_achieved", "reasoning": "No change."})
|
||||||
|
verifier = Verifier(client=fake) # type: ignore[arg-type]
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_unchanged(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "not_achieved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_degrades_on_client_failure() -> None:
|
||||||
|
class _BrokenClient:
|
||||||
|
def _create_message(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
raise RuntimeError("connection failed")
|
||||||
|
|
||||||
|
from semantic.llm_client import EnrichmentUnavailable
|
||||||
|
|
||||||
|
class _UnavailableClient:
|
||||||
|
def _create_message(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
raise EnrichmentUnavailable("timeout")
|
||||||
|
|
||||||
|
verifier = Verifier(client=_UnavailableClient()) # type: ignore[arg-type]
|
||||||
|
verdict = verifier.verify(
|
||||||
|
pre_observation=_pre_observation(),
|
||||||
|
post_observation=_post_observation_changed(),
|
||||||
|
planned_step=_planned_step(),
|
||||||
|
step_result=_success_result(),
|
||||||
|
)
|
||||||
|
assert verdict.result == "achieved"
|
||||||
|
assert "fallback" in verdict.reasoning.lower()
|
||||||
@@ -5,6 +5,7 @@ import importlib
|
|||||||
|
|
||||||
def test_imports_new_packages() -> None:
|
def test_imports_new_packages() -> None:
|
||||||
for package in (
|
for package in (
|
||||||
|
"agents",
|
||||||
"api",
|
"api",
|
||||||
"core",
|
"core",
|
||||||
"device",
|
"device",
|
||||||
|
|||||||
Reference in New Issue
Block a user