Skip to content

Commit 1aff8c7

Browse files
fix: dispatch async retry stream events to async-only callbacks
The async retry loop emitted RETRY events via the sync emit() path, so consumers registered through the public add_async_callback() API passed has_callbacks but never received the event. Add an async-aware _aemit_retry_stream_event helper (using emit_async) and share event construction via _build_retry_stream_event. Adds tests for the async-only callback path and the no-callback no-op case. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent e5e962b commit 1aff8c7

2 files changed

Lines changed: 136 additions & 44 deletions

File tree

src/praisonai-agents/praisonaiagents/agent/chat_mixin.py

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4432,19 +4432,51 @@ def _emit_retry_stream_event(self, *, attempt, max_attempts, delay, reason):
44324432
return
44334433
try:
44344434
from ..streaming.events import StreamEvent, StreamEventType
4435-
emitter.emit(StreamEvent(
4436-
type=StreamEventType.RETRY,
4437-
metadata={
4438-
"attempt": attempt,
4439-
"max_attempts": max_attempts,
4440-
"delay": delay,
4441-
"reason": reason,
4442-
},
4443-
agent_id=getattr(self, 'name', None),
4435+
emitter.emit(self._build_retry_stream_event(
4436+
StreamEvent, StreamEventType,
4437+
attempt=attempt, max_attempts=max_attempts, delay=delay, reason=reason,
44444438
))
44454439
except Exception as _re:
44464440
logger.debug(f"Failed to emit RETRY stream event: {_re}")
44474441

4442+
async def _aemit_retry_stream_event(self, *, attempt, max_attempts, delay, reason):
4443+
"""Async counterpart of ``_emit_retry_stream_event``.
4444+
4445+
Uses ``emit_async`` so consumers registered via the public
4446+
``add_async_callback()`` API also receive RETRY events; ``emit`` alone
4447+
only visits synchronous callbacks. Same zero-overhead guard and
4448+
never-raise contract as the sync path.
4449+
"""
4450+
emitter = getattr(self, 'stream_emitter', None)
4451+
if emitter is None or not getattr(emitter, 'has_callbacks', False):
4452+
return
4453+
try:
4454+
from ..streaming.events import StreamEvent, StreamEventType
4455+
event = self._build_retry_stream_event(
4456+
StreamEvent, StreamEventType,
4457+
attempt=attempt, max_attempts=max_attempts, delay=delay, reason=reason,
4458+
)
4459+
emit_async = getattr(emitter, 'emit_async', None)
4460+
if emit_async is not None:
4461+
await emit_async(event)
4462+
else:
4463+
emitter.emit(event)
4464+
except Exception as _re:
4465+
logger.debug(f"Failed to emit RETRY stream event: {_re}")
4466+
4467+
def _build_retry_stream_event(self, StreamEvent, StreamEventType, *, attempt, max_attempts, delay, reason):
4468+
"""Construct the RETRY ``StreamEvent`` shared by the sync/async emitters."""
4469+
return StreamEvent(
4470+
type=StreamEventType.RETRY,
4471+
metadata={
4472+
"attempt": attempt,
4473+
"max_attempts": max_attempts,
4474+
"delay": delay,
4475+
"reason": reason,
4476+
},
4477+
agent_id=getattr(self, 'name', None),
4478+
)
4479+
44484480
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
44494481
"""
44504482
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
@@ -4508,20 +4540,14 @@ def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, str
45084540

45094541
# Surface the retry as a first-class stream event so CLI/UI
45104542
# consumers can show a live "retrying in Ns" status instead of
4511-
# appearing hung. Guarded by has_callbacks -> zero overhead when
4512-
# nothing is listening.
4513-
emitter = getattr(self, 'stream_emitter', None)
4514-
if emitter is not None and emitter.has_callbacks:
4515-
from ..streaming.events import StreamEvent, StreamEventType
4516-
emitter.emit(StreamEvent(
4517-
type=StreamEventType.RETRY,
4518-
metadata={
4519-
"attempt": attempt + 1,
4520-
"max_attempts": max_attempts,
4521-
"delay": delay,
4522-
"reason": str(e),
4523-
},
4524-
))
4543+
# appearing hung. Guarded so it is zero-overhead when nothing
4544+
# is listening.
4545+
self._emit_retry_stream_event(
4546+
attempt=attempt + 1,
4547+
max_attempts=max_attempts,
4548+
delay=delay,
4549+
reason=str(e),
4550+
)
45254551

45264552
# Log retry attempt (buffered to avoid spam during transient failures)
45274553
logger.debug(f"[{self.name}] Retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
@@ -4604,20 +4630,15 @@ async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=No
46044630
)
46054631
await self._hook_runner.execute_async(HookEvent.ON_RETRY, retry_input)
46064632

4607-
# Surface the retry as a first-class stream event (see sync path).
4608-
# Guarded by has_callbacks -> zero overhead when nothing is listening.
4609-
emitter = getattr(self, 'stream_emitter', None)
4610-
if emitter is not None and emitter.has_callbacks:
4611-
from ..streaming.events import StreamEvent, StreamEventType
4612-
await emitter.emit_async(StreamEvent(
4613-
type=StreamEventType.RETRY,
4614-
metadata={
4615-
"attempt": attempt + 1,
4616-
"max_attempts": max_attempts,
4617-
"delay": delay,
4618-
"reason": str(e),
4619-
},
4620-
))
4633+
# Surface a streaming RETRY event (see sync path for rationale).
4634+
# Use the async emitter so async-only consumers registered via
4635+
# add_async_callback() also receive the event.
4636+
await self._aemit_retry_stream_event(
4637+
attempt=attempt + 1,
4638+
max_attempts=max_attempts,
4639+
delay=delay,
4640+
reason=str(e),
4641+
)
46214642

46224643
# Log retry attempt
46234644
logger.debug(f"[{self.name}] Async retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")

src/praisonai-agents/tests/unit/test_streaming_events.py

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,85 @@ def test_retry_event_carries_attempt_and_delay(self):
360360
"attempt": 2,
361361
"max_attempts": 4,
362362
"delay": 8.0,
363-
"reason": "rate limit",
363+
"reason": "rate limited",
364364
},
365365
))
366366

367367
assert len(received) == 1
368-
ev = received[0]
369-
assert ev.type == StreamEventType.RETRY
370-
assert ev.metadata["attempt"] == 2
371-
assert ev.metadata["max_attempts"] == 4
372-
assert ev.metadata["delay"] == 8.0
373-
assert ev.metadata["reason"] == "rate limit"
368+
evt = received[0]
369+
assert evt.type == StreamEventType.RETRY
370+
assert evt.metadata["attempt"] == 2
371+
assert evt.metadata["max_attempts"] == 4
372+
assert evt.metadata["delay"] == 8.0
373+
assert evt.metadata["reason"] == "rate limited"
374+
375+
def test_emit_retry_stream_event_helper_zero_overhead_without_callbacks(self):
376+
"""The agent helper must be a no-op when no callbacks are attached."""
377+
from praisonaiagents import Agent
378+
379+
agent = Agent(name="retry-agent")
380+
# No callbacks attached -> helper should not raise and emit nothing.
381+
received = []
382+
agent.stream_emitter.add_callback(lambda e: received.append(e))
383+
agent.stream_emitter.remove_callback # sanity: emitter present
384+
# Remove the callback to simulate the no-consumer case.
385+
agent.stream_emitter._callbacks.clear()
386+
assert agent.stream_emitter.has_callbacks is False
387+
agent._emit_retry_stream_event(attempt=1, max_attempts=3, delay=1.0, reason="x")
388+
assert received == []
389+
390+
def test_emit_retry_stream_event_helper_emits_when_listening(self):
391+
"""The agent helper should emit a RETRY event when a consumer listens."""
392+
from praisonaiagents import Agent
393+
from praisonaiagents.streaming.events import StreamEventType
394+
395+
agent = Agent(name="retry-agent")
396+
received = []
397+
agent.stream_emitter.add_callback(lambda e: received.append(e))
398+
agent._emit_retry_stream_event(attempt=2, max_attempts=4, delay=5.0, reason="rate limited")
399+
400+
assert len(received) == 1
401+
evt = received[0]
402+
assert evt.type == StreamEventType.RETRY
403+
assert evt.metadata == {
404+
"attempt": 2,
405+
"max_attempts": 4,
406+
"delay": 5.0,
407+
"reason": "rate limited",
408+
}
409+
410+
def test_aemit_retry_reaches_async_only_callbacks(self):
411+
"""The async helper must reach consumers registered via add_async_callback()."""
412+
import asyncio
413+
from praisonaiagents import Agent
414+
from praisonaiagents.streaming.events import StreamEventType
415+
416+
agent = Agent(name="retry-agent")
417+
received = []
418+
419+
async def async_cb(event):
420+
received.append(event)
421+
422+
# Async-only consumer: has_callbacks is True but sync emit() would miss it.
423+
agent.stream_emitter.add_async_callback(async_cb)
424+
425+
asyncio.run(agent._aemit_retry_stream_event(
426+
attempt=1, max_attempts=3, delay=2.0, reason="rate limited",
427+
))
428+
429+
assert len(received) == 1
430+
assert received[0].type == StreamEventType.RETRY
431+
assert received[0].metadata["attempt"] == 1
432+
assert received[0].metadata["max_attempts"] == 3
433+
434+
def test_aemit_retry_zero_overhead_without_callbacks(self):
435+
"""The async helper must be a no-op when no callbacks are attached."""
436+
import asyncio
437+
from praisonaiagents import Agent
438+
439+
agent = Agent(name="retry-agent")
440+
assert agent.stream_emitter.has_callbacks is False
441+
# Should not raise and should emit nothing.
442+
asyncio.run(agent._aemit_retry_stream_event(
443+
attempt=1, max_attempts=3, delay=1.0, reason="x",
444+
))

0 commit comments

Comments
 (0)