Skip to content

Commit e5e962b

Browse files
fix: surface retry/backoff status in the CLI (fixes #3311)
Add StreamEventType.RETRY emitted alongside the existing ON_RETRY hook (guarded by has_callbacks for zero-overhead) in both sync and async retry loops, carrying attempt/max_attempts/delay/reason in metadata. Map it to a canonical run.retry NDJSON event in the CLI stream-json bridge so consumers get a live 'retrying in Ns (attempt k/N)' signal. Non-JSON/silent modes stay silent. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent 17a19af commit e5e962b

2 files changed

Lines changed: 40 additions & 1 deletion

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4419,6 +4419,32 @@ async def _apply_context_compaction_async(self, messages, hook_event_class):
44194419
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
44204420
return False
44214421

4422+
def _emit_retry_stream_event(self, *, attempt, max_attempts, delay, reason):
4423+
"""Emit a ``StreamEventType.RETRY`` event during backoff.
4424+
4425+
Lets consumers (e.g. the CLI stream-json bridge) render a live
4426+
"retrying in Ns (attempt k/N)" status instead of appearing hung.
4427+
Guarded by ``has_callbacks`` so it stays zero-overhead when nothing is
4428+
listening, and never raises into the retry loop.
4429+
"""
4430+
emitter = getattr(self, 'stream_emitter', None)
4431+
if emitter is None or not getattr(emitter, 'has_callbacks', False):
4432+
return
4433+
try:
4434+
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),
4444+
))
4445+
except Exception as _re:
4446+
logger.debug(f"Failed to emit RETRY stream event: {_re}")
4447+
44224448
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):
44234449
"""
44244450
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.

src/praisonai/tests/unit/cli/test_run_event_bridge.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ def __init__(self, value):
2727

2828
class _FakeStreamEvent:
2929
def __init__(self, type_value, content=None, tool_call=None,
30-
error=None, is_reasoning=False, agent_id=None):
30+
error=None, is_reasoning=False, agent_id=None, metadata=None):
3131
self.type = _FakeEnum(type_value)
3232
self.content = content
3333
self.tool_call = tool_call
3434
self.error = error
3535
self.is_reasoning = is_reasoning
3636
self.agent_id = agent_id
37+
self.metadata = metadata
3738

3839

3940
class _FakeEmitter:
@@ -145,6 +146,18 @@ def test_retry_event_mapped_to_run_retry(capsys):
145146
assert retry["data"]["max_attempts"] == 4
146147
assert retry["data"]["delay"] == 8.0
147148
assert retry["data"]["reason"] == "rate limit"
149+
assert retry["data"]["schema_version"] == SCHEMA_VERSION
150+
151+
152+
def test_retry_event_silent_in_text_mode(capsys):
153+
output = OutputController(mode=OutputMode.TEXT)
154+
agent = _FakeAgent()
155+
bridge = attach_bridge(agent, output)
156+
# Bridge is a no-op in non-JSON modes.
157+
assert bridge is None
158+
agent.stream_emitter.emit(_FakeStreamEvent(
159+
"retry", metadata={"attempt": 1, "max_attempts": 3, "delay": 1.0}))
160+
assert capsys.readouterr().out.strip() == ""
148161

149162

150163
def test_run_lifecycle_helpers(capsys):

0 commit comments

Comments
 (0)