Repository: hud-evals/hud-sdk
Pull request #52: L/agent logs
Author: login: lorenss-m
Created: 2025-05-29T20:02:59Z | Merged: 2025-05-31T09:03:11Z



Diff:
diff --git a/hud/__init__.py b/hud/__init__.py
index 556cfd833..ba1ea6633 100644
--- a/hud/__init__.py
+++ b/hud/__init__.py
@@ -22,7 +22,7 @@
 
 if not hud_logger.handlers:
     handler = logging.StreamHandler()
-    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+    formatter = logging.Formatter("[%(levelname)s] %(asctime)s | %(name)s | %(message)s")
     handler.setFormatter(formatter)
     hud_logger.addHandler(handler)
 
diff --git a/hud/adapters/claude/tests/test_adapter.py b/hud/adapters/claude/tests/test_adapter.py
index 33de2b417..b4b52a3de 100644
--- a/hud/adapters/claude/tests/test_adapter.py
+++ b/hud/adapters/claude/tests/test_adapter.py
@@ -505,7 +505,7 @@ def test_complex_action_sequence(self, adapter):
             {"action": "screenshot"},
         ]
 
-        results = adapter.adapt_list(actions)
+        results = adapter.adapt_list(actions, None)
 
         assert len(results) == 6
         assert isinstance(results[0], MoveAction)
diff --git a/hud/adapters/common/adapter.py b/hud/adapters/common/adapter.py
index 630595f4c..adff16052 100644
--- a/hud/adapters/common/adapter.py
+++ b/hud/adapters/common/adapter.py
@@ -6,7 +6,7 @@
 from PIL import Image
 from pydantic import TypeAdapter, ValidationError
 
-from .types import CLA
+from .types import CLA, LogType
 
 if TYPE_CHECKING:
     from typing_extensions import TypeIs
@@ -146,12 +146,14 @@ def postprocess_action(self, action: dict[str, Any]) -> dict[str, Any]:
 
         return processed_action
 
-    def adapt(self, action: Any) -> CLA:
+    def adapt(self, action: Any, log: LogType = None) -> CLA:
         # any preprocessing steps
         action = self.preprocess(action)
 
         # convert to CLA
         action = self.convert(action)
+        if log is not None:
+            action.logs = log
         self.memory.append(action)
 
         # convert to json and apply coordinate rescaling
@@ -161,8 +163,9 @@ def adapt(self, action: Any) -> CLA:
         # convert back to CLA
         return TypeAdapter(CLA).validate_python(rescaled_action)
 
-    def adapt_list(self, actions: list[Any]) -> list[CLA]:
+    def adapt_list(self, actions: list[Any], logs: list[LogType] | None = None) -> list[CLA]:
         if not isinstance(actions, list):
             raise ValueError("Please provide a list of actions")
-
-        return [self.adapt(action) for action in actions]
+        if logs is None or len(logs) != len(actions):
+            return [self.adapt(action) for action in actions]
+        return [self.adapt(action, log) for action, log in zip(actions, logs, strict=False)]
diff --git a/hud/adapters/common/tests/test_adapter.py b/hud/adapters/common/tests/test_adapter.py
index ece33099f..9d0bcd391 100644
--- a/hud/adapters/common/tests/test_adapter.py
+++ b/hud/adapters/common/tests/test_adapter.py
@@ -243,7 +243,7 @@ def test_adapt_list(adapter):
     mock_adapt = MagicMock(side_effect=[click_action, type_action])
     with patch.object(adapter, "adapt", mock_adapt):
         actions = [{"type": "click"}, {"type": "type"}]
-        result = adapter.adapt_list(actions)
+        result = adapter.adapt_list(actions, None)
 
         assert adapter.adapt.call_count == 2
         assert len(result) == 2
@@ -254,7 +254,7 @@ def test_adapt_list(adapter):
 def test_adapt_list_invalid(adapter):
     """Test adapt_list with invalid input."""
     with pytest.raises(ValueError):
-        adapter.adapt_list("not a list")  # type: ignore
+        adapter.adapt_list("not a list", None)  # type: ignore
 
 
 def test_integration(adapter):
diff --git a/hud/adapters/common/types.py b/hud/adapters/common/types.py
index cd7bf0316..70cada22d 100644
--- a/hud/adapters/common/types.py
+++ b/hud/adapters/common/types.py
@@ -1,13 +1,40 @@
 from __future__ import annotations
 
-from typing import Annotated, Literal, TypeAlias
+from typing import Annotated, Any, Literal, TypeAlias
 
 from pydantic import BaseModel, Field
 
+LogType = str | dict[str, Any] | list[str | dict[str, Any]] | None
+
+
+# Helper function to format logs for display
+def _format_logs_for_display(
+    logs: LogType,
+    max_log_len: int = 277,
+) -> str:
+    if not logs:
+        return ""
+    log_repr = repr(logs)
+    truncated_log = log_repr[:max_log_len] + "..." if len(log_repr) > max_log_len else log_repr
+    return f" │ Logs: {truncated_log}"
+
 
 # Base class for all actions
 class CLAAction(BaseModel):
     type: str
+    logs: LogType = None
+
+    def __str__(self) -> str:
+        # Basic representation for actions that don't have a specific override
+        # This base __str__ will NOT include logs by default, subclasses should handle it.
+        attributes = ", ".join(
+            f"{k}='{v}'" if isinstance(v, str) else f"{k}={v}"
+            for k, v in self.model_dump().items()
+            if k != "type" and v is not None and k != "logs"
+        )
+        action_str = f"{self.type.capitalize()}Action ({attributes})"
+        action_str += _format_logs_for_display(self.logs)  # Add logs if present via helper
+        return action_str
 
 
 # Basic Point model for coordinates
@@ -21,27 +48,54 @@ class ClickAction(CLAAction):
     type: Literal["click"] = "click"
     point: Point | None = None
     button: CLAButton = "left"
-    pattern: list[int] | None = None  # [delay_1, delay_2, ...]
+    pattern: list[int] | None = None
     hold_keys: list[CLAKey] | None = None
 
+    def __str__(self) -> str:
+        parts = ["💥 Click"]
+        if self.point:
+            parts.append(f"at ({self.point.x}, {self.point.y})")
+        if self.button != "left":
+            parts.append(f"with {self.button} button")
+        if self.hold_keys:
+            parts.append(f"holding {self.hold_keys}")
+        action_str = " ".join(parts)
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # PRESS ACTION for key presses/hotkeys
 class PressAction(CLAAction):
     type: Literal["press"] = "press"
     keys: list[CLAKey]
 
