Skip to content
Merged
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
11 changes: 11 additions & 0 deletions src/agent_harness/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)

Expand Down
19 changes: 18 additions & 1 deletion tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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")
load_python_callable(f"{module_name}:NON_CALLABLE")
41 changes: 41 additions & 0 deletions tests/test_trace.py
Original file line number Diff line number Diff line change
@@ -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)
Loading