Skip to content

Commit 0184d8d

Browse files
committed
session: classify litellm exceptions raised inside user callables
The rollout isolation in the previous commit converts target-side LLMInputError into a recorded transcript event with stop_reason="target_input_refused". It only fires when the exception reaches the rollout helpers as an already-classified LLM error class. For callable: targets (LangGraph agents, framework wrappers, raw litellm callers, etc.) the user's own code makes the provider calls and bypasses generate()/_with_retries entirely, so provider errors bubble up as raw litellm exceptions (BadRequestError, ContentPolicyViolationError, RateLimitError, ...). The rollout's isinstance(_, LLMInputError) checks miss them and the stage aborts on the first content-filter rejection \u2014 the same failure pattern the isolation was supposed to fix. Fix CallableSession.run_turn so any exception escaping invoke_callable is passed through _classify_llm_error before propagating. Classified errors are re-raised with __cause__ preserved; unclassified errors (user agent crashes, ValueError from misconfigured tools, etc.) pass through unchanged so they don't get smuggled into one of the four LLM error classes. Tests: - test_run_turn_reclassifies_litellm_bad_request_as_input_error: a fake litellm BadRequestError raised from inside the user callable emerges as LLMInputError with cause preserved. - test_run_turn_passes_through_unclassified_exceptions: a custom RuntimeError from inside the user callable propagates as itself, not as any LLM*Error class. All 50 rollout + exception-handling tests pass. Local smoke against azure/gpt-5.4-mini with the LangGraph travel-planner callable (30 prompts + 10 scenarios, C=4) completed end-to-end in 176.9s.
1 parent 82cf339 commit 0184d8d

2 files changed

Lines changed: 98 additions & 8 deletions

File tree

p2m/core/session.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Message,
1616
ModelResponse,
1717
ToolCall,
18+
_classify_llm_error,
1819
build_llm_call_trace,
1920
generate,
2021
generate_with_tools,
@@ -512,15 +513,35 @@ async def run_turn(self, messages: list[Message]) -> TurnResult:
512513
for msg in messages
513514
if msg.role in ("user", "assistant")
514515
]
515-
raw_result = await invoke_callable(
516-
self._callable, user_text, history=history,
517-
timeout_s=self._message_timeout_s,
518-
)
516+
try:
517+
raw_result = await invoke_callable(
518+
self._callable, user_text, history=history,
519+
timeout_s=self._message_timeout_s,
520+
)
521+
except Exception as exc:
522+
# The user callable typically makes its own LLM calls
523+
# (LangGraph, agent frameworks, raw litellm) which bypass
524+
# our generate()/_with_retries wrapper, so provider errors
525+
# bubble up unclassified. Re-raise them as the right p2m
526+
# error class (LLMInputError for content-filter / 400s,
527+
# LLMRateLimitError for 429s, LLMProviderError for 5xx,
528+
# LLMAuthError for 401/403) so the rollout stage's per-seed
529+
# isolation paths can do their job.
530+
classified = _classify_llm_error(exc)
531+
if classified is exc:
532+
raise
533+
raise classified from exc
519534
else:
520-
raw_result = await invoke_callable(
521-
self._callable, user_text,
522-
timeout_s=self._message_timeout_s,
523-
)
535+
try:
536+
raw_result = await invoke_callable(
537+
self._callable, user_text,
538+
timeout_s=self._message_timeout_s,
539+
)
540+
except Exception as exc:
541+
classified = _classify_llm_error(exc)
542+
if classified is exc:
543+
raise
544+
raise classified from exc
524545

525546
result = self._normalize_callable_result(raw_result)
526547

tests/test_exception_handling.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,75 @@ async def test_valid_callable_opens_successfully(self) -> None:
123123
await session.open()
124124
await session.close()
125125

126+
async def test_run_turn_reclassifies_litellm_bad_request_as_input_error(self) -> None:
127+
"""User callables (LangGraph, agent frameworks, raw litellm) bypass
128+
``generate()``/``_with_retries`` and so emit unclassified provider
129+
errors. ``CallableSession.run_turn`` must re-classify them so the
130+
rollout stage's per-seed isolation paths can route content-filter
131+
rejections to a recorded transcript event instead of aborting the
132+
whole batch.
133+
"""
134+
from p2m.core.session import CallableSession
135+
from p2m.core.model_client import LLMInputError, Message
136+
137+
# Lightweight stand-in for ``litellm.BadRequestError`` that avoids
138+
# importing litellm in the unit test. ``_classify_llm_error`` reads
139+
# exception types from the live litellm module at call time, so we
140+
# patch it directly to make the substitution explicit.
141+
class FakeLitellmBadRequest(Exception):
142+
pass
143+
144+
def _classified(exc: Exception) -> Exception:
145+
if isinstance(exc, FakeLitellmBadRequest):
146+
err = LLMInputError(f"Bad request: {exc}")
147+
err.__cause__ = exc
148+
return err
149+
return exc
150+
151+
async def fake_invoke_callable(fn, *args, **kwargs):
152+
raise FakeLitellmBadRequest(
153+
"Invalid prompt: your prompt was flagged as potentially "
154+
"violating our usage policy"
155+
)
156+
157+
session = CallableSession(callable_ref="json:dumps")
158+
await session.open()
159+
try:
160+
with (
161+
patch("p2m.core.session.invoke_callable", new=fake_invoke_callable),
162+
patch("p2m.core.session._classify_llm_error", new=_classified),
163+
):
164+
with self.assertRaises(LLMInputError) as ctx:
165+
await session.run_turn([Message(role="user", content="hi")])
166+
self.assertIn("flagged as potentially violating", str(ctx.exception))
167+
self.assertIsInstance(ctx.exception.__cause__, FakeLitellmBadRequest)
168+
finally:
169+
await session.close()
170+
171+
async def test_run_turn_passes_through_unclassified_exceptions(self) -> None:
172+
"""Errors that the classifier doesn't recognise (e.g. user agent
173+
crashes, ValueError from misconfigured tools) must propagate as-is
174+
rather than being smuggled into one of the four LLM error classes.
175+
"""
176+
from p2m.core.session import CallableSession
177+
from p2m.core.model_client import Message
178+
179+
class CustomAgentError(RuntimeError):
180+
pass
181+
182+
async def fake_invoke_callable(fn, *args, **kwargs):
183+
raise CustomAgentError("user agent blew up")
184+
185+
session = CallableSession(callable_ref="json:dumps")
186+
await session.open()
187+
try:
188+
with patch("p2m.core.session.invoke_callable", new=fake_invoke_callable):
189+
with self.assertRaises(CustomAgentError) as ctx:
190+
await session.run_turn([Message(role="user", content="hi")])
191+
self.assertIn("user agent blew up", str(ctx.exception))
192+
finally:
193+
await session.close()
194+
126195

127196
class OTelTracedSessionErrorTest(unittest.IsolatedAsyncioTestCase):
128197
async def test_missing_module_raises_value_error(self) -> None:

0 commit comments

Comments
 (0)