Skip to content

Commit e0d022a

Browse files
fix(eval): close judge lifecycle warnings
1 parent 2ea6d22 commit e0d022a

12 files changed

Lines changed: 303 additions & 19 deletions

File tree

examples/optimization/eval_optimize_loop/README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,15 @@ a harmless explanation rewrite does not zero out an otherwise correct route.
111111

112112
`environment_snapshot` records the git commit, dirty flag, Python version, SDK
113113
version when installed, model name, redacted base URL host, seed, command, and
114-
optimizer config path. It never records API keys. Provider/runtime warnings are
115-
not globally suppressed: an expected DeepSeek response-schema downgrade remains
116-
observable as a provider compatibility warning.
117-
The pipeline awaits `Runner.close()` on its own execution paths. Even so,
118-
upstream OpenAI/httpx may still emit Python 3.13 stream-shutdown diagnostics.
119-
Those warnings remain observable rather than being suppressed or automatically
120-
attributed to this example.
114+
optimizer config path. It never records API keys. When a judge provider does
115+
not support native JSON schemas, the judge uses JSON-object mode and validates
116+
the response locally instead of sending an ignored schema.
117+
Provider and runtime warnings are not globally suppressed. Judge calls run
118+
non-streaming and explicitly close their agent run so normal online evaluation
119+
does not leave OpenAI/httpx stream-shutdown diagnostics behind.
120+
The pipeline awaits `Runner.close()` for its agent execution paths.
121+
If an upstream OpenAI/httpx diagnostic still occurs outside this lifecycle,
122+
warnings remain observable rather than being filtered.
121123

122124
Report error text redacts provider URLs, configured provider credentials,
123125
standalone provider-key shapes, and semantic credential markers while retaining

tests/agents/core/test_llm_processor.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from unittest.mock import Mock
1313

1414
import pytest
15+
from unittest.mock import patch
1516

1617
from trpc_agent_sdk.agents._base_agent import BaseAgent
1718
from trpc_agent_sdk.agents.core._llm_processor import LlmProcessor
@@ -23,6 +24,8 @@
2324

2425

2526
class _StubAgent(BaseAgent):
27+
instruction: str = ""
28+
2629
async def _run_async_impl(self, ctx):
2730
yield
2831

@@ -159,6 +162,34 @@ def test_event_without_content_skips_planning(self, model, invocation_context):
159162

160163

161164
class TestCallLlmAsync:
165+
166+
@pytest.mark.asyncio
167+
async def test_non_streaming_drains_model_before_yielding_event(self, invocation_context):
168+
"""A non-streaming event is exposed only after the model generator is closed."""
169+
170+
class ClosingModel(MockLLMModel):
171+
172+
def __init__(self):
173+
super().__init__(model_name="test-llmproc-model")
174+
self.finished = False
175+
176+
async def _generate_async_impl(self, request, stream=False, ctx=None):
177+
try:
178+
yield LlmResponse(content=Content(parts=[Part(text="complete")]))
179+
finally:
180+
self.finished = True
181+
182+
model = ClosingModel()
183+
processor = LlmProcessor(model)
184+
event_stream = processor.call_llm_async(LlmRequest(), invocation_context, stream=False)
185+
186+
with patch("trpc_agent_sdk.agents.core._llm_processor.trace_call_llm"):
187+
event = await anext(event_stream)
188+
189+
assert event.content.parts[0].text == "complete"
190+
assert model.finished is True
191+
await event_stream.aclose()
192+
162193
def test_yields_events_for_responses(self, model, invocation_context):
163194
proc = LlmProcessor(model)
164195
request = LlmRequest()

tests/agents/test_llm_agent_ext.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,5 +409,37 @@ async def run():
409409
assert events[0].error_code == "build_error"
410410

411411

412+
class TestRunAsyncImplStreaming:
413+
414+
def test_disables_model_stream_when_run_config_disables_streaming(self, invocation_context):
415+
"""A non-streaming run must not create a provider SSE response."""
416+
417+
class RecordingModel(MockLLMModel):
418+
419+
def __init__(self):
420+
super().__init__(model_name="test-llm-ext-model")
421+
self.stream_args: list[bool] = []
422+
423+
async def _generate_async_impl(self, request, stream=False, ctx=None):
424+
self.stream_args.append(stream)
425+
yield LlmResponse(content=Content(parts=[Part(text="recorded")]))
426+
427+
model = RecordingModel()
428+
agent = _agent(model=model)
429+
invocation_context.agent = agent
430+
invocation_context.run_config = RunConfig(streaming=False)
431+
invocation_context.override_messages = [
432+
Content(role="user", parts=[Part(text="evaluate this response")]),
433+
]
434+
435+
async def run():
436+
async for _ in agent._run_async_impl(invocation_context):
437+
pass
438+
439+
asyncio.run(run())
440+
441+
assert model.stream_args == [False]
442+
443+
412444
if __name__ == "__main__":
413445
pytest.main([__file__, "-v"])

