jaf-py 2.5.4__py3-none-any.whl → 2.5.7__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.7"
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,160 @@ 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
555
+
556
+ for retry_attempt in range(config.max_empty_response_retries + 1):
557
+ # Get completion from model provider
558
+ # Check if streaming should be used based on configuration and availability
559
+ get_stream = getattr(config.model_provider, "get_completion_stream", None)
560
+ use_streaming = (config.prefer_streaming != False and callable(get_stream))
544
561
 
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}",
562
+ if use_streaming:
563
+ try:
564
+ aggregated_text = ""
565
+ # Working array of partial tool calls
566
+ partial_tool_calls: List[Dict[str, Any]] = []
567
+
568
+ async for chunk in get_stream(state, current_agent, config): # type: ignore[arg-type]
569
+ # Text deltas
570
+ delta_text = getattr(chunk, "delta", None)
571
+ if delta_text:
572
+ aggregated_text += delta_text
573
+
574
+ # Tool call deltas
575
+ tcd = getattr(chunk, "tool_call_delta", None)
576
+ if tcd is not None:
577
+ idx = getattr(tcd, "index", 0) or 0
578
+ # Ensure slot exists
579
+ while len(partial_tool_calls) <= idx:
580
+ partial_tool_calls.append({
581
+ "id": None,
597
582
  "type": "function",
598
- "function": {
599
- "name": tc["function"]["name"] or "",
600
- "arguments": arguments
601
- }
583
+ "function": {"name": None, "arguments": ""}
602
584
  })
585
+ target = partial_tool_calls[idx]
586
+ # id
587
+ tc_id = getattr(tcd, "id", None)
588
+ if tc_id:
589
+ target["id"] = tc_id
590
+ # function fields
591
+ fn = getattr(tcd, "function", None)
592
+ if fn is not None:
593
+ fn_name = getattr(fn, "name", None)
594
+ if fn_name:
595
+ target["function"]["name"] = fn_name
596
+ args_delta = getattr(fn, "arguments_delta", None)
597
+ if args_delta:
598
+ target["function"]["arguments"] += args_delta
599
+
600
+ # Emit partial assistant message when something changed
601
+ if delta_text or tcd is not None:
602
+ assistant_event_streamed = True
603
+ # Normalize tool_calls for message
604
+ message_tool_calls = None
605
+ if len(partial_tool_calls) > 0:
606
+ message_tool_calls = []
607
+ for i, tc in enumerate(partial_tool_calls):
608
+ arguments = tc["function"]["arguments"]
609
+ if isinstance(arguments, str):
610
+ arguments = _normalize_tool_call_arguments(arguments)
611
+ message_tool_calls.append({
612
+ "id": tc["id"] or f"call_{i}",
613
+ "type": "function",
614
+ "function": {
615
+ "name": tc["function"]["name"] or "",
616
+ "arguments": arguments
617
+ }
618
+ })
619
+
620
+ partial_msg = Message(
621
+ role=ContentRole.ASSISTANT,
622
+ content=aggregated_text or "",
623
+ tool_calls=None if not message_tool_calls else [
624
+ ToolCall(
625
+ id=mc["id"],
626
+ type="function",
627
+ function=ToolCallFunction(
628
+ name=mc["function"]["name"],
629
+ arguments=_normalize_tool_call_arguments(mc["function"]["arguments"])
630
+ ),
631
+ ) for mc in message_tool_calls
632
+ ],
633
+ )
634
+ try:
635
+ if config.on_event:
636
+ config.on_event(AssistantMessageEvent(data=to_event_data(
637
+ AssistantMessageEventData(message=partial_msg)
638
+ )))
639
+ except Exception as _e:
640
+ # Do not fail the run on callback errors
641
+ pass
642
+
643
+ # Build final response object compatible with downstream logic
644
+ final_tool_calls = None
645
+ if len(partial_tool_calls) > 0:
646
+ final_tool_calls = []
647
+ for i, tc in enumerate(partial_tool_calls):
648
+ arguments = tc["function"]["arguments"]
649
+ if isinstance(arguments, str):
650
+ arguments = _normalize_tool_call_arguments(arguments)
651
+ final_tool_calls.append({
652
+ "id": tc["id"] or f"call_{i}",
653
+ "type": "function",
654
+ "function": {
655
+ "name": tc["function"]["name"] or "",
656
+ "arguments": arguments
657
+ }
658
+ })
603
659
 
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
660
+ llm_response = {
661
+ "message": {
662
+ "content": aggregated_text or None,
663
+ "tool_calls": final_tool_calls
664
+ }
648
665
  }
649
- }
650
- except Exception:
651
- # Fallback to non-streaming on error
652
- assistant_event_streamed = False
666
+ except Exception:
667
+ # Fallback to non-streaming on error
668
+ assistant_event_streamed = False
669
+ llm_response = await config.model_provider.get_completion(state, current_agent, config)
670
+ else:
653
671
  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)
