Skip to content

Commit 14895df

Browse files
authored
Merge pull request #5321 from Agenta-AI/fix-pi-duplicate-error-frame
fix(runner): emit a single real error frame instead of appending "produced no output"
2 parents 7d8f950 + 4a77cf8 commit 14895df

2 files changed

Lines changed: 121 additions & 4 deletions

File tree

sdks/python/agenta/sdk/agents/adapters/vercel/stream.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ async def _agent_run_to_vercel_parts_impl(
128128
usage: Optional[Dict[str, Any]] = None
129129
stop_reason: Optional[str] = None
130130
content_parts_emitted = 0
131+
# Whether a real `error` frame already went out this turn (a live-streamed provider/runner
132+
# error, or one raised out of the run and caught below). The zero-content-parts backstop
133+
# below must not pile a generic "produced no output" frame on top of a real error the user
134+
# already saw -- that would bury the actionable message under a useless one (the runner's
135+
# swallowed-provider-error recovery streams the real error live AND fails the terminal
136+
# result, so both an `etype == "error"` event and the `except` branch can fire for the SAME
137+
# underlying failure; either one must suppress the backstop).
138+
error_emitted = False
131139
# Tool-call ids already surfaced as a tool part. An approval request attaches
132140
# to its tool part by id, so we synthesize one only when none preceded it.
133141
seen_tool_calls: set = set()
@@ -285,6 +293,7 @@ async def _agent_run_to_vercel_parts_impl(
285293
elif etype == "usage":
286294
usage = _usage_metadata(data)
287295
elif etype == "error":
296+
error_emitted = True
288297
yield {"type": "error", "errorText": data.get("message", "")}
289298
elif etype == "done":
290299
# Last non-null stop reason wins; see the routing-layer twin's `done` note.
@@ -294,7 +303,15 @@ async def _agent_run_to_vercel_parts_impl(
294303
except Exception as exc:
295304
# Sanitize — an unexpected exception's raw str() can carry a stack/path dump.
296305
log.error("agent_run_to_vercel_parts: error mid-stream", exc_info=True)
297-
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
306+
if not error_emitted:
307+
# Only surface this as a NEW user-facing frame when no error already went out this
308+
# turn. A swallowed-provider-error recovery streams the real error live (the
309+
# `etype == "error"` branch above) and THEN fails the terminal result, so this
310+
# exception is very often just that same failure resurfacing as a raised
311+
# `RuntimeError` (`result_from_wire`) -- yielding it too would duplicate the message
312+
# the user already saw under a second, "Agent run failed: ..."-prefixed frame.
313+
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
314+
error_emitted = True
298315
finally:
299316
# Every exit path — including the raw exception above — must still drain to a
300317
# finish frame, or a consumer waiting on it hangs. Mirrors the routing-layer twin.
@@ -312,8 +329,12 @@ async def _agent_run_to_vercel_parts_impl(
312329
trace_id = result.trace_id
313330

314331
yield {"type": "finish-step"}
315-
if content_parts_emitted == 0:
332+
if content_parts_emitted == 0 and not error_emitted:
316333
# An ok:true run with zero content parts would otherwise render as a blank bubble.
334+
# Skip this when a real error already went out above -- appending a second, useless
335+
# "no output" frame on top of it would bury the actionable message (the swallowed-
336+
# provider-error path both streams a live error event AND fails the terminal result,
337+
# so this backstop must not double up on it).
317338
yield {"type": "error", "errorText": "The agent produced no output."}
318339
finish: Dict[str, Any] = {"type": "finish"}
319340
finish_reason = _map_finish_reason(stop_reason)
@@ -380,6 +401,9 @@ async def _agent_stream_to_vercel_stream_impl(
380401
usage: Optional[Dict[str, Any]] = None
381402
stop_reason: Optional[str] = None
382403
content_parts_emitted = 0
404+
# See the dev-twin's `error_emitted` note above: suppresses the zero-content backstop when
405+
# a real error already went out this turn, live or raised.
406+
error_emitted = False
383407
seen_tool_calls: set = set()
384408
tool_names_by_id: Dict[Any, Any] = {}
385409

@@ -535,6 +559,7 @@ async def _agent_stream_to_vercel_stream_impl(
535559
elif etype == "usage":
536560
usage = _usage_metadata(data)
537561
elif etype == "error":
562+
error_emitted = True
538563
yield {"type": "error", "errorText": data.get("message", "")}
539564
elif etype == "done":
540565
# Prefer the LAST non-null stop reason. The handler appends a corrective
@@ -547,13 +572,23 @@ async def _agent_stream_to_vercel_stream_impl(
547572
stop_reason = reason
548573
except Exception as exc:
549574
log.error("agent_stream_to_vercel_stream: error mid-stream", exc_info=True)
550-
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
575+
if not error_emitted:
576+
# See the dev-twin's matching note: suppress this when a real error already went
577+
# out live this turn, so a swallowed-provider-error recovery (live error event, then
578+
# a failed terminal result raised as this same exception) doesn't duplicate the
579+
# user-facing message under a second, differently-worded frame.
580+
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
581+
error_emitted = True
551582
finally:
552583
# Every exit path — including the raw exception above — must still drain to a
553584
# finish frame, or a consumer waiting on it hangs.
554585
yield {"type": "finish-step"}
555-
if content_parts_emitted == 0:
586+
if content_parts_emitted == 0 and not error_emitted:
556587
# An ok:true run with zero content parts would otherwise render as a blank bubble.
588+
# Skip this when a real error already went out above (see the dev-twin's matching
589+
# note) -- a swallowed-provider-error turn both streams a live error event and fails
590+
# the terminal result, so this backstop must not double up on it and bury the real
591+
# message under "The agent produced no output."
557592
yield {"type": "error", "errorText": "The agent produced no output."}
558593
finish: Dict[str, Any] = {"type": "finish"}
559594
finish_reason = _map_finish_reason(stop_reason)

sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,88 @@ async def test_dropped_only_content_part_still_triggers_zero_content_guard_dev_t
173173
)
174174

175175

176+
@pytest.mark.asyncio
177+
async def test_swallowed_provider_error_emits_exactly_one_error_frame() -> None:
178+
"""F-5317-followup: a turn that recovers a swallowed provider error (out-of-credit, bad
179+
key, ...) streams a real error live AND fails its terminal result -- the runner's
180+
`findSwallowedPiError` path both `emitEvent({type:"error"})`s the recovered message and
181+
returns `{ok:false, error:...}` for the SAME failure (`sandbox_agent.ts` around the
182+
`swallowedError` branch). On the wire that means the live event surfaces as one `error`
183+
frame here, and the failed terminal record raises out of the event iterator and is caught
184+
by this adapter's `except Exception` as a second `error` frame.
185+
186+
Before the fix, the zero-content-parts backstop then piled a THIRD, generic frame on top
187+
("The agent produced no output.") because neither error frame incremented
188+
`content_parts_emitted` -- burying the actionable message under a useless one. QA observed
189+
exactly this on a real out-of-credit run: frames were
190+
``[error(real), error("Agent run failed: " + real), error("The agent produced no
191+
output.")]`` and the UI showed only the last frame. This test pins that the generic
192+
backstop no longer fires once a real error went out.
193+
"""
194+
real_error = (
195+
"pi_core: the model provider account has insufficient credit "
196+
"(check the project's OpenAI key)."
197+
)
198+
199+
async def _events_with_uncaught_failure():
200+
yield {"type": "error", "data": {"message": real_error}}
201+
# Mirrors the terminal `ok:false` result raising out of the event iterator uncaught
202+
# (streaming.py's AgentStream.__aiter__ -> result_from_wire -> RuntimeError, propagated
203+
# through handler.py's agent_event_stream with no enclosing except).
204+
raise RuntimeError(f"Agent run failed: {real_error}")
205+
206+
parts = [
207+
part
208+
async for part in agent_stream_to_vercel_stream(
209+
_events_with_uncaught_failure(), trace_id="t-swallowed"
210+
)
211+
]
212+
for part in parts:
213+
assert_conforms(part)
214+
215+
error_parts = [p for p in parts if p["type"] == "error"]
216+
assert len(error_parts) == 1, (
217+
f"expected exactly one error frame, got {error_parts!r}"
218+
)
219+
assert error_parts[0]["errorText"] == real_error
220+
assert not any(p.get("errorText") == "The agent produced no output." for p in parts)
221+
222+
223+
@pytest.mark.asyncio
224+
async def test_swallowed_provider_error_emits_exactly_one_error_frame_dev_twin() -> (
225+
None
226+
):
227+
"""Dev-twin counterpart: the live error event AND the terminal `ok:false` both come off the
228+
same ``AgentStream`` (`kind:"event"` then `kind:"result"`), matching the real runner's NDJSON
229+
record shape (`server.ts` `liveEmit` then the terminal `{kind:"result"}` write).
230+
"""
231+
real_error = (
232+
"pi_core: the model provider account has insufficient credit "
233+
"(check the project's OpenAI key)."
234+
)
235+
# `Event.from_wire` keeps the raw record verbatim as `.data` (it does not unwrap a nested
236+
# `data` key), so a live error event's `message` rides at the TOP level -- mirrors the
237+
# runner's actual `run.emitEvent({type:"error", message: swallowedError})` shape
238+
# (`sandbox_agent.ts`). The terminal result's `error` is the concise message UNPREFIXED
239+
# (`{ok:false, error: swallowedError}`, same file) -- `result_from_wire` adds the
240+
# "Agent run failed: " prefix itself when it raises.
241+
records = [
242+
{"kind": "event", "event": {"type": "error", "message": real_error}},
243+
{"kind": "result", "result": {"ok": False, "error": real_error}},
244+
]
245+
run = AgentStream(_records(records))
246+
parts = [part async for part in agent_run_to_vercel_parts(run)]
247+
for part in parts:
248+
assert_conforms(part)
249+
250+
error_parts = [p for p in parts if p["type"] == "error"]
251+
assert len(error_parts) == 1, (
252+
f"expected exactly one error frame, got {error_parts!r}"
253+
)
254+
assert error_parts[0]["errorText"] == real_error
255+
assert not any(p.get("errorText") == "The agent produced no output." for p in parts)
256+
257+
176258
def test_vendored_version_matches_package_pin() -> None:
177259
# CI-grep-able tripwire: bump this const (and re-audit the shape above) whenever
178260
# web/oss/package.json's "ai" pin changes.

0 commit comments

Comments
 (0)