tests/evaluation/test_llm_judge_multi_model.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,62 @@ async def test_legacy_single_judge_model(self):
290290
assert r.overall_eval_status == EvalStatus.PASSED
291291

292292

293+
class TestJudgeModelResponseFormatCapabilities:
294+
295+
@staticmethod
296+
def _capture_judge_agent_construction(metric):
297+
captured = []
298+
299+
class CapturingJudgeAgent:
300+
301+
def __init__(self, model, config, system_prompt, output_schema=None, tools=None, planner=None):
302+
captured.append({
303+
"model": model,
304+
"config": config,
305+
"output_schema": output_schema,
306+
})
307+
308+
with patch("trpc_agent_sdk.evaluation._llm_judge._JudgeAgent", CapturingJudgeAgent):
309+
LLMJudge(metric)
310+
return captured
311+
312+
@staticmethod
313+
def _rubric_metric(model_name):
314+
return EvalMetric(
315+
metric_name="llm_rubric_response",
316+
threshold=0.5,
317+
criterion={
318+
"llm_judge": {
319+
"judge_model": {
320+
"model_name": model_name,
321+
},
322+
},
323+
},
324+
)
325+
326+
def test_deepseek_judge_uses_json_mode_without_native_schema(self):
327+
from trpc_agent_sdk.models.openai_adapter import _deepseek
328+
329+
captured = self._capture_judge_agent_construction(self._rubric_metric("deepseek-v4-flash"))
330+
331+
assert len(captured) == 1
332+
judge = captured[0]
333+
assert judge["output_schema"] is None
334+
assert judge["config"].response_mime_type == "application/json"
335+
with patch.object(_deepseek.logger, "warning") as warning:
336+
response_format = judge["model"]._build_response_format(judge["config"])
337+
assert response_format == {"type": "json_object"}
338+
warning.assert_not_called()
339+
340+
def test_schema_capable_judge_keeps_native_schema(self):
341+
captured = self._capture_judge_agent_construction(self._rubric_metric("gpt-4o"))
342+
343+
assert len(captured) == 1
344+
judge = captured[0]
345+
assert judge["output_schema"] is not None
346+
assert judge["config"].response_mime_type is None
347+
348+
293349
class TestUnknownAggregatorRaisesAtConstruction:
294350

295351
def test_unknown_aggregator_raises(self):

tests/evaluation/test_llm_judge_think.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,77 @@ def test_generation_config_thinking_config_used_when_think_is_none(self):
143143

144144
class TestJudgeAgentPlanner:
145145

146+
@pytest.mark.asyncio
147+
async def test_judge_agent_closes_its_agent_run(self):
148+
captured = {"closed": False}
149+
150+
class _FakeAgentRun:
151+
152+
def __aiter__(self):
153+
return self
154+
155+
async def __anext__(self):
156+
raise StopAsyncIteration
157+
158+
async def aclose(self):
159+
captured["closed"] = True
160+
161+
agent_run = _FakeAgentRun()
162+
163+
class _FakeLlmAgent:
164+
165+
def __init__(self, **kwargs):
166+
pass
167+
168+
def run_async(self, ctx):
169+
return agent_run
170+
171+
class _FakeInvocationContext:
172+
173+
def __init__(self, **kwargs):
174+
pass
175+
176+
with patch("trpc_agent_sdk.evaluation._llm_judge.LlmAgent", _FakeLlmAgent), patch(
177+
"trpc_agent_sdk.evaluation._llm_judge.InvocationContext", _FakeInvocationContext):
178+
judge = _JudgeAgent(
179+
model=object(),
180+
config=None,
181+
system_prompt="sp",
182+
)
183+
await judge.get_response("evaluate this response")
184+
185+
assert captured["closed"] is True
186+
187+
@pytest.mark.asyncio
188+
async def test_judge_agent_disables_model_streaming(self):
189+
captured: dict[str, Any] = {}
190+
191+
class _FakeLlmAgent:
192+
193+
def __init__(self, **kwargs):
194+
captured.update(kwargs)
195+
196+
async def run_async(self, ctx):
197+
captured["run_config"] = ctx.run_config
198+
if False: # pragma: no cover - makes this an async generator
199+
yield None
200+
201+
class _FakeInvocationContext:
202+
203+
def __init__(self, **kwargs):
204+
self.run_config = kwargs["run_config"]
205+
206+
with patch("trpc_agent_sdk.evaluation._llm_judge.LlmAgent", _FakeLlmAgent), patch(
207+
"trpc_agent_sdk.evaluation._llm_judge.InvocationContext", _FakeInvocationContext):
208+
judge = _JudgeAgent(
209+
model=object(),
210+
config=None,
211+
system_prompt="sp",
212+
)
213+
await judge.get_response("evaluate this response")
214+
215+
assert captured["run_config"].streaming is False
216+
146217
def test_judge_agent_accepts_planner_and_forwards_to_llm_agent(self):
147218
captured: dict[str, Any] = {}
148219