672
+
673
+ # Check if response has meaningful content
674
+ has_content = llm_response.get('message', {}).get('content')
675
+ has_tool_calls = llm_response.get('message', {}).get('tool_calls')
676
+
677
+ # If we got a valid response, break out of retry loop
678
+ if has_content or has_tool_calls:
679
+ break
680
+
681
+ # If this is not the last attempt, retry with exponential backoff
682
+ if retry_attempt < config.max_empty_response_retries:
683
+ delay = config.empty_response_retry_delay * (2 ** retry_attempt)
684
+ if config.log_empty_responses:
685
+ print(f"[JAF:ENGINE] Empty LLM response on attempt {retry_attempt + 1}/{config.max_empty_response_retries + 1}, retrying in {delay:.1f}s...")
686
+ print(f"[JAF:ENGINE] Response had message: {bool(llm_response.get('message'))}, content: {bool(has_content)}, tool_calls: {bool(has_tool_calls)}")
687
+ await asyncio.sleep(delay)
688
+ else:
689
+ # Last attempt failed, log detailed diagnostic info
690
+ if config.log_empty_responses:
691
+ print(f"[JAF:ENGINE] Empty LLM response after {config.max_empty_response_retries + 1} attempts")
692
+ print(f"[JAF:ENGINE] Agent: {current_agent.name}, Model: {model}")
693
+ print(f"[JAF:ENGINE] Message count: {len(state.messages)}, Turn: {state.turn_count}")
694
+ print(f"[JAF:ENGINE] Response structure: {json.dumps(llm_response, indent=2)[:1000]}")
695
+
696
+ # Apply after_llm_call callback if provided
697
+ if config.after_llm_call:
698
+ if asyncio.iscoroutinefunction(config.after_llm_call):
699
+ llm_response = await config.after_llm_call(state, llm_response)
700
+ else:
701
+ result = config.after_llm_call(state, llm_response)
702
+ if asyncio.iscoroutine(result):
703
+ llm_response = await result
704
+ else:
705
+ llm_response = result
656
706
 
657
707
  # Emit LLM call end event
658
708
  if config.on_event:
@@ -665,6 +715,9 @@ async def _run_internal(
665
715
 
666
716
  # Check if response has message
667
717
  if not llm_response.get('message'):
718
+ if config.log_empty_responses:
719
+ print(f"[JAF:ENGINE] ERROR: No message in LLM response")
720
+ print(f"[JAF:ENGINE] Response structure: {json.dumps(llm_response, indent=2)[:500]}")
668
721
  return RunResult(
669
722
  final_state=state,
670
723
  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.7",
447
447
  httpx_client=client
448
448
  )
449
449
  self._httpx_client = client
jaf/core/types.py CHANGED
@@ -892,6 +892,13 @@ 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
+ prefer_streaming: Optional[bool] = None # Whether to prefer streaming responses. None (default) = use streaming if available, True = prefer streaming, False = disable streaming
901
+
895
902
 
896
903
  # Regeneration types for conversation management
897
904
  @dataclass(frozen=True)
jaf/providers/model.py CHANGED
@@ -219,6 +219,7 @@ def make_litellm_provider(
219
219
  request_params = {
220
220
  "model": model,
221
221
  "messages": messages,
222
+ "stream": False
222
223
  }
223
224
 
224
225
  # Add optional parameters
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jaf-py
3
- Version: 2.5.4
3
+ Version: 2.5.7
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.7-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=ZwZkJFpYcvCXUPt5JwqrCZrE44rd_OkD7VuZNzCWHiI,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=FUX3rSY0PLzsERRmVYLtEtMaEkTIkm4TMZaeg3fmnt8,61140
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=3FcJ1vr5Nm-dAhQCpVrT_VR_e-gQG74PuuhNAcfzQhU,53367
59
+ jaf/core/types.py,sha256=RLfFS5TpJiog5-mAVMH_7oM7ZEJqGsZBbrOQiUpGUM4,33042
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
@@ -74,7 +74,7 @@ jaf/policies/handoff.py,sha256=KJYYuL9T6v6DECRhnsS2Je6q4Aj9_zC5d_KBnvEnZNE,8318
74
74
  jaf/policies/validation.py,sha256=wn-7ynH10E5nk-_r1_kHIYHrBGmLX0EFr-FUTHrsxvc,10903
75
75
  jaf/providers/__init__.py,sha256=lIbl1JvGrDhI9CzEk79N8yJNhf7ww_aWD-F40MnG3vY,2174
76
76
  jaf/providers/mcp.py,sha256=WxcC8gUFpDBBYyhorMcc1jHq3xMDMBtnwyRPthfL0S0,13074
77
- jaf/providers/model.py,sha256=bN2Hhr0N3soZzMrCdJ1pJa4rvo80oedCphDPcNgrVMY,39336
77
+ jaf/providers/model.py,sha256=477Ly6D9OaZsUGsAoJtpSvdY_rLZ07ZjhlhapRzSxTI,39368
78
78
  jaf/server/__init__.py,sha256=fMPnLZBRm6t3yQrr7-PnoHAQ8qj9o6Z1AJLM1M6bIS0,392
79
79
  jaf/server/main.py,sha256=CTb0ywbPIq9ELfay5MKChVR7BpIQOoEbPjPfpzo2aBQ,2152
80
80
  jaf/server/server.py,sha256=cbdhVTPoWa7rVIX3DDLoXjppeGTKQWNFj3NA1jrZN88,46830
@@ -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.7.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
92
+ jaf_py-2.5.7.dist-info/METADATA,sha256=Imc7tiZbuvXTqZaDANIAMhDmzWfaWKK0kJzu0C3La4k,27743
93
+ jaf_py-2.5.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
94
+ jaf_py-2.5.7.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
95
+ jaf_py-2.5.7.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
96
+ jaf_py-2.5.7.dist-info/RECORD,,
File without changes