From 67ad5fd06926bc21e14e7fd167b6cf0ab192d1e4 Mon Sep 17 00:00:00 2001 From: snoopuppy582 Date: Tue, 12 May 2026 23:33:16 +0900 Subject: [PATCH] fix: validate trace item object shapes --- src/agent_harness/trace.py | 11 ++++++++++ tests/test_adapters.py | 19 +++++++++++++++++- tests/test_trace.py | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/test_trace.py diff --git a/src/agent_harness/trace.py b/src/agent_harness/trace.py index 4321b0d..c5382fd 100644 --- a/src/agent_harness/trace.py +++ b/src/agent_harness/trace.py @@ -11,6 +11,13 @@ class TraceValidationError(ValueError): """Raised when a trace file is invalid""" +def _validate_object_items(field_name: str, items: list[Any]) -> None: + """Validate that every trace list item is an object.""" + for index, item in enumerate(items): + if not isinstance(item, dict): + raise TraceValidationError(f"{field_name}[{index}] must be an object") + + @dataclass(frozen=True) class Trace: """Execution trace captured during a scenario run.""" @@ -37,6 +44,10 @@ def from_dict(cls, data: Any) -> "Trace": if not isinstance(events, list): raise TraceValidationError("events must be a list") + + _validate_object_items("messages", messages) + _validate_object_items("tool_calls", tool_calls) + _validate_object_items("events", events) return cls(messages=messages, tool_calls=tool_calls, events=events) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 700b283..30edaac 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -126,6 +126,23 @@ def malformed_agent(payload): run_python_callable_target(scenario, malformed_agent) +def test_python_callable_wraps_invalid_trace_item_shape(): + scenario = make_scenario(assertions=[]) + + def malformed_agent(payload): + return { + "messages": ["not an object"], + "tool_calls": [], + "events": [], + } + + with pytest.raises( + AdapterError, + match=r"Python callable returned invalid trace: messages\[0\] must be an object", + ): + run_python_callable_target(scenario, malformed_agent) + + def test_load_python_callable_loads_valid_module_function(tmp_path, monkeypatch): module_name = write_fake_target_module(tmp_path, monkeypatch) @@ -165,4 +182,4 @@ def test_load_python_callable_rejects_non_callable(tmp_path, monkeypatch): module_name = write_fake_target_module(tmp_path, monkeypatch) with pytest.raises(AdapterError, match="is not callable"): - load_python_callable(f"{module_name}:NON_CALLABLE") \ No newline at end of file + load_python_callable(f"{module_name}:NON_CALLABLE") diff --git a/tests/test_trace.py b/tests/test_trace.py new file mode 100644 index 0000000..de54a1e --- /dev/null +++ b/tests/test_trace.py @@ -0,0 +1,41 @@ +"""Tests for trace validation.""" + +from __future__ import annotations + +import pytest + +from agent_harness.trace import Trace, TraceValidationError + + +def test_trace_from_dict_accepts_object_items(): + trace = Trace.from_dict( + { + "messages": [{"role": "assistant", "content": "ok"}], + "tool_calls": [{"name": "search", "arguments": {"q": "owasp"}}], + "events": [{"type": "goal", "id": "summarize_document"}], + } + ) + + assert trace.messages == [{"role": "assistant", "content": "ok"}] + assert trace.tool_calls == [{"name": "search", "arguments": {"q": "owasp"}}] + assert trace.events == [{"type": "goal", "id": "summarize_document"}] + + +@pytest.mark.parametrize( + ("field_name", "bad_item"), + [ + ("messages", "not an object"), + ("tool_calls", ["not", "an", "object"]), + ("events", 123), + ], +) +def test_trace_from_dict_rejects_non_object_items(field_name, bad_item): + data = { + "messages": [], + "tool_calls": [], + "events": [], + } + data[field_name] = [{}, bad_item] + + with pytest.raises(TraceValidationError, match=rf"{field_name}\[1\] must be an object"): + Trace.from_dict(data)