import itertools import random import tempfile from copy import deepcopy from typing import Any, cast import pytest from aviary.env import Environment, Frame from aviary.message import Message from aviary.tools import Tool, ToolRequestMessage from pydantic import BaseModel from ldp.agent import Agent, SimpleAgent, SimpleAgentState from ldp.alg import BeamSearchRollout, Callback, RolloutManager, TreeSearchRollout from ldp.data_structures import Trajectory, Transition from ldp.graph import FxnOp, OpResult, compute_graph, set_training_mode class DummyEnv(Environment[None]): def __init__(self): self.tools = [Tool.from_function(self.talk)] async def reset(self) -> tuple[list[Message], list[Tool]]: return [Message(content="Hello!")], self.tools async def step( self, action: ToolRequestMessage ) -> tuple[list[Message], float, bool, bool]: if action.tool_calls: responses = cast(list[Message], await self.exec_tool_calls(action)) else: responses = [Message(content="Use the 'talk' tool to speak.")] return responses, 0.0, False, False async def talk(self, message: str) -> str: """Say something to me. Args: message (str): what you want to say Returns: str: my response """ return message def export_frame(self) -> Frame: return Frame() async def count_exclamations(traj: Trajectory) -> float: last_step = traj.steps[-1] agent_state = cast(SimpleAgentState, last_step.next_agent_state) return float( sum(m.content.count("!") for m in agent_state.messages if m.content is not None) ) @pytest.mark.vcr @pytest.mark.parametrize("training", [True, False]) @pytest.mark.asyncio async def test_rollout(training: bool) -> None: agent = SimpleAgent() callback = DummyCallback() set_training_mode(training) rollout_manager = RolloutManager( agent, catch_agent_failures=False, catch_env_failures=False, callbacks=[callback], ) trajs = await rollout_manager.sample_trajectories( environments=[DummyEnv(), DummyEnv()], max_steps=1 ) assert len(trajs) == 2 # Let's check we can serialize and deserialize the trajectories for traj in trajs: with tempfile.NamedTemporaryFile(suffix=".jsonl") as f: traj.to_jsonl(filename=f.name) rehydrated_traj = Trajectory.from_jsonl(f.name) assert traj.traj_id == rehydrated_traj.traj_id assert all(v == 2 for v in callback.fn_invocations.values()) async def adeepcopy(x): return deepcopy(x) @pytest.mark.vcr @pytest.mark.asyncio async def test_beam_search() -> None: agent = SimpleAgent() callback = DummyCallback() beam_search = BeamSearchRollout( agent, beam_width=1, # keep these numbers small to speed up test samples_per_beam=1, env_clone_fn=adeepcopy, agent_clone_fn=deepcopy, scoring_fn=count_exclamations, catch_agent_failures=False, catch_env_failures=False, callbacks=[callback], ) trajs = await beam_search.sample_trajectories( environments=[DummyEnv(), DummyEnv()], max_steps=1 ) assert len(trajs) == 2 assert all( v == 2 for k, v in callback.fn_invocations.items() if k != "after_agent_init_state" # TODO: support this callback too ) class DummyCallback(Callback): def __init__(self): # NOTE: don't use collections.defaultdict here because it can lead to # test aliasing for a callback being missed altogether self.fn_invocations = { "before_transition": 0, "after_agent_init_state": 0, "after_agent_get_asv": 0, "after_env_reset": 0, "after_env_step": 0, "after_transition": 0, } async def before_transition( self, traj_id: str, agent: Agent, env: Environment, agent_state: Any, obs: list[Message], ) -> None: self.fn_invocations["before_transition"] += 1 async def after_agent_init_state(self, traj_id: str, init_state: Any) -> None: self.fn_invocations["after_agent_init_state"] += 1 async def after_agent_get_asv( self, traj_id: str, action: OpResult[ToolRequestMessage], next_agent_state: Any, value: float, ): self.fn_invocations["after_agent_get_asv"] += 1 async def after_env_reset( self, traj_id: str, obs: list[Message], tools: list[Tool] ) -> None: self.fn_invocations["after_env_reset"] += 1 async def after_env_step( self, traj_id: str, obs: list[Message], reward: float, done: bool, trunc: bool, ): self.fn_invocations["after_env_step"] += 1 async def after_transition( self, traj_id: str, agent: Agent, env: Environment, transition: Transition, ) -> None: self.fn_invocations["after_transition"] += 1 class CountingAgentState(BaseModel): count: float = 0.0 class CountingAgent(Agent[CountingAgentState]): def __init__(self): self.op = FxnOp[ToolRequestMessage](lambda: ToolRequestMessage(tool_calls=[])) async def init_state(self, tools: list[Tool]) -> CountingAgentState: return CountingAgentState() @compute_graph() async def get_asv( self, agent_state: CountingAgentState, obs: list[Message] ) -> tuple[OpResult[ToolRequestMessage], CountingAgentState, float]: new_state = CountingAgentState(count=float(cast(str, obs[0].content)) + 1) action = await self.op() return action, new_state, 0.0 class CountingEnv(Environment[float]): def __init__(self, state: float = 0.0): self.state = state async def reset(self) -> tuple[list[Message], list[Tool]]: return [Message(content=str(self.state))], [] async def step( self, action: ToolRequestMessage ) -> tuple[list[Message], float, bool, bool]: self.state += 1 return [Message(content=str(self.state))], 0.0, self.state >= 3, False def export_frame(self) -> Frame: return Frame() @pytest.mark.asyncio async def test_deterministic_rollout(): agent = CountingAgent() env = CountingEnv() rollout_manager = RolloutManager(agent) traj, *_ = await rollout_manager.sample_trajectories(environments=[env]) assert len(traj.steps) == 3 for i_step, step in enumerate(traj.steps): f_step = float(i_step) # check that we didn't clobber any agent or env states assert step.agent_state.count == f_step assert step.next_agent_state.count == f_step + 1 assert step.observation[0].content == str(f_step) assert step.next_observation[0].content == str(f_step + 1) class NoisyCountingEnv(CountingEnv): async def step( self, action: ToolRequestMessage ) -> tuple[list[Message], float, bool, bool]: self.state += 1 + random.uniform(-0.01, 0.01) return [Message(content=str(self.state))], 1.0, self.state >= 3, False class TestTreeSearch: @pytest.mark.asyncio async def test_tree_search(self) -> None: agent = CountingAgent() # Use a slightly stochastic env so we can distinguish branches env = NoisyCountingEnv() callback = DummyCallback() rollout_manager = TreeSearchRollout( agent, branching_factor=2, env_clone_fn=deepcopy, concurrency_limit=1, callbacks=[callback], ) tree = await rollout_manager.sample_tree(env, max_depth=3) trajs = tree.get_trajectories() assert len(trajs) == 8 traj_ids_wo_root: set[str] = { cast(str, traj.traj_id).replace(tree.root_id, "").lstrip(":") for traj in trajs } # IDs should be 0:0:0, 0:0:1, ... 1:1:1 (order doesn't matter) assert traj_ids_wo_root == { ":".join(x) for x in itertools.product("01", repeat=3) } observations: dict[tuple[str, ...], str] = {} for traj in trajs: branch_path = tuple(cast(str, traj.traj_id).split(":")[1:]) prev_step: Transition | None = None for i_step, step in enumerate(traj.steps): if prev_step is not None: # Check that the child node started at the state emitted at the parent node assert prev_step.next_agent_state == step.agent_state # Steps that started at the same node in the tree should have the same observation node_id = branch_path[: i_step + 1] assert len(step.observation) == 1 assert step.observation[0].content if node_id in observations: assert observations[node_id] == step.observation[0].content else: observations[node_id] = step.observation[0].content prev_step = step for callback_fn, num_calls in callback.fn_invocations.items(): if callback_fn in {"after_agent_init_state", "after_env_reset"}: assert num_calls == 1, "These should be invoked once at the start" else: # We expect sum_{i=1}^3 2^i = 2^4 - 2 = 14 transitions: # - branching factor = 2, depth = 3 # - root node isn't sampled, so no i=0 term in sum assert num_calls == 14 @pytest.mark.asyncio async def test_early_stopping(self) -> None: agent = CountingAgent() # Use a slightly stochastic env so we can distinguish branches env = NoisyCountingEnv() callback = DummyCallback() rollout_manager = TreeSearchRollout( agent, branching_factor=2, env_clone_fn=deepcopy, concurrency_limit=1, callbacks=[callback], target_reward=0.5, ) trajs = (await rollout_manager.sample_tree(env, max_depth=3)).get_trajectories() assert len(trajs) < 8 # should have exited early for traj in trajs: # should have hit target reward immediately assert len(traj.steps) == 1