jaf-py 2.5.3__py3-none-any.whl → 2.5.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jaf/__init__.py CHANGED
@@ -191,7 +191,7 @@ def generate_run_id() -> RunId:
191
191
  """Generate a new run ID."""
192
192
  return create_run_id(str(uuid.uuid4()))
193
193
 
194
- __version__ = "2.5.3"
194
+ __version__ = "2.5.5"
195
195
  __all__ = [
196
196
  # Core types and functions
197
197
  "TraceId", "RunId", "ValidationResult", "Message", "ModelConfig",
jaf/core/engine.py CHANGED
@@ -293,15 +293,7 @@ async def _load_conversation_history(state: RunState[Ctx], config: RunConfig[Ctx
293
293
  # For HITL scenarios, append new messages to memory messages
294
294
  # This prevents duplication when resuming from interruptions
295
295
  if memory_messages:
296
- combined_messages = memory_messages + [
297
- msg for msg in state.messages
298
- if not any(
299
- mem_msg.role == msg.role and
300
- mem_msg.content == msg.content and
301
- getattr(mem_msg, 'tool_calls', None) == getattr(msg, 'tool_calls', None)
302
- for mem_msg in memory_messages
303
- )
304
- ]
296
+ combined_messages = memory_messages + list(state.messages)
305
297
  else:
306
298
  combined_messages = list(state.messages)
307
299
 
@@ -535,6 +527,17 @@ async def _run_internal(
535
527
  "gpt-4o"
536
528
  )
537
529
 
530
+ # Apply before_llm_call callback if provided
531
+ if config.before_llm_call:
532
+ if asyncio.iscoroutinefunction(config.before_llm_call):
533
+ state = await config.before_llm_call(state, current_agent)
534
+ else:
535
+ result = config.before_llm_call(state, current_agent)
536
+ if asyncio.iscoroutine(result):
537
+ state = await result
538
+ else:
539
+ state = result
540
+
538
541
  # Emit LLM call start event
539
542
  if config.on_event:
540
543
  config.on_event(LLMCallStartEvent(data=to_event_data(LLMCallStartEventData(
@@ -546,121 +549,157 @@ async def _run_internal(
546
549
  messages=state.messages
547
550
  ))))
548
551
 
549
- # Get completion from model provider, prefer streaming if available
552
+ # Retry logic for empty LLM responses
550
553
  llm_response: Dict[str, Any]
551
554
  assistant_event_streamed = False
552
-
553
- get_stream = getattr(config.model_provider, "get_completion_stream", None)
554
- if callable(get_stream):
555
- try:
556
- aggregated_text = ""
557
- # Working array of partial tool calls
558
- partial_tool_calls: List[Dict[str, Any]] = []
559
-
560
- async for chunk in get_stream(state, current_agent, config): # type: ignore[arg-type]
561
- # Text deltas
562
- delta_text = getattr(chunk, "delta", None)
563
- if delta_text:
564
- aggregated_text += delta_text
565
-
566
- # Tool call deltas
567
- tcd = getattr(chunk, "tool_call_delta", None)
568
- if tcd is not None:
569
- idx = getattr(tcd, "index", 0) or 0
570
- # Ensure slot exists
571
- while len(partial_tool_calls) <= idx:
572
- partial_tool_calls.append({
573
- "id": None,
574
- "type": "function",
575
- "function": {"name": None, "arguments": ""}
576
- })
577
- target = partial_tool_calls[idx]
578
- # id
579
- tc_id = getattr(tcd, "id", None)
580
- if tc_id:
581
- target["id"] = tc_id
582
- # function fields
583
- fn = getattr(tcd, "function", None)
584
- if fn is not None:
585
- fn_name = getattr(fn, "name", None)
586
- if fn_name:
587
- target["function"]["name"] = fn_name
588
- args_delta = getattr(fn, "arguments_delta", None)
589
- if args_delta:
590
- target["function"]["arguments"] += args_delta
591
-
592
- # Emit partial assistant message when something changed
593
- if delta_text or tcd is not None:
594
- assistant_event_streamed = True
595
- # Normalize tool_calls for message
596
- message_tool_calls = None
597
- if len(partial_tool_calls) > 0:
598
- message_tool_calls = []
599
- for i, tc in enumerate(partial_tool_calls):
600
- arguments = tc["function"]["arguments"]
601
- if isinstance(arguments, str):
602
- arguments = _normalize_tool_call_arguments(arguments)
603
- message_tool_calls.append({
604
- "id": tc["id"] or f"call_{i}",
555
+
556
+ for retry_attempt in range(config.max_empty_response_retries + 1):
557
+ # Get completion from model provider, prefer streaming if available
558
+ get_stream = getattr(config.model_provider, "get_completion_stream", None)
559
+ if callable(get_stream):
560
+ try:
561
+ aggregated_text = ""
562
+ # Working array of partial tool calls
563
+ partial_tool_calls: List[Dict[str, Any]] = []
564
+
565
+ async for chunk in get_stream(state, current_agent, config): # type: ignore[arg-type]
566
+ # Text deltas
567
+ delta_text = getattr(chunk, "delta", None)
568
+ if delta_text:
569
+ aggregated_text += delta_text
570
+
571
+ # Tool call deltas
572
+ tcd = getattr(chunk, "tool_call_delta", None)
573
+ if tcd is not None:
574
+ idx = getattr(tcd, "index", 0) or 0
575
+ # Ensure slot exists
576
+ while len(partial_tool_calls) <= idx:
577
+ partial_tool_calls.append({
578
+ "id": None,
605
579
  "type": "function",
606
- "function": {
607
- "name": tc["function"]["name"] or "",
608
- "arguments": arguments
609
- }
580
+ "function": {"name": None, "arguments": ""}
610
581
  })
582
+ target = partial_tool_calls[idx]
583
+ # id
584
+ tc_id = getattr(tcd, "id", None)
585
+ if tc_id:
586
+ target["id"] = tc_id
587
+ # function fields
588
+ fn = getattr(tcd, "function", None)
589
+ if fn is not None:
590
+ fn_name = getattr(fn, "name", None)
591
+ if fn_name:
592
+ target["function"]["name"] = fn_name
593
+ args_delta = getattr(fn, "arguments_delta", None)
594
+ if args_delta:
595
+ target["function"]["arguments"] += args_delta
596
+
597
+ # Emit partial assistant message when something changed
598
+ if delta_text or tcd is not None:
599
+ assistant_event_streamed = True
600
+ # Normalize tool_calls for message
601
+ message_tool_calls = None
602
+ if len(partial_tool_calls) > 0:
603
+ message_tool_calls = []
604
+ for i, tc in enumerate(partial_tool_calls):
605
+ arguments = tc["function"]["arguments"]
606
+ if isinstance(arguments, str):
607
+ arguments = _normalize_tool_call_arguments(arguments)
608
+ message_tool_calls.append({
609
+ "id": tc["id"] or f"call_{i}",
610
+ "type": "function",
611
+ "function": {
612
+ "name": tc["function"]["name"] or "",
613
+ "arguments": arguments
614
+ }
615
+ })
616
+
617
+ partial_msg = Message(
618
+ role=ContentRole.ASSISTANT,
619
+ content=aggregated_text or "",
620
+ tool_calls=None if not message_tool_calls else [
621
+ ToolCall(
622
+ id=mc["id"],
623
+ type="function",
624
+ function=ToolCallFunction(
625
+ name=mc["function"]["name"],
626
+ arguments=_normalize_tool_call_arguments(mc["function"]["arguments"])
627
+ ),
628
+ ) for mc in message_tool_calls
629
+ ],
630
+ )
631
+ try:
632
+ if config.on_event:
633
+ config.on_event(AssistantMessageEvent(data=to_event_data(
634
+ AssistantMessageEventData(message=partial_msg)
635
+ )))
636
+ except Exception as _e:
637
+ # Do not fail the run on callback errors
638
+ pass
639
+
640
+ # Build final response object compatible with downstream logic
641
+ final_tool_calls = None
642
+ if len(partial_tool_calls) > 0:
643
+ final_tool_calls = []
644
+ for i, tc in enumerate(partial_tool_calls):
645
+ arguments = tc["function"]["arguments"]
646
+ if isinstance(arguments, str):
647
+ arguments = _normalize_tool_call_arguments(arguments)
648
+ final_tool_calls.append({
649
+ "id": tc["id"] or f"call_{i}",
650
+ "type": "function",
651
+ "function": {
652
+ "name": tc["function"]["name"] or "",
653
+ "arguments": arguments
654
+ }
655
+ })
611
656
 
612
- partial_msg = Message(
613
- role=ContentRole.ASSISTANT,
614
- content=aggregated_text or "",
615
- tool_calls=None if not message_tool_calls else [
616
- ToolCall(
617
- id=mc["id"],
618
- type="function",
619
- function=ToolCallFunction(
620
- name=mc["function"]["name"],
621
- arguments=_normalize_tool_call_arguments(mc["function"]["arguments"])
622
- ),
623
- ) for mc in message_tool_calls
624
- ],
625
- )
626
- try:
627
- if config.on_event:
628
- config.on_event(AssistantMessageEvent(data=to_event_data(
629
- AssistantMessageEventData(message=partial_msg)
630
- )))
631
- except Exception as _e:
632
- # Do not fail the run on callback errors
633
- pass
634
-
635
- # Build final response object compatible with downstream logic
636
- final_tool_calls = None
637
- if len(partial_tool_calls) > 0:
638
- final_tool_calls = []
639
- for i, tc in enumerate(partial_tool_calls):
640
- arguments = tc["function"]["arguments"]
641
- if isinstance(arguments, str):
642
- arguments = _normalize_tool_call_arguments(arguments)
643
- final_tool_calls.append({
644
- "id": tc["id"] or f"call_{i}",
645
- "type": "function",
646
- "function": {
647
- "name": tc["function"]["name"] or "",
648
- "arguments": arguments
649
- }
650
- })
651
-
652
- llm_response = {
653
- "message": {
654
- "content": aggregated_text or None,
655
- "tool_calls": final_tool_calls
657
+ llm_response = {
658
+ "message": {
659
+ "content": aggregated_text or None,
660
+ "tool_calls": final_tool_calls
661
+ }
656
662
  }
657
- }
658
- except Exception:
659
- # Fallback to non-streaming on error
660
- assistant_event_streamed = False
663
+ except Exception:
664
+ # Fallback to non-streaming on error
665
+ assistant_event_streamed = False
666
+ llm_response = await config.model_provider.get_completion(state, current_agent, config)
667
+ else:
661
668
  llm_response = await config.model_provider.get_completion(state, current_agent, config)
662
- else:
663
- llm_response = await config.model_provider.get_completion(state, current_agent, config)
669
+
670
+ # Check if response has meaningful content
671
+ has_content = llm_response.get('message', {}).get('content')
672
+ has_tool_calls = llm_response.get('message', {}).get('tool_calls')
673
+
674
+ # If we got a valid response, break out of retry loop
675
+ if has_content or has_tool_calls:
676
+ break
677
+
678
+ # If this is not the last attempt, retry with exponential backoff
679
+ if retry_attempt < config.max_empty_response_retries:
680
+ delay = config.empty_response_retry_delay * (2 ** retry_attempt)
681
+ if config.log_empty_responses:
682
+ print(f"[JAF:ENGINE] Empty LLM response on attempt {retry_attempt + 1}/{config.max_empty_response_retries + 1}, retrying in {delay:.1f}s...")
683
+ print(f"[JAF:ENGINE] Response had message: {bool(llm_response.get('message'))}, content: {bool(has_content)}, tool_calls: {bool(has_tool_calls)}")
684
+ await asyncio.sleep(delay)
685
+ else:
686
+ # Last attempt failed, log detailed diagnostic info
687
+ if config.log_empty_responses:
688
+ print(f"[JAF:ENGINE] Empty LLM response after {config.max_empty_response_retries + 1} attempts")
689
+ print(f"[JAF:ENGINE] Agent: {current_agent.name}, Model: {model}")
690
+ print(f"[JAF:ENGINE] Message count: {len(state.messages)}, Turn: {state.turn_count}")
691
+ print(f"[JAF:ENGINE] Response structure: {json.dumps(llm_response, indent=2)[:1000]}")
692
+
693
+ # Apply after_llm_call callback if provided
694
+ if config.after_llm_call:
695
+ if asyncio.iscoroutinefunction(config.after_llm_call):
696
+ llm_response = await config.after_llm_call(state, llm_response)
697
+ else:
698
+ result = config.after_llm_call(state, llm_response)
699
+ if asyncio.iscoroutine(result):
700
+ llm_response = await result
701
+ else:
702
+ llm_response = result
664
703
 
665
704
  # Emit LLM call end event
666
705
  if config.on_event:
@@ -673,6 +712,9 @@ async def _run_internal(
673
712
 
674
713
  # Check if response has message
675
714
  if not llm_response.get('message'):
715
+ if config.log_empty_responses:
716
+ print(f"[JAF:ENGINE] ERROR: No message in LLM response")
717
+ print(f"[JAF:ENGINE] Response structure: {json.dumps(llm_response, indent=2)[:500]}")
676
718
  return RunResult(
677
719
  final_state=state,
678
720
  outcome=ErrorOutcome(error=ModelBehaviorError(