+    def __str__(self) -> str:
+        action_str = f"🎹 Press keys: {'+'.join(self.keys)}"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # KEYDOWN ACTION for key presses/hotkeys
 class KeyDownAction(CLAAction):
     type: Literal["keydown"] = "keydown"
     keys: list[CLAKey]
 
+    def __str__(self) -> str:
+        action_str = f"👇 KeyDown: {'+'.join(self.keys)}"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # KEYUP ACTION for key presses/hotkeys
 class KeyUpAction(CLAAction):
     type: Literal["keyup"] = "keyup"
     keys: list[CLAKey]
 
+    def __str__(self) -> str:
+        action_str = f"👆 KeyUp: {'+'.join(self.keys)}"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # TYPE ACTION for text typing
 class TypeAction(CLAAction):
@@ -49,6 +103,13 @@ class TypeAction(CLAAction):
     text: str
     enter_after: bool | None = False
 
+    def __str__(self) -> str:
+        action_str = f'✍️ Type: "{self.text}"'
+        if self.enter_after:
+            action_str += " (and press Enter)"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # SCROLL ACTION
 class ScrollAction(CLAAction):
@@ -57,6 +118,18 @@ class ScrollAction(CLAAction):
     scroll: Point | None = None
     hold_keys: list[CLAKey] | None = None
 
+    def __str__(self) -> str:
+        parts = ["📄 Scroll"]
+        if self.point:
+            parts.append(f"at ({self.point.x}, {self.point.y})")
+        if self.scroll:
+            parts.append(f"by ({self.scroll.x}, {self.scroll.y})")
+        if self.hold_keys:  # Added hold_keys for scroll
+            parts.append(f"holding {self.hold_keys}")
+        action_str = " ".join(parts)
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # MOVE ACTION for mouse movement
 class MoveAction(CLAAction):
@@ -64,11 +137,26 @@ class MoveAction(CLAAction):
     point: Point | None = None
     offset: Point | None = None
 
+    def __str__(self) -> str:
+        parts = ["✨ Move"]
+        if self.point:
+            parts.append(f"to ({self.point.x},{self.point.y})")
+        if self.offset:
+            parts.append(f"by ({self.offset.x},{self.offset.y})")
+        action_str = " ".join(parts)
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # WAIT ACTION
 class WaitAction(CLAAction):
     type: Literal["wait"] = "wait"
-    time: int  # in milliseconds
+    time: int
+
+    def __str__(self) -> str:
+        action_str = f"💤 Wait for {self.time}ms"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
 
 
 # DRAG ACTION
@@ -78,26 +166,63 @@ class DragAction(CLAAction):
     pattern: list[int] | None = None  # [delay_1, delay_2, ...]
     hold_keys: list[CLAKey] | None = None
 
+    def __str__(self) -> str:
+        parts = ["🤏 Drag"]
+        if self.path and len(self.path) > 0:
+            if len(self.path) == 1:
+                parts.append(f"at ({self.path[0].x},{self.path[0].y})")
+            else:
+                parts.append(
+                    f"from ({self.path[0].x}, {self.path[0].y}) to "
+                    f"({self.path[-1].x}, {self.path[-1].y})"
+                )
+        if self.hold_keys:  # Added hold_keys for drag
+            parts.append(f"holding {self.hold_keys}")
+        action_str = " ".join(parts)
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # RESPONSE ACTION from agent
 class ResponseAction(CLAAction):
     type: Literal["response"] = "response"
     text: str  # The final textual response from the agent
 
+    def __str__(self) -> str:
+        displayed_text = self.text if len(self.text) < 50 else self.text[:47] + "..."
+        action_str = f'💬 Response: "{displayed_text}"'
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # SCREENSHOT ACTION
 class ScreenshotFetch(CLAAction):
     type: Literal["screenshot"] = "screenshot"
 
+    def __str__(self) -> str:
+        action_str = "📸 Screenshot"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 class PositionFetch(CLAAction):
     type: Literal["position"] = "position"
 
+    def __str__(self) -> str:
+        action_str = "📍 Position"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 class CustomAction(CLAAction):
     type: Literal["custom"] = "custom"
     action: str
 