tests/models/test_openai_model_ext.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,49 @@ def test_no_warning_for_supported(self):
11541154
class TestGenerateAsyncEdgeCases:
11551155
"""Edge case tests for _generate_async_impl via generate_async."""
11561156

1157+
@pytest.mark.asyncio
1158+
async def test_streaming_response_is_closed_before_client(self):
1159+
"""The OpenAI stream itself is closed before its owning client."""
1160+
model = _model()
1161+
content = Content(parts=[Part.from_text(text="hi")], role="user")
1162+
request = _request([content])
1163+
cleanup_order: list[str] = []
1164+
1165+
class ClosableStream:
1166+
1167+
def __init__(self):
1168+
self._yielded = False
1169+
1170+
def __aiter__(self):
1171+
return self
1172+
1173+
async def __anext__(self):
1174+
if self._yielded:
1175+
raise StopAsyncIteration
1176+
self._yielded = True
1177+
chunk = Mock()
1178+
chunk.model_dump.return_value = {"choices": [], "usage": None}
1179+
return chunk
1180+
1181+
async def close(self):
1182+
cleanup_order.append("stream")
1183+
1184+
stream = ClosableStream()
1185+
1186+
async def close_client():
1187+
cleanup_order.append("client")
1188+
1189+
with patch.object(model, "_create_async_client") as mock_factory:
1190+
mock_client = AsyncMock()
1191+
mock_client.chat.completions.create = AsyncMock(return_value=stream)
1192+
mock_client.close = AsyncMock(side_effect=close_client)
1193+
mock_factory.return_value = mock_client
1194+
1195+
async for _ in model.generate_async(request, stream=True):
1196+
pass
1197+
1198+
assert cleanup_order == ["stream", "client"]
1199+
11571200
@pytest.mark.asyncio
11581201
async def test_streaming_error_yields_error_response(self):
11591202
"""Streaming errors yield an error LlmResponse."""

trpc_agent_sdk/agents/_llm_agent.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,8 @@ def accumulate_content(event: Event) -> None:
499499
logger.debug("Starting LLM call for agent: %s", self.name)
500500

501501
# Use LlmProcessor to get unified events
502-
async for event in llm_processor.call_llm_async(request, ctx, stream=True):
502+
model_streaming = ctx.run_config.streaming if ctx.run_config is not None else True
503+
async for event in llm_processor.call_llm_async(request, ctx, stream=model_streaming):
503504
# Handle different event types by checking content and error status
504505
if event.is_error():
505506
# Error event - yield and stop

trpc_agent_sdk/agents/core/_llm_processor.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ async def call_llm_async(self,
6767
"""
6868
author = context.agent.name
6969
logger.debug("Starting LLM call for agent: %s", author)
70+
buffered_events: list[Event] = []
7071

7172
try:
7273
# Step 1: Validate the request
@@ -131,7 +132,10 @@ def _append_function_calls(target: list[dict], calls: list) -> None:
131132
if not llm_response.partial:
132133
final_llm_response = llm_response
133134

134-
yield event
135+
if stream:
136+
yield event
137+
else:
138+
buffered_events.append(event)
135139
except Exception as ex:
136140
metrics_error_type = type(ex).__name__
137141
raise
@@ -164,6 +168,10 @@ def _append_function_calls(target: list[dict], calls: list) -> None:
164168
except Exception as ex: # pylint: disable=broad-except
165169
logger.error("LLM call failed for agent %s: %s", author, ex)
166170
yield self._create_error_event(context, "llm_call_error", f"LLM call failed: {str(ex)}")
171+
else:
172+
if not stream:
173+
for event in buffered_events:
174+
yield event
167175

168176
logger.debug("LLM call completed for agent: %s", author)
169177

0 commit comments

Comments
 (0)