Skip to content

Commit 5e87594

Browse files
declan-scaleclaude
andcommitted
fix(harness): coerce non-dict span input/output so spans aren't dropped
The SGP spans API requires a span's input/output to be an object: a scalar or string payload is rejected with a 422 ("Input should be a valid dictionary") and the async processor drops the span. The reasoning-span fix records the chain-of-thought as a plain-string output, so reasoning spans were silently dropped — observed as "0 reasoning traces" in the golden agent while tool spans (dict output) survived. Some harnesses' tool results are also plain strings and would hit the same 422. Coerce at the tracer boundary: wrap any non-dict input/output under a single key ({"output": ...} / {"input": ...}); dicts and None pass through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2d66f9b commit 5e87594

4 files changed

Lines changed: 47 additions & 5 deletions

File tree

src/agentex/lib/core/harness/tracer.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616
logger = logging.getLogger(__name__)
1717

1818

19+
def _as_span_payload(value: Any, *, key: str) -> Any:
20+
"""Coerce a span input/output payload into a dict.
21+
22+
The SGP spans API requires ``input`` and ``output`` to be objects: a scalar
23+
or string is rejected with a 422 and the span is dropped by the async
24+
processor. The SpanDeriver legitimately produces non-dict payloads — the
25+
reasoning span's output is the chain-of-thought string, and some harnesses'
26+
tool results are plain strings — so wrap anything that isn't already a dict
27+
(``None`` passes through unchanged so an absent payload stays absent).
28+
"""
29+
if value is None or isinstance(value, dict):
30+
return value
31+
return {key: value}
32+
33+
1934
class SpanTracer:
2035
"""Opens/closes adk.tracing child spans in response to span signals.
2136
@@ -60,7 +75,7 @@ async def handle(self, signal: SpanSignal) -> None:
6075
span = await self._tracing.start_span(
6176
trace_id=self.trace_id,
6277
name=signal.name,
63-
input=signal.input,
78+
input=_as_span_payload(signal.input, key="input"),
6479
parent_id=self.parent_span_id,
6580
task_id=self.task_id,
6681
)
@@ -73,7 +88,7 @@ async def handle(self, signal: SpanSignal) -> None:
7388
# The real TracingModule.end_span signature is:
7489
# end_span(trace_id, span, start_to_close_timeout, heartbeat_timeout, retry_policy)
7590
# It does not accept an output= kwarg.
76-
span.output = signal.output
91+
span.output = _as_span_payload(signal.output, key="output")
7792
# Tool failure status (ToolResponseContent.is_error) is recorded
7893
# on span.data when the harness reports one; Span has no dedicated
7994
# error field. None means no status was reported, so leave data alone.

tests/lib/core/harness/test_auto_send.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ async def test_auto_send_derives_tool_spans_via_tracer():
218218

219219
assert result.final_text == ""
220220
assert fake_tracing.started_names == ["Bash"]
221-
assert fake_tracing.ended_outputs == ["ok"]
221+
# String tool output is wrapped in a dict (SGP spans require an object).
222+
assert fake_tracing.ended_outputs == [{"output": "ok"}]
222223

223224

224225
# ---------------------------------------------------------------------------

tests/lib/core/harness/test_tracer.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,32 @@ async def test_open_then_close_starts_and_ends_span():
1515
await tracer.handle(OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"}))
1616
await tracer.handle(CloseSpan(key="call_1", output="files", is_complete=True))
1717
assert fake.started == [("Bash", "p1", {"cmd": "ls"})]
18-
assert fake.ended == [("Bash", "files")]
18+
# A plain-string output is wrapped in a dict (SGP spans require an object).
19+
assert fake.ended == [("Bash", {"output": "files"})]
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_non_dict_payloads_are_wrapped_in_a_dict():
24+
"""SGP spans reject scalar input/output with a 422; the tracer wraps any
25+
non-dict payload so reasoning spans (string output) are not dropped."""
26+
fake = FakeTracing()
27+
tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake)
28+
await tracer.handle(OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={}))
29+
await tracer.handle(CloseSpan(key="reasoning:0", output="chain of thought", is_complete=True))
30+
# Empty-dict input stays a dict; string output is wrapped.
31+
assert fake.started == [("reasoning", "p1", {})]
32+
assert fake.ended == [("reasoning", {"output": "chain of thought"})]
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_dict_and_none_payloads_pass_through_unchanged():
37+
fake = FakeTracing()
38+
tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake)
39+
await tracer.handle(OpenSpan(key="c", kind="tool", name="T", input={"a": 1}))
40+
await tracer.handle(CloseSpan(key="c", output={"result": "x"}, is_complete=True))
41+
await tracer.handle(OpenSpan(key="d", kind="tool", name="U", input={}))
42+
await tracer.handle(CloseSpan(key="d", output=None, is_complete=False))
43+
assert fake.ended == [("T", {"result": "x"}), ("U", None)]
1944

2045

2146
@pytest.mark.asyncio

tests/lib/core/harness/test_yield_delivery.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ async def test_yield_passes_events_through_and_traces():
4242
out = [e async for e in yield_events(_gen(events), tracer=tracer)]
4343
assert out == events # passthrough unchanged
4444
assert fake.started_names == ["Bash"] # span derived + opened
45-
assert fake.ended_outputs == ["ok"] # span closed with response
45+
# String tool output is wrapped in a dict (SGP spans require an object).
46+
assert fake.ended_outputs == [{"output": "ok"}] # span closed with response
4647

4748

4849
@pytest.mark.asyncio

0 commit comments

Comments
 (0)