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.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)
|
||||
- [ ] 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.1 Create the `agents/` package (`__init__.py`, `models.py`, `observer.py`, `verifier.py`, `reflector.py`, `collab_runner.py`, `config.py`)
|
||||
- [x] 1.2 Add an `agents*` entry to `[tool.setuptools.packages.find].include` in `pyproject.toml` (no new third-party dependency)
|
||||
- [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
|
||||
- [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.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.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.2 Write unit tests for each dataclass round-tripping through `to_dict()`/`from_dict()`
|
||||
|
||||
## 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
|
||||
- [ ] 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.1 Implement `agents/observer.py`: `Observer.observe(...) -> Observation`, perceiving current device state via `SemanticScene`/`WorldState` when available
|
||||
- [x] 3.2 Make `Observer.observe(...)` work when `SemanticScene` and/or `WorldState` are `None`, falling back to the raw `Scene`/`PlannedStep`/`StepResult`
|
||||
- [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.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
|
||||
- [ ] 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.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.2 Wire `Verifier` to reuse `semantic/llm_client.py`'s existing LLM client abstraction (injectable/mockable) rather than a new client
|
||||
- [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.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
|
||||
- [ ] 5.3 Write unit tests for `Reflector.reflect(...)` covering: recovery-action outcome, replan-request outcome, and client-failure degrade case
|
||||
- [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
|
||||
- [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
|
||||
- [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.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
|
||||
- [ ] 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.1 Implement `agents/collab_runner.py`: `CollaborativeTaskRunner`, composing the existing `Planner`, `Executor`, and `runtime/task.py`'s `TaskRunner` strictly by import
|
||||
- [x] 6.2 Implement the handoff loop: Observer → Planner → Executor → Verifier → (Reflector only on a Verifier-flagged failure) → back to Observer
|
||||
- [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
|
||||
- [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.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.1 Confirm collaboration is disabled by default: constructing/running a task without explicitly enabling it behaves exactly as plain `TaskRunner.run()` today
|
||||
- [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.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
|
||||
- [ ] 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.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.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.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.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]
|
||||
include = [
|
||||
"agents*",
|
||||
"api*",
|
||||
"core*",
|
||||
"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:
|
||||
for package in (
|
||||
"agents",
|
||||
"api",
|
||||
"core",
|
||||
"device",
|
||||
|
||||
Reference in New Issue
Block a user