Skip to content

Commit d3cf334

Browse files
committed
fix(claude-agents): put the input content write inside the guard too
The prompt write ran before the try that fails the root, so a raise while serialising it left the root open: never ended, never exported, so the run disappeared from AI Config Monitoring along with the feature_flag event it carries. Both paths had it. Two tests, one per path. Found by Bugbot on #34, which is this shape in langchain-messages.
1 parent e694ff1 commit d3cf334

2 files changed

Lines changed: 70 additions & 14 deletions

File tree

packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -422,13 +422,6 @@ async def _call_impl(
422422
config.get("tools"), th
423423
)
424424
catalog = ToolCatalog(config.get("tools"), _native_tool_aliases(th))
425-
set_input_content_attributes(
426-
span,
427-
capture_content,
428-
system_instructions=opening.system_instructions,
429-
messages=opening.messages,
430-
tool_definitions=catalog.current,
431-
)
432425

433426
# Declared out here so the except clause can end a chat span the throw left open.
434427
inference = InferenceSpans(config, parent, capture_content, catalog, opening)
@@ -438,6 +431,16 @@ async def _call_impl(
438431
root_usage_written = False
439432
gen: AsyncIterator[Any] | None = None
440433
try:
434+
# Inside the guard, because serialising the prompt raises on anything that is not
435+
# JSON-serialisable and a raise out here would leave the root open: never ended, never
436+
# exported, and the run gone from AI Config Monitoring with the feature_flag event on it.
437+
set_input_content_attributes(
438+
span,
439+
capture_content,
440+
system_instructions=opening.system_instructions,
441+
messages=opening.messages,
442+
tool_definitions=catalog.current,
443+
)
441444
tool_mcp = (
442445
await build_tool_mcp(user_config_tools, th)
443446
if user_config_tools
@@ -582,13 +585,6 @@ async def _stream_gen(
582585
config.get("tools"), tool_handlers
583586
)
584587
catalog = ToolCatalog(config.get("tools"), _native_tool_aliases(tool_handlers))
585-
set_input_content_attributes(
586-
span,
587-
capture_content,
588-
system_instructions=opening.system_instructions,
589-
messages=opening.messages,
590-
tool_definitions=catalog.current,
591-
)
592588

593589
inference = InferenceSpans(config, parent, capture_content, catalog, opening)
594590
tool_telemetry: ToolTelemetry | None = None
@@ -598,6 +594,16 @@ async def _stream_gen(
598594
gen: AsyncIterator[Any] | None = None
599595

600596
try:
597+
# Inside the guard, because serialising the prompt raises on anything that is not
598+
# JSON-serialisable. A raise out here would leave the root open with the `finally` never
599+
# entered, so the run would vanish from AI Config Monitoring with its feature_flag event.
600+
set_input_content_attributes(
601+
span,
602+
capture_content,
603+
system_instructions=opening.system_instructions,
604+
messages=opening.messages,
605+
tool_definitions=catalog.current,
606+
)
601607
tool_mcp = (
602608
await build_tool_mcp(user_config_tools, tool_handlers)
603609
if user_config_tools

packages/claude-agents/tests/test_handler.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,3 +1840,53 @@ async def _no_result(**_kwargs: Any) -> AsyncIterator[Any]:
18401840
attrs = root().attributes or {}
18411841
assert "gen_ai.usage.input_tokens" not in attrs
18421842
assert "gen_ai.usage.total_tokens" not in attrs
1843+
1844+
1845+
class TestInputWritesNeverLeakASpan:
1846+
"""Serialising the prompt must not be able to strand the root span.
1847+
1848+
The input content write ran before the guard that fails the root, so a raise there left it open:
1849+
never ended, never exported, so the run disappeared from AI Config Monitoring along with the
1850+
feature_flag event it carries.
1851+
"""
1852+
1853+
async def test_the_blocking_root_still_ends(
1854+
self, monkeypatch: pytest.MonkeyPatch
1855+
) -> None:
1856+
async def _q(**_kwargs: Any) -> AsyncIterator[Any]:
1857+
yield result_message("done")
1858+
1859+
monkeypatch.setattr(handler_mod, "query", _q)
1860+
monkeypatch.setattr(
1861+
handler_mod,
1862+
"set_input_content_attributes",
1863+
MagicMock(side_effect=TypeError("cannot serialise this prompt")),
1864+
)
1865+
with pytest.raises(TypeError):
1866+
await create_claude_agents_handler(capture_content=True)(
1867+
BASE_CONFIG, "q", {}, {}
1868+
)
1869+
1870+
assert root().end_time is not None, "the root span leaked"
1871+
assert root().status.status_code == StatusCode.ERROR
1872+
1873+
async def test_the_streaming_root_still_ends(
1874+
self, monkeypatch: pytest.MonkeyPatch
1875+
) -> None:
1876+
async def _q(**_kwargs: Any) -> AsyncIterator[Any]:
1877+
yield stream_event("chunk")
1878+
yield result_message("done")
1879+
1880+
monkeypatch.setattr(handler_mod, "query", _q)
1881+
monkeypatch.setattr(
1882+
handler_mod,
1883+
"set_input_content_attributes",
1884+
MagicMock(side_effect=TypeError("cannot serialise this prompt")),
1885+
)
1886+
with pytest.raises(TypeError):
1887+
async for _ in await create_claude_agents_handler(
1888+
capture_content=True
1889+
).stream(BASE_CONFIG, "q", {}, {}):
1890+
pass
1891+
1892+
assert root().end_time is not None, "the root span leaked"

0 commit comments

Comments
 (0)