Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/minisweagent/agents/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
19 changes: 18 additions & 1 deletion src/minisweagent/models/litellm_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
23 changes: 23 additions & 0 deletions src/minisweagent/utils/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
56 changes: 55 additions & 1 deletion tests/utils/test_serialize.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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"