jaf-py 2.5.4__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.4"
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
@@ -527,6 +527,17 @@ async def _run_internal(
527
527
  "gpt-4o"
528
528
  )
529
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
+
530
541
  # Emit LLM call start event
531
542
  if config.on_event:
532
543
  config.on_event(LLMCallStartEvent(data=to_event_data(LLMCallStartEventData(
@@ -538,121 +549,157 @@ async def _run_internal(
538
549
  messages=state.messages
539
550
  ))))
540
551
 
541
- # Get completion from model provider, prefer streaming if available
552
+ # Retry logic for empty LLM responses
542
553
  llm_response: Dict[str, Any]
543
554
  assistant_event_streamed = False
544
-
545
- get_stream = getattr(config.model_provider, "get_completion_stream", None)
546
- if callable(get_stream):
547
- try:
548
- aggregated_text = ""
549
- # Working array of partial tool calls
550
- partial_tool_calls: List[Dict[str, Any]] = []
551
-
552
- async for chunk in get_stream(state, current_agent, config): # type: ignore[arg-type]
553
- # Text deltas
554
- delta_text = getattr(chunk, "delta", None)
555
- if delta_text:
556
- aggregated_text += delta_text
557
-
558
- # Tool call deltas
559
- tcd = getattr(chunk, "tool_call_delta", None)
560
- if tcd is not None:
561
- idx = getattr(tcd, "index", 0) or 0
562
- # Ensure slot exists
563
- while len(partial_tool_calls) <= idx:
564
- partial_tool_calls.append({
565
- "id": None,
566
- "type": "function",
567
- "function": {"name": None, "arguments": ""}
568
- })
569
- target = partial_tool_calls[idx]
570
- # id
571
- tc_id = getattr(tcd, "id", None)
572
- if tc_id:
573
- target["id"] = tc_id
574
- # function fields
575
- fn = getattr(tcd, "function", None)
576
- if fn is not None:
577
- fn_name = getattr(fn, "name", None)
578
- if fn_name:
579
- target["function"]["name"] = fn_name
580
- args_delta = getattr(fn, "arguments_delta", None)
581
- if args_delta:
582
- target["function"]["arguments"] += args_delta
583
-
584
- # Emit partial assistant message when something changed
585
- if delta_text or tcd is not None:
586
- assistant_event_streamed = True
587
- # Normalize tool_calls for message
588
- message_tool_calls = None
589
- if len(partial_tool_calls) > 0:
590
- message_tool_calls = []
591
- for i, tc in enumerate(partial_tool_calls):
592
- arguments = tc["function"]["arguments"]
593
- if isinstance(arguments, str):
594
- arguments = _normalize_tool_call_arguments(arguments)
595
- message_tool_calls.append({
596
- "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,
597
579
  "type": "function",
598
- "function": {
599
- "name": tc["function"]["name"] or "",
600
- "arguments": arguments
601
- }
580
+ "function": {"name": None, "arguments": ""}
602
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
+ })
603
656
 
604
- partial_msg = Message(
605
- role=ContentRole.ASSISTANT,
606
- content=aggregated_text or "",
607
- tool_calls=None if not message_tool_calls else [
608
- ToolCall(
609
- id=mc["id"],
610
- type="function",
611
- function=ToolCallFunction(
612
- name=mc["function"]["name"],
613
- arguments=_normalize_tool_call_arguments(mc["function"]["arguments"])
614
- ),
615
- ) for mc in message_tool_calls
616
- ],
617
- )
618
- try:
619
- if config.on_event:
620
- config.on_event(AssistantMessageEvent(data=to_event_data(
621
- AssistantMessageEventData(message=partial_msg)
622
- )))
623
- except Exception as _e:
624
- # Do not fail the run on callback errors
625
- pass
626
-
627
- # Build final response object compatible with downstream logic
628
- final_tool_calls = None
629
- if len(partial_tool_calls) > 0:
630
- final_tool_calls = []
631
- for i, tc in enumerate(partial_tool_calls):
632
- arguments = tc["function"]["arguments"]
633
- if isinstance(arguments, str):
634
- arguments = _normalize_tool_call_arguments(arguments)
635
- final_tool_calls.append({
636
- "id": tc["id"] or f"call_{i}",
637
- "type": "function",
638
- "function": {
639
- "name": tc["function"]["name"] or "",
640
- "arguments": arguments
641
- }
642
- })
643
-
644
- llm_response = {
645
- "message": {
646
- "content": aggregated_text or None,
647
- "tool_calls": final_tool_calls
657
+ llm_response = {
658
+ "message": {
659
+ "content": aggregated_text or None,
660
+ "tool_calls": final_tool_calls
661
+ }
648
662
  }
649
- }
650
- except Exception:
651
- # Fallback to non-streaming on error
652
- 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:
653
668
  llm_response = await config.model_provider.get_completion(state, current_agent, config)
654
- else:
655
- 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
656
703
 
657
704
  # Emit LLM call end event
658
705
  if config.on_event:
@@ -665,6 +712,9 @@ async def _run_internal(
665
712
 
666
713
  # Check if response has message
667
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]}")
668
718
  return RunResult(
669
719
  final_state=state,
670
720
  outcome=ErrorOutcome(error=ModelBehaviorError(
jaf/core/tracing.py CHANGED
@@ -443,7 +443,7 @@ class LangfuseTraceCollector:
443
443
  public_key=public_key,
444
444
  secret_key=secret_key,
445
445
  host=host,
446
- release="jaf-py-v2.5.4",
446
+ release="jaf-py-v2.5.5",
447
447
  httpx_client=client
448
448
  )
449
449
  self._httpx_client = client
jaf/core/types.py CHANGED
@@ -892,6 +892,12 @@ class RunConfig(Generic[Ctx]):
892
892
  default_fast_model: Optional[str] = None # Default model for fast operations like guardrails
893
893
  default_tool_timeout: Optional[float] = 300.0 # Default timeout for tool execution in seconds
894
894
  approval_storage: Optional['ApprovalStorage'] = None # Storage for approval decisions
895
+ before_llm_call: Optional[Callable[[RunState[Ctx], Agent[Ctx, Any]], Union[RunState[Ctx], Awaitable[RunState[Ctx]]]]] = None # Callback before LLM call - can modify context/messages
896
+ after_llm_call: Optional[Callable[[RunState[Ctx], ModelCompletionResponse], Union[ModelCompletionResponse, Awaitable[ModelCompletionResponse]]]] = None # Callback after LLM call - can process response
897
+ max_empty_response_retries: int = 3 # Maximum retries when LLM returns empty response
898
+ empty_response_retry_delay: float = 1.0 # Initial delay in seconds before retrying empty response (uses exponential backoff)
899
+ log_empty_responses: bool = True # Whether to log diagnostic info for empty responses
900
+
895
901
 
896
902
  # Regeneration types for conversation management
897
903
  @dataclass(frozen=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jaf-py
3
- Version: 2.5.4
3
+ Version: 2.5.5
4
4
  Summary: A purely functional agent framework with immutable state and composable tools - Python implementation
5
5
  Author: JAF Contributors
6
6
  Maintainer: JAF Contributors
@@ -82,7 +82,7 @@ Dynamic: license-file
82
82
 
83
83
  <!-- ![JAF Banner](docs/cover.png) -->
84
84
 
85
- [![Version](https://img.shields.io/badge/version-2.5.4-blue.svg)](https://github.com/xynehq/jaf-py)
85
+ [![Version](https://img.shields.io/badge/version-2.5.5-blue.svg)](https://github.com/xynehq/jaf-py)
86
86
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
87
87
  [![Docs](https://img.shields.io/badge/Docs-Live-brightgreen)](https://xynehq.github.io/jaf-py/)
88
88
 
@@ -1,4 +1,4 @@
1
- jaf/__init__.py,sha256=05QV74KFtLBXDOyJnhFS4uco0WLSL22ipBRFc4gBMtY,8260
1
+ jaf/__init__.py,sha256=jzou4ny01tsRBZAb-ojUf4pjyu5LA32FRDt_QiHjSPs,8260
2
2
  jaf/cli.py,sha256=Af4di_NZ7rZ4wFl0R4EZh611NgJ--TL03vNyZ2M1_FY,8477
3
3
  jaf/exceptions.py,sha256=nl8JY355u7oTXB3PmC_LhnUaL8fzk2K4EaWM4fVpMPE,9196
4
4
  jaf/a2a/__init__.py,sha256=p4YVthZH0ow1ZECqWTQ0aQl8JWySYZb25jlzZJ09na4,7662
@@ -42,7 +42,7 @@ jaf/core/__init__.py,sha256=1VHV2-a1oJXIWcg8n5G5g2cmjw2QXv7OezncNB59KLw,1988
42
42
  jaf/core/agent_tool.py,sha256=tfLNaTIcOZ0dR9GBP1AHLPkLExm_dLbURnVIN4R84FQ,11806
43
43
  jaf/core/analytics.py,sha256=zFHIWqWal0bbEFCmJDc4DKeM0Ja7b_D19PqVaBI12pA,23338
44
44
  jaf/core/composition.py,sha256=IVxRO1Q9nK7JRH32qQ4p8WMIUu66BhqPNrlTNMGFVwE,26317
45
- jaf/core/engine.py,sha256=5SUYyUbLTEXm9sk56n21PRdgkhrbPoJiU2i55ZETqcE,57914
45
+ jaf/core/engine.py,sha256=gv2nnkiWbqD54ru7NE3-c__DjOgp4OtPSoY9ApaBZIc,61009
46
46
  jaf/core/errors.py,sha256=5fwTNhkojKRQ4wZj3lZlgDnAsrYyjYOwXJkIr5EGNUc,5539
47
47
  jaf/core/guardrails.py,sha256=nv7pQuCx7-9DDZrecWO1DsDqFoujL81FBDrafOsXgcI,26179
48
48
  jaf/core/handoff.py,sha256=ttjOQ6CSl34J4T_1ejdmq78gZ-ve07_IQE_DAbz2bmo,6002
@@ -55,8 +55,8 @@ jaf/core/state.py,sha256=oNCVXPWLkqnBQObdQX10TcmZ0eOF3wKG6DtL3kF6ohw,9649
55
55
  jaf/core/streaming.py,sha256=h_lYHQA9ee_D5QsDO9-Vhevgi7rFXPslPzd9605AJGo,17034
56
56
  jaf/core/tool_results.py,sha256=-bTOqOX02lMyslp5Z4Dmuhx0cLd5o7kgR88qK2HO_sw,11323
57
57
  jaf/core/tools.py,sha256=84N9A7QQ3xxcOs2eUUot3nmCnt5i7iZT9VwkuzuFBxQ,16274
58
- jaf/core/tracing.py,sha256=aIPhDtugRhdym5_IO8ES4Cm5qzc1zYDjdMsE2wsJ_as,53367
59
- jaf/core/types.py,sha256=GaAjeLimd8LNRIu6QnpsWGPtNCdlk77HdXoPtJdQ9eY,32154
58
+ jaf/core/tracing.py,sha256=GNOJ8cJ-1kIsrvx_WUEhihy6T-hYk6i5MFB_UVGAcwU,53367
59
+ jaf/core/types.py,sha256=xXkSV2ZE9oQea3JrU9XfWFvIHWUbZHHftJaZV-UAwHg,32860
60
60
  jaf/core/workflows.py,sha256=Ul-82gzjIXtkhnSMSPv-8igikjkMtW1EBo9yrfodtvI,26294
61
61
  jaf/memory/__init__.py,sha256=-L98xlvihurGAzF0DnXtkueDVvO_wV2XxxEwAWdAj50,1400
62
62
  jaf/memory/approval_storage.py,sha256=HHZ_b57kIthdR53QE5XNSII9xy1Cg-1cFUCSAZ8A4Rk,11083
@@ -88,9 +88,9 @@ jaf/visualization/functional_core.py,sha256=zedMDZbvjuOugWwnh6SJ2stvRNQX1Hlkb9Ab
88
88
  jaf/visualization/graphviz.py,sha256=WTOM6UP72-lVKwI4_SAr5-GCC3ouckxHv88ypCDQWJ0,12056
89
89
  jaf/visualization/imperative_shell.py,sha256=GpMrAlMnLo2IQgyB2nardCz09vMvAzaYI46MyrvJ0i4,2593
90
90
  jaf/visualization/types.py,sha256=QQcbVeQJLuAOXk8ynd08DXIS-PVCnv3R-XVE9iAcglw,1389
91
- jaf_py-2.5.4.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
92
- jaf_py-2.5.4.dist-info/METADATA,sha256=rI-ngB2xNPg5gIk_L5hq9j5pi_XdAyV3htqTpgtq0Pk,27743
93
- jaf_py-2.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
94
- jaf_py-2.5.4.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
95
- jaf_py-2.5.4.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
96
- jaf_py-2.5.4.dist-info/RECORD,,
91
+ jaf_py-2.5.5.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
92
+ jaf_py-2.5.5.dist-info/METADATA,sha256=QavZuyRtYw55yL1NQOmYfLIMxLhTu_V5yZaHOKOY4gY,27743
93
+ jaf_py-2.5.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
94
+ jaf_py-2.5.5.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
95
+ jaf_py-2.5.5.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
96
+ jaf_py-2.5.5.dist-info/RECORD,,
File without changes