diff --git a/src/minisweagent/agents/default.py b/src/minisweagent/agents/default.py index da54249a8..d70dbdfb8 100644 --- a/src/minisweagent/agents/default.py +++ b/src/minisweagent/agents/default.py @@ -12,7 +12,7 @@ from minisweagent import Environment, Model, __version__ from minisweagent.exceptions import InterruptAgentFlow, LimitsExceeded -from minisweagent.utils.serialize import recursive_merge +from minisweagent.utils.serialize import recursive_merge, to_jsonable class AgentConfig(BaseModel): @@ -151,5 +151,5 @@ def save(self, path: Path | None, *extra_dicts) -> dict: data = self.serialize(*extra_dicts) if path: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2)) + path.write_text(json.dumps(to_jsonable(data), indent=2)) return data diff --git a/src/minisweagent/models/litellm_model.py b/src/minisweagent/models/litellm_model.py index b40bea46c..2a7d603a8 100644 --- a/src/minisweagent/models/litellm_model.py +++ b/src/minisweagent/models/litellm_model.py @@ -9,6 +9,7 @@ import litellm from pydantic import BaseModel +from minisweagent.exceptions import FormatError from minisweagent.models import GLOBAL_MODEL_STATS from minisweagent.models.utils.actions_toolcall import ( BASH_TOOL, @@ -84,8 +85,24 @@ def query(self, messages: list[dict[str, str]], **kwargs) -> dict: cost_output = self._calculate_cost(response) GLOBAL_MODEL_STATS.add(cost_output["cost"]) message = response.choices[0].message.model_dump() + try: + actions = self._parse_actions(response) + except FormatError as e: + # Preserve raw assistant response for debugging (appears in live + final trajectories) + debug_message = { + "role": "assistant", + "content": message.get("content"), + "tool_calls": message.get("tool_calls"), + "extra": { + "parse_error": True, + "response": response.model_dump(), + **cost_output, + "timestamp": time.time(), + }, + } + raise FormatError(debug_message, *e.messages) from e message["extra"] = { - "actions": self._parse_actions(response), + "actions": actions, "response": response.model_dump(), **cost_output, "timestamp": time.time(), diff --git a/src/minisweagent/utils/serialize.py b/src/minisweagent/utils/serialize.py index e5280d999..987b53aaa 100644 --- a/src/minisweagent/utils/serialize.py +++ b/src/minisweagent/utils/serialize.py @@ -24,3 +24,26 @@ def recursive_merge(*dictionaries: dict | None) -> dict: else: result[key] = value return result + + +def to_jsonable(value: Any) -> Any: + """Convert values to something JSON-serializable for logging.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, (bytes, bytearray)): + return value.decode("utf-8", errors="replace") + if isinstance(value, dict): + return {str(k): to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [to_jsonable(v) for v in value] + if hasattr(value, "model_dump"): + try: + return to_jsonable(value.model_dump()) + except Exception: + pass + if hasattr(value, "__dict__"): + try: + return to_jsonable(vars(value)) + except Exception: + pass + return str(value) diff --git a/tests/utils/test_serialize.py b/tests/utils/test_serialize.py index d058d01a4..943ff661d 100644 --- a/tests/utils/test_serialize.py +++ b/tests/utils/test_serialize.py @@ -1,4 +1,4 @@ -from minisweagent.utils.serialize import UNSET, recursive_merge +from minisweagent.utils.serialize import UNSET, recursive_merge, to_jsonable def test_empty_input(): @@ -136,3 +136,57 @@ def test_unset_values_skipped(): {"a": {"x": 1, "y": 2}}, {"a": {"y": UNSET, "z": 3}, "b": UNSET, "c": 4}, ) == {"a": {"x": 1, "y": 2, "z": 3}, "c": 4} + + +def test_to_jsonable_basic_types(): + """Test that JSON-native types pass through unchanged.""" + assert to_jsonable(None) is None + assert to_jsonable("value") == "value" + assert to_jsonable(7) == 7 + assert to_jsonable(3.5) == 3.5 + assert to_jsonable(True) is True + + +def test_to_jsonable_bytes_and_collections(): + """Test conversion of bytes and collection types.""" + assert to_jsonable(b"hello") == "hello" + assert to_jsonable(bytearray(b"world")) == "world" + assert to_jsonable({1: "a", "b": 2}) == {"1": "a", "b": 2} + assert to_jsonable([1, 2, 3]) == [1, 2, 3] + assert to_jsonable((1, 2)) == [1, 2] + assert to_jsonable({3, 4}) in ([3, 4], [4, 3]) + + +def test_to_jsonable_model_dump(): + """Test that objects with model_dump are serialized via model_dump.""" + + class DummyModel: + def model_dump(self): + return {"key": "value", "nested": {"num": 1}} + + assert to_jsonable(DummyModel()) == {"key": "value", "nested": {"num": 1}} + + +def test_to_jsonable_dict_fallback(): + """Test fallback to __dict__ when model_dump fails.""" + + class Dummy: + def __init__(self): + self.value = 42 + + def model_dump(self): + raise RuntimeError("boom") + + assert to_jsonable(Dummy()) == {"value": 42} + + +def test_to_jsonable_string_fallback(): + """Test fallback to string for objects without __dict__.""" + + class SlotOnly: + __slots__ = () + + def __str__(self) -> str: + return "slot-only" + + assert to_jsonable(SlotOnly()) == "slot-only"