+    def __str__(self) -> str:
+        action_str = f"⚙️ Custom: {self.action}"
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # Union of all possible actions
 CLA = Annotated[
diff --git a/hud/adapters/operator/adapter.py b/hud/adapters/operator/adapter.py
index 28a40963b..0a41733a1 100644
--- a/hud/adapters/operator/adapter.py
+++ b/hud/adapters/operator/adapter.py
@@ -56,9 +56,9 @@ def convert(self, data: Any) -> CLA:
                 return ClickAction(point=Point(x=x, y=y), button="left", pattern=[100])
 
             elif action_type == "scroll":
-                x, y = data.get("x", 0), data.get("y", 0)
-                scroll_x = data.get("scroll_x", 0)
-                scroll_y = data.get("scroll_y", 0)
+                x, y = int(data.get("x", 0)), int(data.get("y", 0))
+                scroll_x = int(data.get("scroll_x", 0))
+                scroll_y = int(data.get("scroll_y", 0))
                 return ScrollAction(point=Point(x=x, y=y), scroll=Point(x=scroll_x, y=scroll_y))
 
             elif action_type == "type":
diff --git a/hud/agent/base.py b/hud/agent/base.py
index 74bcad29d..fb64dc775 100644
--- a/hud/agent/base.py
+++ b/hud/agent/base.py
@@ -1,7 +1,8 @@
 from abc import ABC, abstractmethod
-from typing import Sequence, TypeVar, Generic
+from typing import Any, Sequence, TypeVar, Generic
 
 from hud.adapters import Adapter, CLA
+from hud.adapters.common.types import LogType
 from hud.types import Gym
 from hud.utils.common import Observation
 import logging
@@ -62,7 +63,9 @@ def preprocess(self, observation: Observation) -> Observation:
         return processed_obs
 
     @abstractmethod
-    async def fetch_response(self, observation: Observation) -> tuple[list[ActionT], bool]:
+    async def fetch_response(
+        self, observation: Observation
+    ) -> tuple[list[ActionT], bool, list[LogType] | None]:
         """
         Fetch a response from the model based on the observation.
 
@@ -70,26 +73,26 @@ async def fetch_response(self, observation: Observation) -> tuple[list[ActionT],
             observation: The preprocessed observation
 
         Returns:
-            tuple[list[ActionT], bool]: A tuple containing the list of raw actions and a
+            tuple[list[ActionT], bool, list[str | dict[str, Any]] | None]: A tuple containing the list of raw actions,
                                        boolean indicating if the agent believes it has
-                                       completed the task
+                                       completed the task, and a list of strings or dictionaries of logs.
         """
         pass
 
-    def postprocess(self, actions: list[ActionT]) -> list[CLA]:
+    def postprocess(self, actions: list[ActionT], logs: list[LogType] | None) -> list[CLA]:
         """
         Convert model actions to HUD actions.
 
         Args:
             actions: The raw actions from the model
-
+            logs: The logs from the model
         Returns:
             Sequence[CLA]: The actions converted to HUD format
         """
         if not self.adapter:
             raise ValueError("Cannot postprocess actions without an adapter")
 
-        return self.adapter.adapt_list(actions)
+        return self.adapter.adapt_list(actions, logs)
 
     async def predict(
         self, observation: Observation, verbose: bool = False
@@ -112,13 +115,13 @@ async def predict(
         processed_obs = self.preprocess(observation)
 
         # Stage 2: Fetch response from the model
-        actions, done = await self.fetch_response(processed_obs)
+        actions, done, logs = await self.fetch_response(processed_obs)
         if verbose:
             logger.info("Raw action: %s", actions)
 
         # Stage 3: Postprocess the actions if we have an adapter
         if self.adapter and actions:
-            hud_actions = self.postprocess(actions)
+            hud_actions = self.postprocess(actions, logs)
             return hud_actions, done
 
         # If no adapter, return actions as is
diff --git a/hud/agent/claude.py b/hud/agent/claude.py
index 809270f92..64d49119c 100644
--- a/hud/agent/claude.py
+++ b/hud/agent/claude.py
@@ -18,6 +18,7 @@
 from hud.types import Gym
 from hud.utils.common import Observation
 from hud.settings import settings
+from hud.adapters.common.types import LogType
 
 logger = logging.getLogger(__name__)
 
@@ -109,7 +110,9 @@ def __init__(
         self.messages: list[BetaMessageParam] = []
         self.pending_computer_use_tool_id = None
 
-    async def fetch_response(self, observation: Observation) -> tuple[list[Any], bool]:
+    async def fetch_response(
+        self, observation: Observation
+    ) -> tuple[list[Any], bool, list[LogType] | None]:
         """
         Fetch a response from Claude based on the observation.
 
@@ -117,8 +120,8 @@ async def fetch_response(self, observation: Observation) -> tuple[list[Any], boo
             observation: The preprocessed observation
 
         Returns:
-            tuple[list[Any], bool]: A tuple containing the list of raw actions and a
-                                   boolean indicating if the agent believes the task is complete
+            tuple[list[Any], bool, list[str | dict[str, Any]] | None]: A tuple containing the list of raw actions,
+                                   boolean indicating if the agent believes the task is complete, and a list of strings or dictionaries of logs.
         """
         if not self.client:
             raise ValueError("Client is required")
@@ -231,4 +234,4 @@ async def fetch_response(self, observation: Observation) -> tuple[list[Any], boo
             # logger.info("No tool use and no final text block found.")
             # Keep done = True, actions remains empty
 
-        return actions, done
+        return actions, done, [response.model_dump()]
diff --git a/hud/agent/claude_plays_pokemon.py b/hud/agent/claude_plays_pokemon.py
index 5423c0e42..1a58b7bab 100644
--- a/hud/agent/claude_plays_pokemon.py
+++ b/hud/agent/claude_plays_pokemon.py
@@ -11,7 +11,7 @@
     BetaImageBlockParam,
 )
 
-from hud.adapters.common.types import CLA
+from hud.adapters.common.types import CLA, LogType
 from hud.agent import Agent
 from hud.adapters import Adapter
 from hud.settings import settings
@@ -185,14 +185,16 @@ def __init__(
 
         self.messages: list[BetaMessageParam] = []
 
-    async def fetch_response(self, observation: Observation) -> tuple[list[dict[str, Any]], bool]:
+    async def fetch_response(
+        self, observation: Observation
+    ) -> tuple[list[dict[str, Any]], bool, list[LogType] | None]:
         """Fetch a response from Claude based on the current observation.
 
         Args:
             observation: The current game observation
 
         Returns:
-            tuple[list[dict[str, Any]], bool]: List of actions and whether the game is done
+            tuple[list[dict[str, Any]], bool, list[LogType] | None]: List of actions, whether the game is done, and a list of strings or dictionaries of logs.
 
         Raises:
             ValueError: If client is not initialized
@@ -280,4 +282,4 @@ async def fetch_response(self, observation: Observation) -> tuple[list[dict[str,
 
         logger.debug("Extracted actions", extra={"actions": action_list})
 
-        return action_list, False
+        return action_list, False, [response.model_dump()]
diff --git a/hud/agent/langchain.py b/hud/agent/langchain.py
index 8bf0f9846..cce5b1a3f 100644
--- a/hud/agent/langchain.py
+++ b/hud/agent/langchain.py
@@ -24,6 +24,7 @@
     WaitAction,
     ResponseAction,
     CustomAction,
+    LogType,
     # Exclude ScreenshotFetch, PositionFetch as they are internal
 )
 
@@ -107,7 +108,7 @@ def _get_default_system_prompt(self) -> str:
 
     async def fetch_response(
         self, observation: Observation
-    ) -> tuple[list[dict | SingleCLAction], bool]:
+    ) -> tuple[list[dict | SingleCLAction], bool, list[LogType] | None]:
         """
         Fetches a response from the configured Langchain model, expecting a single
         structured CLA action.
@@ -119,6 +120,7 @@ async def fetch_response(
             A tuple containing:
             - A list with a single dictionary representing the raw CLA action (before adapter postprocessing).
             - A boolean indicating if the agent chose ResponseAction (task completion).
+            - A list of strings or dictionaries of logs.
         """
         # 1. Format observation into Langchain message(s)
         human_content: List[Union[str, dict]] = []
@@ -137,7 +139,16 @@ async def fetch_response(
         if not human_content:
             logger.warning("LangchainAgent received an observation with no text or screenshot.")
             # Decide how to handle empty observation - perhaps return no action?
-            return [], False  # Or raise an error?
+            return (
+                [],
+                False,
+                [
+                    {
+                        "type": "warning",
+                        "message": "LangchainAgent received an observation with no text or screenshot.",
+                    }
+                ],
+            )
 
         current_human_message = HumanMessage(content=human_content)
 
@@ -161,7 +172,11 @@ async def fetch_response(
         except Exception as e:
             logger.error(f"Langchain model invocation failed: {e}", exc_info=True)
             # Decide how to handle LLM errors - maybe retry or return empty action?
-            return [], False
+            return (
+                [],
+                False,
+                [{"type": "error", "message": f"Langchain model invocation failed: {e}"}],
+            )
 
         # 5. Process the structured response
         is_done = False
@@ -192,7 +207,16 @@ async def fetch_response(
             else:
                 ai_message_content_for_history = repr(ai_response_structured)
             # Return no action as we didn't get the expected structure
-            return [], False
+            return (
+                [],
+                False,
+                [
+                    {
+                        "type": "error",
+                        "message": f"Langchain model did not return the expected StepAction structure. {ai_message_content_for_history}",
+                    }
+                ],
+            )
 
         # 6. Update history
         self.history.append(current_human_message)
@@ -203,7 +227,16 @@ async def fetch_response(
 
         if actual_action:
             # Return the single action dictionary within a list
-            return [actual_action], is_done
+            return [actual_action], is_done, [ai_message_content_for_history]
         else:
             # Should ideally not happen if structure validation worked, but as a fallback
-            return [], is_done
+            return (
+                [],
+                is_done,
+                [
+                    {
+                        "type": "error",
+                        "message": f"Langchain model did not return the expected StepAction structure. {ai_message_content_for_history}",
+                    }
+                ],
+            )
diff --git a/hud/agent/operator.py b/hud/agent/operator.py
index c863c5074..151d66936 100644
--- a/hud/agent/operator.py
+++ b/hud/agent/operator.py
@@ -19,6 +19,7 @@
 from hud.types import Gym
 from hud.utils.common import Observation
 from hud.settings import settings
+from hud.adapters.common.types import LogType
 
 logger = logging.getLogger(__name__)
 
@@ -86,7 +87,9 @@ def __init__(
         self.initial_prompt = None
         self.pending_safety_checks = []
 
-    async def fetch_response(self, observation: Observation) -> tuple[list[dict[str, Any]], bool]:
+    async def fetch_response(
+        self, observation: Observation
+    ) -> tuple[list[dict[str, Any]], bool, list[LogType] | None]:
         """
         Fetch a response from the model based on the observation.
 
@@ -94,8 +97,8 @@ async def fetch_response(self, observation: Observation) -> tuple[list[dict[str,
             observation: The preprocessed observation
 
         Returns:
-            tuple[list[dict[str, Any]], bool]: A tuple containing the list of raw actions and a
-                                             boolean indicating if the agent believes the task is complete
+            tuple[list[dict[str, Any]], bool, list[LogType] | None]: A tuple containing the list of raw actions,
+                                             boolean indicating if the agent believes the task is complete, and a list of strings or dictionaries of logs.
         """
         if not self.client:
             raise ValueError("Client is required")
@@ -142,7 +145,16 @@ async def fetch_response(self, observation: Observation) -> tuple[list[dict[str,
             # This is a response to a previous action
             if not observation.screenshot:
                 logger.warning("No screenshot provided for response to action")
-                return [], True
+                return (
+                    [],
+                    True,
+                    [
+                        {
+                            "type": "warning",
+                            "message": "No screenshot provided for response to action",
+                        }
+                    ],
+                )
 
             # Create a response to the previous action with the new screenshot
             input_param_followup = cast(
@@ -221,4 +233,4 @@ async def fetch_response(self, observation: Observation) -> tuple[list[dict[str,
                 logger.info("No computer calls and no final text message found.")
             # Keep done = True, actions remains empty
 
-        return actions, done
+        return actions, done, [response.model_dump()]
diff --git a/hud/agent/tests/test_base.py b/hud/agent/tests/test_base.py
index ed357f3ca..a8a1229ce 100644
--- a/hud/agent/tests/test_base.py
+++ b/hud/agent/tests/test_base.py
@@ -7,7 +7,7 @@
 
 from hud.agent.base import Agent
 from hud.adapters import Adapter
-from hud.adapters.common.types import ClickAction, Point
+from hud.adapters.common.types import ClickAction, LogType, Point
 from hud.utils.common import Observation
 
 
@@ -19,13 +19,16 @@ def __init__(self, client: Any = None, adapter: Adapter | None = None):
         self.mock_responses = []
         self.call_count = 0
 
-    async def fetch_response(self, observation: Observation) -> tuple[list[dict[str, Any]], bool]:
+    async def fetch_response(
+        self, observation: Observation
+    ) -> tuple[list[dict[str, Any]], bool, list[LogType] | None]:
         """Mock implementation that returns predefined responses."""
         if self.call_count < len(self.mock_responses):
-            response = self.mock_responses[self.call_count]
+            # Ensure we return a 3-tuple including None for logs
+            actions, done, logs = self.mock_responses[self.call_count]
             self.call_count += 1
-            return response
-        return [], True
+            return actions, done, logs
+        return [], True, None
 
 
 class TestAgentBase:
@@ -103,14 +106,14 @@ def test_postprocess_without_adapter(self, agent_without_adapter):
         actions = [{"type": "click", "x": 100, "y": 200}]
 
         with pytest.raises(ValueError, match="Cannot postprocess actions without an adapter"):
-            agent_without_adapter.postprocess(actions)
+            agent_without_adapter.postprocess(actions, None)
 
     def test_postprocess_with_adapter(self, agent_with_adapter, mock_adapter):
         """Test postprocess with adapter."""
         actions = [{"type": "click", "x": 100, "y": 200}]
-        result = agent_with_adapter.postprocess(actions)
+        result = agent_with_adapter.postprocess(actions, None)
 
-        mock_adapter.adapt_list.assert_called_once_with(actions)
+        mock_adapter.adapt_list.assert_called_once_with(actions, None)
         assert len(result) == 1
         assert isinstance(result[0], ClickAction)
 
@@ -118,7 +121,7 @@ def test_postprocess_with_adapter(self, agent_with_adapter, mock_adapter):
     async def test_predict_without_verbose(self, agent_with_adapter):
         """Test predict method without verbose logging."""
         observation = Observation(text="test", screenshot="screenshot")
-        agent_with_adapter.mock_responses = [([{"type": "click", "x": 100, "y": 200}], False)]
+        agent_with_adapter.mock_responses = [([{"type": "click", "x": 100, "y": 200}], False, None)]
 
         actions, done = await agent_with_adapter.predict(observation, verbose=False)
 
@@ -131,7 +134,7 @@ async def test_predict_without_verbose(self, agent_with_adapter):
     async def test_predict_with_verbose_logging(self, mock_logger, agent_with_adapter):
         """Test predict method with verbose logging (covers missing lines 100-116)."""
         observation = Observation(text="test", screenshot="screenshot")
-        agent_with_adapter.mock_responses = [([{"type": "click", "x": 100, "y": 200}], True)]
+        agent_with_adapter.mock_responses = [([{"type": "click", "x": 100, "y": 200}], True, None)]
 
         actions, done = await agent_with_adapter.predict(observation, verbose=True)
 
@@ -148,7 +151,7 @@ async def test_predict_without_adapter_returns_raw_actions(self, agent_without_a
         """Test predict without adapter returns raw actions."""
         observation = Observation(text="test", screenshot=None)
         raw_actions = [{"type": "click", "x": 100, "y": 200}]
-        agent_without_adapter.mock_responses = [(raw_actions, True)]
+        agent_without_adapter.mock_responses = [(raw_actions, True, None)]
 
         actions, done = await agent_without_adapter.predict(observation, verbose=False)
 
@@ -160,7 +163,7 @@ async def test_predict_without_adapter_returns_raw_actions(self, agent_without_a
     async def test_predict_with_empty_actions(self, agent_with_adapter):
         """Test predict when fetch_response returns empty actions."""
         observation = Observation(text="test", screenshot="screenshot")
-        agent_with_adapter.mock_responses = [([], True)]
+        agent_with_adapter.mock_responses = [([], True, None)]
 
         actions, done = await agent_with_adapter.predict(observation, verbose=False)
 
@@ -174,7 +177,8 @@ async def test_predict_full_pipeline(self, agent_with_adapter, mock_adapter):
         # Set up observation with screenshot that will be rescaled
         observation = Observation(text="test input", screenshot="original_screenshot")
         raw_actions = [{"type": "click", "x": 150, "y": 250}]
-        agent_with_adapter.mock_responses = [(raw_actions, False)]
+        sample_logs = ["Log message 1", {"key": "value"}]
+        agent_with_adapter.mock_responses = [(raw_actions, False, sample_logs)]
 
         actions, done = await agent_with_adapter.predict(observation, verbose=True)
 
@@ -183,7 +187,7 @@ async def test_predict_full_pipeline(self, agent_with_adapter, mock_adapter):
         mock_adapter.rescale.assert_called_once_with("original_screenshot")
 
         # Stage 3: Postprocessing
-        mock_adapter.adapt_list.assert_called_once_with(raw_actions)
+        mock_adapter.adapt_list.assert_called_once_with(raw_actions, sample_logs)
 
         assert len(actions) == 1
         assert isinstance(actions[0], ClickAction)
@@ -194,7 +198,7 @@ async def test_predict_integration_without_screenshot(self, agent_with_adapter):
         """Test predict integration when observation has no screenshot."""
         observation = Observation(text="test input", screenshot=None)
         raw_actions = [{"type": "response", "text": "Task completed"}]
-        agent_with_adapter.mock_responses = [(raw_actions, True)]
+        agent_with_adapter.mock_responses = [(raw_actions, True, None)]
 
         actions, done = await agent_with_adapter.predict(observation, verbose=False)
 
diff --git a/hud/env/environment.py b/hud/env/environment.py
index 6ab56e5ca..c00ec2fea 100644
--- a/hud/env/environment.py
+++ b/hud/env/environment.py
@@ -224,7 +224,11 @@ async def run(self, agent: Agent, max_steps: int = 27, verbose: bool = True) ->
         for i in range(max_steps):
             action, done = await agent.predict(obs, verbose=verbose)
             if verbose:
-                logger.info("Step %d: Action: %s", i, action)
+                logger.info(
+                    "Step %d: Action: %s",
+                    i,
+                    [str(a) for a in action] if len(action) > 1 else str(action[0]),
+                )
             obs, reward, terminated, info = await self.step(action)
             if verbose:
                 logger.info("Step %d: Observation: %s", i, obs)
diff --git a/hud/telemetry/exporter.py b/hud/telemetry/exporter.py
index 5a4d5028b..9b48158f1 100644
--- a/hud/telemetry/exporter.py
+++ b/hud/telemetry/exporter.py
@@ -298,12 +298,10 @@ async def _export_trace_payload_async(payload: dict[str, Any]) -> None:
         "telemetry": payload.get("mcp_calls", []),
     }
 
-    # Ensure mcp_calls is not empty if that's a requirement, or send as is. For now, send as is.
-    # if not data_to_send["mcp_calls"]:
-    #     logger.debug("No MCP calls in payload for task run %s, skipping specific export if "
-    #                  "desired.", task_run_id)
-    #     # Depending on backend, might not want to send empty mcp_calls list, or it's fine.
+    await send_telemetry_to_server(task_run_id, data_to_send)
 
+
+async def send_telemetry_to_server(task_run_id: str, data: dict[str, Any]) -> None:
     telemetry_url = f"{settings.base_url}/v2/task_runs/{task_run_id}/telemetry-upload"
 
     try:
@@ -320,7 +318,7 @@ async def _export_trace_payload_async(payload: dict[str, Any]) -> None:
             )
             response = await client.post(
                 telemetry_url,
-                json=data_to_send,  # Send the structured attributes and mcp_calls
+                json=data,  # Send the structured attributes and mcp_calls
                 headers=headers,
                 timeout=30.0,
             )
diff --git a/hud/tests/test_taskset.py b/hud/tests/test_taskset.py
new file mode 100644
index 000000000..d38718f6a
--- /dev/null
+++ b/hud/tests/test_taskset.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from hud.task import Task
+from hud.taskset import TaskSet, load_taskset
+
+
+@pytest.fixture
+def sample_tasks():
+    """Create sample tasks for testing."""
+    return [
+        Task(
+            id="task-1",
+            prompt="Test task 1",
+            gym="hud-browser",
+            setup=("goto", "https://example.com"),
+            evaluate=("page_contains", "Example"),
+        ),
+        Task(id="task-2", prompt="Test task 2", gym="qa", evaluate=("response_includes", "answer")),
+        Task(
+            id="task-3",
+            prompt="Test task 3 with metadata",
+            gym="hud-browser",
+            metadata={"difficulty": "easy", "category": "navigation"},
+        ),
+    ]
+
+
+@pytest.fixture
+def sample_taskset(sample_tasks):
+    """Create a sample TaskSet."""
+    return TaskSet(
+        id="test-taskset-123", description="Test TaskSet for unit tests", tasks=sample_tasks
+    )
+
+
+def test_taskset_creation(sample_tasks):
+    """Test TaskSet initialization and properties."""
+    taskset = TaskSet(description="My test set", tasks=sample_tasks)
+
+    assert taskset.description == "My test set"
+    assert len(taskset.tasks) == 3
+    assert taskset.id is None  # No ID when created locally
+
+    # Test with ID
+    taskset_with_id = TaskSet(id="custom-id", description="Test", tasks=sample_tasks[:1])
+    assert taskset_with_id.id == "custom-id"
+
+
+def test_taskset_supports_integer_indexing(sample_taskset):
+    """TaskSet should support accessing tasks by integer index."""
+    # Positive indexing
+    assert sample_taskset[0].id == "task-1"
+    assert sample_taskset[1].id == "task-2"
+    assert sample_taskset[2].id == "task-3"
+
+    # Negative indexing
+    assert sample_taskset[-1].id == "task-3"
+    assert sample_taskset[-2].id == "task-2"
+
+    # Out of bounds should raise IndexError
+    with pytest.raises(IndexError):
+        _ = sample_taskset[10]
+    with pytest.raises(IndexError):
+        _ = sample_taskset[-10]
+
+
+def test_taskset_length_returns_number_of_tasks(sample_taskset):
+    """len(TaskSet) should return the number of tasks."""
+    assert len(sample_taskset) == 3
+
+    # Empty taskset
+    empty = TaskSet(tasks=[])
+    assert len(empty) == 0
+
+    # Single task
+    single = TaskSet(tasks=[sample_taskset[0]])
+    assert len(single) == 1
+
+
+@pytest.mark.asyncio
+async def test_taskset_upload_posts_tasks_to_api(sample_taskset, mocker):
+    """TaskSet.upload should POST tasks to the platform API."""
+    # Arrange
+    expected_response = {"id": "uploaded-taskset-456"}
+    mock_make_request = mocker.patch("hud.taskset.make_request", new_callable=AsyncMock)
+    mock_make_request.return_value = expected_response
+
+    # Act
+    await sample_taskset.upload(
+        name="My Evaluation Set", description="Testing upload functionality", api_key="test-api-key"
+    )
+
+    # Assert API was called correctly
+    mock_make_request.assert_called_once()
+    call_kwargs = mock_make_request.call_args[1]
+
+    assert call_kwargs["method"] == "POST"
+    assert "tasksets" in call_kwargs["url"]
+    assert call_kwargs["api_key"] == "test-api-key"
+
+    # Verify request payload structure
+    payload = call_kwargs["json"]
+    assert payload["name"] == "My Evaluation Set"
+    assert payload["description"] == "Testing upload functionality"
+    assert len(payload["tasks"]) == 3
+
+    # Verify task data is included
+    first_task = payload["tasks"][0]
+    assert first_task["prompt"] == "Test task 1"
+    assert first_task["gym"] == "hud-browser"
+
+
+@pytest.mark.asyncio
+async def test_load_taskset_fetches_and_deserializes_tasks(mocker):
+    """load_taskset should fetch tasks from API and create TaskSet object."""
+    # Arrange - realistic API response
+    api_response = {
+        "evalset": [
+            {
+                "id": "task-1",
+                "prompt": "Navigate to checkout",
+                "gym": "hud-browser",
+                "setup": {"function": "goto", "args": ["https://shop.example.com"]},
+                "evaluate": {"function": "page_contains", "args": ["Checkout"]},
+            },
+            {
+                "id": "task-2",
+                "prompt": "Answer the question",
+                "gym": "qa",
+                "evaluate": {"function": "response_includes", "args": ["42"]},
+            },
+        ]
+    }
+
+    mock_make_request = mocker.patch("hud.taskset.make_request", new_callable=AsyncMock)
+    mock_make_request.return_value = api_response
+
+    # Act
+    loaded_taskset = await load_taskset("test-taskset-id", api_key="test-key")
+
+    # Assert
+    assert isinstance(loaded_taskset, TaskSet)
+    assert loaded_taskset.id == "test-taskset-id"
+    assert len(loaded_taskset) == 2
+
+    # Verify task details
+    task1 = loaded_taskset[0]
+    assert task1.prompt == "Navigate to checkout"
+    assert task1.gym == "hud-browser"
+    # Check setup exists (don't assume specific type)
+    assert task1.setup is not None
+
+    task2 = loaded_taskset[1]
+    assert task2.prompt == "Answer the question"
+    # Check evaluate exists
+    assert task2.evaluate is not None
+
+    # Verify API call
+    mock_make_request.assert_called_once()
+    call_kwargs = mock_make_request.call_args[1]
+    assert "tasksets/test-taskset-id/tasks" in call_kwargs["url"]
+    assert call_kwargs["api_key"] == "test-key"
+
+
+@pytest.mark.asyncio
+async def test_load_taskset_propagates_api_errors(mocker):
+    """load_taskset should propagate API errors to caller."""
+    # Arrange
+    mock_make_request = mocker.patch("hud.taskset.make_request", new_callable=AsyncMock)
+    mock_make_request.side_effect = ValueError("Invalid taskset ID")
+
+    # Act & Assert
+    with pytest.raises(ValueError, match="Invalid taskset ID"):
+        await load_taskset("invalid-id", api_key="test-key")
+
+
+def test_taskset_initializes_with_minimal_configuration():
+    """TaskSet should work with just a list of tasks."""
+    task = Task(prompt="Simple task")
+    taskset = TaskSet(tasks=[task])
+
+    assert len(taskset) == 1
+    assert taskset[0].prompt == "Simple task"
+    assert taskset.id is None
+    assert taskset.description is None
+
+
+def test_taskset_with_mixed_gym_types(sample_tasks):
+    """Test TaskSet with different gym types."""
+    # Add a custom gym task
+    from pathlib import Path
+
+    from hud.types import CustomGym
+
+    custom_task = Task(
+        prompt="Custom environment task",
+        gym=CustomGym(location="local", image_or_build_context=Path("/test/path")),
+        evaluate=("custom_eval", "arg"),
+    )
+
+    mixed_tasks = [*sample_tasks, custom_task]
+    taskset = TaskSet(tasks=mixed_tasks)
+
+    assert len(taskset) == 4
+    # Verify the custom gym task is properly included
+    assert isinstance(taskset[3].gym, CustomGym)
diff --git a/hud/trajectory.py b/hud/trajectory.py
index f3e71a68b..f693ee73f 100644
--- a/hud/trajectory.py
+++ b/hud/trajectory.py
@@ -6,6 +6,8 @@
 from IPython.display import HTML, Markdown, display
 from pydantic import BaseModel, Field
 
+from .adapters.common.types import LogType
+
 
 class TrajectoryStep(BaseModel):
     """Model representing a single task run's trajectory information."""
@@ -13,6 +15,7 @@ class TrajectoryStep(BaseModel):
     observation_url: str | None = None
     observation_text: str | None = None
     actions: list[dict]
+    logs: LogType | None = None
     start_timestamp: str | None = None
     end_timestamp: str | None = None
 
diff --git a/hud/utils/common.py b/hud/utils/common.py
index abe100206..d86ecc8b1 100644
--- a/hud/utils/common.py
+++ b/hud/utils/common.py
@@ -67,8 +67,8 @@ class Observation(BaseModel):
 
     def __str__(self) -> str:
         return f"""Observation(screenshot={
-            self.screenshot[:100] if self.screenshot else "None"
-        }..., text={self.text}...)"""
+            f"{self.screenshot[:100]}..." if self.screenshot else "None"
+        }, text={f"{self.text[:100]}..." if self.text else "None"})"""
 
 
 class ExecuteResult(TypedDict):
diff --git a/hud/utils/tests/test_common.py b/hud/utils/tests/test_common.py
index f7a806d0f..1eae1b9f9 100644
--- a/hud/utils/tests/test_common.py
+++ b/hud/utils/tests/test_common.py
@@ -50,3 +50,228 @@ async def test_get_gym_id(mocker: pytest_mock.MockerFixture):
     mocker.patch("hud.utils.common.make_request", return_value={"id": "test_gym_id"})
     gym_id = await get_gym_id("test_gym")
     assert gym_id == "test_gym_id"
+
+
+def test_function_config_stores_function_name_args_and_optional_id():
+    """FunctionConfig should store function name, args list, and optional id."""
+    from hud.utils.common import FunctionConfig
+
+    # Minimal config
+    minimal = FunctionConfig(function="test_func", args=[])
+    assert minimal.function == "test_func"
+    assert minimal.args == []
+    assert minimal.id is None
+
+    # With args
+    with_args = FunctionConfig(function="navigate", args=["https://example.com", {"wait": True}])
+    assert with_args.function == "navigate"
+    assert len(with_args.args) == 2
+    assert with_args.args[0] == "https://example.com"
+    assert with_args.args[1] == {"wait": True}
+
+    # With id
+    with_id = FunctionConfig(
+        function="complex_operation",
+        args=[42, "test", {"nested": {"key": "value"}}],
+        id="op_123",
+    )
+    assert with_id.function == "complex_operation"
+    assert len(with_id.args) == 3
+    assert with_id.id == "op_123"
+
+
+@pytest.mark.asyncio
+async def test_get_gym_id_fetches_id_from_api_response(
+    mocker: pytest_mock.MockerFixture,
+):
+    """get_gym_id should extract 'id' field from API response."""
+    # Arrange
+    api_response = {"id": "gym-123", "name": "Test Gym", "status": "active"}
+    mocker.patch("hud.utils.common.make_request", return_value=api_response)
+
+    # Act
+    gym_id = await get_gym_id("test_gym")
+
+    # Assert
+    assert gym_id == "gym-123"
+
+
+@pytest.mark.asyncio
+async def test_get_gym_id_propagates_network_errors(mocker: pytest_mock.MockerFixture):
+    """get_gym_id should propagate exceptions from make_request."""
+    # Arrange
+    mocker.patch("hud.utils.common.make_request", side_effect=ConnectionError("API unavailable"))
+
+    # Act & Assert
+    with pytest.raises(ConnectionError, match="API unavailable"):
+        await get_gym_id("test_gym")
+
+
+@pytest.mark.asyncio
+async def test_get_gym_id_raises_key_error_when_id_missing(
+    mocker: pytest_mock.MockerFixture,
+):
+    """get_gym_id should raise KeyError when response lacks 'id' field."""
+    # Arrange
+    incomplete_response = {"name": "Test Gym", "status": "active"}  # Missing 'id'
+    mocker.patch("hud.utils.common.make_request", return_value=incomplete_response)
+
+    # Act & Assert
+    with pytest.raises(KeyError):
+        await get_gym_id("test_gym")
+
+
+def test_directory_to_tar_bytes_creates_valid_tar_archive(
+    tmpdir_factory: pytest.TempdirFactory,
+):
+    """directory_to_tar_bytes should create a valid tar archive containing all files."""
+    # Arrange
+    temp_dir = tmpdir_factory.mktemp("test_archive")
+    temp_dir_path = Path(temp_dir)
+
+    # Create test structure
+    (temp_dir_path / "file1.txt").write_text("content1")
+    (temp_dir_path / "file2.py").write_text("import os\nprint('hello')")
+
+    subdir = temp_dir_path / "subdir"
+    subdir.mkdir()
+    (subdir / "nested.json").write_text('{"key": "value"}')
+
+    # Act
+    tar_bytes = directory_to_tar_bytes(temp_dir_path)
+
+    # Assert
+    assert isinstance(tar_bytes, bytes)
+    assert len(tar_bytes) > 0
+
+    # Verify contents
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        members = {m.name for m in tar.getmembers()}
+        assert "file1.txt" in members
+        assert "file2.py" in members
+        assert "subdir/nested.json" in members
+
+        # Verify file contents
+        content = tar.extractfile("file1.txt")
+        assert content is not None
+        assert content.read().decode() == "content1"
+
+
+def test_directory_to_tar_bytes_handles_empty_directory(
+    tmpdir_factory: pytest.TempdirFactory,
+):
+    """directory_to_tar_bytes should handle empty directories gracefully."""
+    # Arrange
+    empty_dir = tmpdir_factory.mktemp("empty")
+    empty_dir_path = Path(empty_dir)
+
+    # Act
+    tar_bytes = directory_to_tar_bytes(empty_dir_path)
+
+    # Assert
+    assert isinstance(tar_bytes, bytes)
+    assert len(tar_bytes) > 0  # Even empty tar has headers
+
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        members = tar.getmembers()
+        # May contain the directory itself or be completely empty
+        assert len(members) >= 0
+
+
+def test_directory_to_tar_bytes_preserves_directory_structure(
+    tmpdir_factory: pytest.TempdirFactory,
+):
+    """directory_to_tar_bytes should preserve nested directory structure."""
+    # Arrange
+    root = tmpdir_factory.mktemp("root")
+    root_path = Path(root)
+
+    # Create nested structure
+    (root_path / "a" / "b" / "c").mkdir(parents=True)
+    (root_path / "a" / "file1.txt").write_text("in a")
+    (root_path / "a" / "b" / "file2.txt").write_text("in b")
+    (root_path / "a" / "b" / "c" / "file3.txt").write_text("in c")
+
+    # Act
+    tar_bytes = directory_to_tar_bytes(root_path)
+
+    # Assert
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        members = {m.name for m in tar.getmembers()}
+        assert "a/file1.txt" in members
+        assert "a/b/file2.txt" in members
+        assert "a/b/c/file3.txt" in members
+
+
+def test_directory_to_tar_bytes_with_exclusions(tmpdir_factory: pytest.TempdirFactory):
+    """Test directory_to_tar_bytes with files to exclude."""
+    temp_dir = tmpdir_factory.mktemp("test_exclude_dir")
+    temp_dir_path = Path(temp_dir)
+
+    # Create various files
+    (temp_dir_path / "include_me.txt").write_text("include")
+    (temp_dir_path / ".git").mkdir()
+    (temp_dir_path / ".git" / "config").write_text("git config")
+    (temp_dir_path / "__pycache__").mkdir()
+    (temp_dir_path / "__pycache__" / "module.pyc").write_bytes(b"pyc content")
+    (temp_dir_path / "normal_dir").mkdir()
+    (temp_dir_path / "normal_dir" / "file.py").write_text("python code")
+
+    tar_bytes = directory_to_tar_bytes(temp_dir_path)
+
+    # Check contents
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        member_names = {m.name for m in tar.getmembers()}
+
+        # Should include regular files and directories
+        assert "include_me.txt" in member_names
+        assert "normal_dir/file.py" in member_names
+
+        # Implementation might exclude common patterns like .git and __pycache__
+        # This depends on the actual implementation
+
+
+def test_directory_to_tar_bytes_empty_directory(tmpdir_factory: pytest.TempdirFactory):
+    """Test directory_to_tar_bytes with empty directory."""
+    temp_dir = tmpdir_factory.mktemp("empty_dir")
+    temp_dir_path = Path(temp_dir)
+
+    tar_bytes = directory_to_tar_bytes(temp_dir_path)
+
+    # Should still create a valid tar even if empty
+    assert tar_bytes is not None
+    assert len(tar_bytes) > 0
+
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        members = tar.getmembers()
+        # Might be empty or contain just the root directory
+        assert len(members) >= 0
+
+
+def test_directory_to_tar_bytes_symlinks(tmpdir_factory: pytest.TempdirFactory):
+    """Test directory_to_tar_bytes with symbolic links."""
+    temp_dir = tmpdir_factory.mktemp("symlink_dir")
+    temp_dir_path = Path(temp_dir)
+
+    # Create a file and a symlink to it
+    target_file = temp_dir_path / "target.txt"
+    target_file.write_text("target content")
+
+    symlink = temp_dir_path / "link_to_target.txt"
+    try:
+        symlink.symlink_to(target_file)
+        has_symlink = True
+    except OSError:
+        # Symlinks might not be supported on all systems (e.g., Windows without admin)
+        has_symlink = False
+
+    tar_bytes = directory_to_tar_bytes(temp_dir_path)
+
+    with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as tar:
+        members = {m.name: m for m in tar.getmembers()}
+
+        assert "target.txt" in members
+
+        if has_symlink:
+            # Check how symlinks are handled (might be followed or preserved)
+            assert "link_to_target.txt" in members


Reviews:
user: login: Parth220
body: Nice this looks good. 

I left a few questions, which would be great if you address. 

If you feel confident, ship it :shipit: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T23:09:22Z

Review comments:
diff_hunk: @@ -161,8 +163,9 @@ def adapt(self, action: Any) -> CLA:
         # convert back to CLA
         return TypeAdapter(CLA).validate_python(rescaled_action)
 
-    def adapt_list(self, actions: list[Any]) -> list[CLA]:
+    def adapt_list(self, actions: list[Any], logs: list[str | dict[str, Any]] | None) -> list[CLA]:
path: hud/adapters/common/adapter.py
user: login: Parth220
body: Can we make this a standard type that we can reference again
created_at: 2025-05-29T23:03:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -161,8 +163,9 @@ def adapt(self, action: Any) -> CLA:
         # convert back to CLA
         return TypeAdapter(CLA).validate_python(rescaled_action)
 
-    def adapt_list(self, actions: list[Any]) -> list[CLA]:
+    def adapt_list(self, actions: list[Any], logs: list[str | dict[str, Any]] | None) -> list[CLA]:
         if not isinstance(actions, list):
             raise ValueError("Please provide a list of actions")
-
-        return [self.adapt(action) for action in actions]
+        if logs is None or len(logs) != len(actions):
path: hud/adapters/common/adapter.py
user: login: Parth220
body: Question:

Why can't the agent send 5 actions and 1 log?

Can't a single output contain 5 actions?

Are there not any scenerios where this true?
created_at: 2025-05-29T23:04:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,34 +46,68 @@ class ClickAction(CLAAction):
     type: Literal["click"] = "click"
     point: Point | None = None
     button: CLAButton = "left"
-    pattern: list[int] | None = None  # [delay_1, delay_2, ...]
+    pattern: list[int] | None = None
     hold_keys: list[CLAKey] | None = None
 
+    def __str__(self) -> str:
+        parts = ["💥 Click"]
+        if self.point:
+            parts.append(f"at ({self.point.x}, {self.point.y})")
+        if self.button != "left":
+            parts.append(f"with {self.button} button")
+        if self.hold_keys:
+            parts.append(f"holding {self.hold_keys}")
+        action_str = " ".join(parts)
+        action_str += _format_logs_for_display(self.logs)
+        return action_str
+
 
 # PRESS ACTION for key presses/hotkeys
 class PressAction(CLAAction):
     type: Literal["press"] = "press"
     keys: list[CLAKey]
 
+    def __str__(self) -> str:
+        action_str = f"🎹 Press keys: {'+'.join(self.keys)}"
path: hud/adapters/common/types.py
user: login: Parth220
body: Are we emoji-maxxing now?

lmao okay let's have some fun
created_at: 2025-05-29T23:06:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: promptless[bot]
created_at: 2025-05-29T20:03:53Z
body: ✅ No documentation updates required.
author_association: CONTRIBUTOR
user: login: Parth220
created_at: 2025-05-29T23:09:55Z
body: Also there's 12 tests failing, which should be revisited @lorenss-m 
author_association: CONTRIBUTOR