tactus 0.31.0__py3-none-any.whl → 0.34.1__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.
Files changed (101) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +403 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/config_server.py +536 -0
  41. tactus/ide/server.py +345 -21
  42. tactus/primitives/human.py +619 -47
  43. tactus/primitives/system.py +0 -1
  44. tactus/protocols/__init__.py +25 -0
  45. tactus/protocols/control.py +427 -0
  46. tactus/protocols/notification.py +207 -0
  47. tactus/sandbox/container_runner.py +79 -11
  48. tactus/sandbox/docker_manager.py +23 -0
  49. tactus/sandbox/entrypoint.py +26 -0
  50. tactus/sandbox/protocol.py +3 -0
  51. tactus/stdlib/README.md +77 -0
  52. tactus/stdlib/__init__.py +27 -1
  53. tactus/stdlib/classify/__init__.py +165 -0
  54. tactus/stdlib/classify/classify.spec.tac +195 -0
  55. tactus/stdlib/classify/classify.tac +257 -0
  56. tactus/stdlib/classify/fuzzy.py +282 -0
  57. tactus/stdlib/classify/llm.py +319 -0
  58. tactus/stdlib/classify/primitive.py +287 -0
  59. tactus/stdlib/core/__init__.py +57 -0
  60. tactus/stdlib/core/base.py +320 -0
  61. tactus/stdlib/core/confidence.py +211 -0
  62. tactus/stdlib/core/models.py +161 -0
  63. tactus/stdlib/core/retry.py +171 -0
  64. tactus/stdlib/core/validation.py +274 -0
  65. tactus/stdlib/extract/__init__.py +125 -0
  66. tactus/stdlib/extract/llm.py +330 -0
  67. tactus/stdlib/extract/primitive.py +256 -0
  68. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  69. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  70. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  71. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  72. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  73. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  74. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  75. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  76. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  77. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  78. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  79. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  80. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  81. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  82. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  83. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  84. tactus/testing/behave_integration.py +171 -7
  85. tactus/testing/context.py +0 -1
  86. tactus/testing/evaluation_runner.py +0 -1
  87. tactus/testing/gherkin_parser.py +0 -1
  88. tactus/testing/mock_hitl.py +0 -1
  89. tactus/testing/mock_tools.py +0 -1
  90. tactus/testing/models.py +0 -1
  91. tactus/testing/steps/builtin.py +0 -1
  92. tactus/testing/steps/custom.py +81 -22
  93. tactus/testing/steps/registry.py +0 -1
  94. tactus/testing/test_runner.py +7 -1
  95. tactus/validation/semantic_visitor.py +11 -5
  96. tactus/validation/validator.py +0 -1
  97. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/METADATA +16 -2
  98. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/RECORD +101 -49
  99. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
  100. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
  101. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
tactus/dspy/agent.py CHANGED
@@ -12,6 +12,8 @@ The Agent uses:
12
12
  - Unified mocking via Mocks {} primitive
13
13
  """
14
14
 
15
+ import asyncio
16
+ import json
15
17
  import logging
16
18
  from typing import Any, Dict, List, Optional
17
19
 
@@ -61,6 +63,7 @@ class DSPyAgentHandle:
61
63
  mock_manager: Any = None,
62
64
  log_handler: Any = None,
63
65
  disable_streaming: bool = False,
66
+ execution_context: Any = None,
64
67
  **kwargs: Any,
65
68
  ):
66
69
  """
@@ -87,6 +90,7 @@ class DSPyAgentHandle:
87
90
  mock_manager: Optional MockManager instance for checking mocks
88
91
  log_handler: Optional log handler for emitting streaming events
89
92
  disable_streaming: If True, disable streaming even when log_handler is present
93
+ execution_context: Optional ExecutionContext for checkpointing agent calls
90
94
  **kwargs: Additional configuration
91
95
  """
92
96
  self.name = name
@@ -95,6 +99,8 @@ class DSPyAgentHandle:
95
99
  self.provider = provider
96
100
  self.tools = tools or []
97
101
  self.toolsets = toolsets or []
102
+ self.execution_context = execution_context
103
+ self._dspy_tools_cache = None # Cache for converted DSPy tools
98
104
  # Default input schema: {message: string}
99
105
  self.input_schema = input_schema or {"message": {"type": "string", "required": False}}
100
106
  # Default output schema: {response: string}
@@ -108,8 +114,18 @@ class DSPyAgentHandle:
108
114
  self.mock_manager = mock_manager
109
115
  self.log_handler = log_handler
110
116
  self.disable_streaming = disable_streaming
117
+ self.tool_choice = kwargs.get("tool_choice") # Extract tool_choice from kwargs
111
118
  self.kwargs = kwargs
112
119
 
120
+ # CRITICAL DEBUG: Log handler state at initialization
121
+ logger.info(
122
+ f"[AGENT_INIT] Agent '{self.name}' initialized with log_handler={log_handler is not None}, "
123
+ f"disable_streaming={disable_streaming}, "
124
+ f"log_handler_type={type(log_handler).__name__ if log_handler else 'None'}, "
125
+ f"tool_choice={self.tool_choice}, "
126
+ f"kwargs_keys={list(kwargs.keys())}"
127
+ )
128
+
113
129
  # Initialize conversation history
114
130
  self._history = create_history()
115
131
 
@@ -306,16 +322,173 @@ class DSPyAgentHandle:
306
322
  raise ValueError(f"Unknown module '{module}'. Supported: {list(mapping.keys())}")
307
323
  return strategy
308
324
 
325
+ def _convert_toolsets_to_dspy_tools_sync(self) -> list:
326
+ """
327
+ Convert Pydantic AI toolsets to DSPy Tool objects (synchronous version).
328
+
329
+ DSPy uses dspy.adapters.types.tool.Tool for native function calling.
330
+ Pydantic AI toolsets expose tools via .get_tools(ctx) method.
331
+
332
+ Returns:
333
+ List of DSPy Tool objects
334
+ """
335
+ try:
336
+ from dspy.adapters.types.tool import Tool as DSPyTool
337
+ except ImportError:
338
+ logger.error("Cannot import DSPyTool - DSPy installation may be incomplete")
339
+ return []
340
+
341
+ logger.info(f"Agent '{self.name}' has {len(self.toolsets)} toolsets to convert")
342
+
343
+ dspy_tools = []
344
+
345
+ # Convert toolsets to DSPy Tools
346
+ for idx, toolset in enumerate(self.toolsets):
347
+ logger.info(f"Agent '{self.name}' processing toolset {idx}: {type(toolset).__name__}")
348
+ try:
349
+ # Pydantic AI FunctionToolset has a .tools dict attribute that's directly accessible
350
+ # This avoids the need for async get_tools() call and RunContext
351
+ if hasattr(toolset, "tools") and isinstance(toolset.tools, dict):
352
+ pydantic_tools = list(toolset.tools.values())
353
+ logger.info(
354
+ f"Agent '{self.name}' toolset {idx} has {len(pydantic_tools)} tools (from .tools attribute)"
355
+ )
356
+ else:
357
+ logger.warning(
358
+ f"Toolset {toolset} doesn't have accessible .tools dict, skipping"
359
+ )
360
+ continue
361
+
362
+ for pydantic_tool in pydantic_tools:
363
+ # Pydantic AI Tool has: name, description, function_schema.json_schema, function
364
+ logger.info(
365
+ f"Agent '{self.name}' converting tool: name={pydantic_tool.name}, desc={pydantic_tool.description[:50] if pydantic_tool.description else 'N/A'}..."
366
+ )
367
+
368
+ # Extract parameter schema from Pydantic AI tool
369
+ tool_args = None
370
+ if hasattr(pydantic_tool, "function_schema") and hasattr(
371
+ pydantic_tool.function_schema, "json_schema"
372
+ ):
373
+ json_schema = pydantic_tool.function_schema.json_schema
374
+ if "properties" in json_schema:
375
+ # Convert JSON schema properties to DSPy's expected format
376
+ tool_args = json_schema["properties"]
377
+ logger.info(
378
+ f"Extracted parameter schema for '{pydantic_tool.name}': {tool_args}"
379
+ )
380
+
381
+ dspy_tool = DSPyTool(
382
+ func=pydantic_tool.function,
383
+ name=pydantic_tool.name,
384
+ desc=pydantic_tool.description,
385
+ args=tool_args, # Pass the parameter schema
386
+ )
387
+ dspy_tools.append(dspy_tool)
388
+ logger.info(
389
+ f"Converted tool '{pydantic_tool.name}' to DSPy Tool with args={tool_args}"
390
+ )
391
+
392
+ except Exception as e:
393
+ import traceback
394
+
395
+ logger.error(f"Failed to convert toolset {toolset} to DSPy Tools: {e}")
396
+ logger.error(f"Traceback: {traceback.format_exc()}")
397
+
398
+ logger.info(f"Agent '{self.name}' converted {len(dspy_tools)} tools to DSPy format")
399
+ return dspy_tools
400
+
401
+ def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
402
+ """
403
+ Execute a tool call using the available toolsets.
404
+
405
+ Args:
406
+ tool_name: Name of the tool to execute
407
+ tool_args: Arguments to pass to the tool
408
+
409
+ Returns:
410
+ Tool execution result
411
+ """
412
+ logger.info(f"[TOOL_EXEC] Executing tool '{tool_name}' with args: {tool_args}")
413
+
414
+ # Find the tool in our toolsets
415
+ for toolset in self.toolsets:
416
+ if hasattr(toolset, "tools") and isinstance(toolset.tools, dict):
417
+ for pydantic_tool in toolset.tools.values():
418
+ if pydantic_tool.name == tool_name:
419
+ logger.info(f"[TOOL_EXEC] Found tool '{tool_name}' in toolset")
420
+ try:
421
+ # Call the Pydantic AI tool function
422
+ # The tool function might be async (wrapped Lua tools are)
423
+ import asyncio
424
+ import inspect
425
+
426
+ # Check if the function is async before calling it
427
+ if inspect.iscoroutinefunction(pydantic_tool.function):
428
+ logger.info(
429
+ f"[TOOL_EXEC] Tool '{tool_name}' is async, running with nest_asyncio"
430
+ )
431
+ # Use nest_asyncio to allow running async code from sync context
432
+ # even when there's already an event loop running
433
+ try:
434
+ import nest_asyncio
435
+
436
+ nest_asyncio.apply()
437
+ except ImportError:
438
+ logger.warning(
439
+ "[TOOL_EXEC] nest_asyncio not available, trying asyncio.run()"
440
+ )
441
+
442
+ # Get the current event loop or create new one
443
+ try:
444
+ loop = asyncio.get_event_loop()
445
+ if loop.is_running():
446
+ logger.info(
447
+ "[TOOL_EXEC] Loop is running, using run_until_complete with nest_asyncio"
448
+ )
449
+ # nest_asyncio allows this even though loop is running
450
+ coro = pydantic_tool.function(**tool_args)
451
+ result = loop.run_until_complete(coro)
452
+ else:
453
+ logger.info(
454
+ "[TOOL_EXEC] Loop not running, using run_until_complete"
455
+ )
456
+ coro = pydantic_tool.function(**tool_args)
457
+ result = loop.run_until_complete(coro)
458
+ except RuntimeError:
459
+ # No loop at all
460
+ logger.info("[TOOL_EXEC] No event loop, using asyncio.run()")
461
+ result = asyncio.run(pydantic_tool.function(**tool_args))
462
+ else:
463
+ # Function is sync - just call it
464
+ logger.info(
465
+ f"[TOOL_EXEC] Tool '{tool_name}' is sync, calling directly"
466
+ )
467
+ result = pydantic_tool.function(**tool_args)
468
+
469
+ logger.info(f"[TOOL_EXEC] Tool '{tool_name}' returned: {result}")
470
+ return result
471
+ except Exception as e:
472
+ logger.error(
473
+ f"[TOOL_EXEC] Tool '{tool_name}' execution failed: {e}",
474
+ exc_info=True,
475
+ )
476
+ return {"error": str(e)}
477
+
478
+ logger.warning(f"[TOOL_EXEC] Tool '{tool_name}' not found in any toolset")
479
+ return {"error": f"Tool '{tool_name}' not found"}
480
+
309
481
  def _build_module(self) -> TactusModule:
310
482
  """Build the internal DSPy module for this agent."""
311
483
  # Create a signature for agent turns
312
- # Input: system_prompt, history, user_message, available_tools
484
+ # Input: system_prompt, history, user_message
485
+ # If tools available: also include tools as structured list[dspy.Tool]
313
486
  # Output: response and tool_calls (if tools are needed)
314
- # Include tools in the signature if they're available
487
+
488
+ # Use DSPy's native function calling with structured tool input
489
+ # See: dspy/adapters/base.py - adapter preprocesses tools field
315
490
  if self.tools or self.toolsets:
316
- signature = (
317
- "system_prompt, history, user_message, available_tools -> response, tool_calls"
318
- )
491
+ signature = "system_prompt, history, user_message, tools: list[dspy.Tool] -> response, tool_calls: dspy.ToolCalls"
319
492
  else:
320
493
  signature = "system_prompt, history, user_message -> response"
321
494
 
@@ -340,22 +513,29 @@ class DSPyAgentHandle:
340
513
  Returns:
341
514
  True if streaming should be enabled
342
515
  """
516
+ # CRITICAL DEBUG: Always log entry
517
+ logger.info(f"[STREAMING] Agent '{self.name}': _should_stream() called")
518
+
343
519
  # Must have log_handler to emit streaming events
344
520
  if self.log_handler is None:
345
- logger.debug(f"[STREAMING] Agent '{self.name}': no log_handler, streaming disabled")
521
+ logger.info(f"[STREAMING] Agent '{self.name}': no log_handler, streaming disabled")
346
522
  return False
347
523
 
348
524
  # Allow log handlers to opt out of streaming (e.g., cost-only collectors)
349
525
  supports_streaming = getattr(self.log_handler, "supports_streaming", True)
526
+ logger.info(
527
+ f"[STREAMING] Agent '{self.name}': log_handler.supports_streaming={supports_streaming}"
528
+ )
350
529
  if not supports_streaming:
351
- logger.debug(
530
+ logger.info(
352
531
  f"[STREAMING] Agent '{self.name}': log_handler supports_streaming=False, streaming disabled"
353
532
  )
354
533
  return False
355
534
 
356
535
  # Respect explicit disable flag
536
+ logger.info(f"[STREAMING] Agent '{self.name}': disable_streaming={self.disable_streaming}")
357
537
  if self.disable_streaming:
358
- logger.debug(
538
+ logger.info(
359
539
  f"[STREAMING] Agent '{self.name}': disable_streaming=True, streaming disabled"
360
540
  )
361
541
  return False
@@ -480,7 +660,6 @@ class DSPyAgentHandle:
480
660
  Returns:
481
661
  TactusResult with value, usage, and cost_stats
482
662
  """
483
- import asyncio
484
663
  import threading
485
664
  import queue
486
665
  from tactus.protocols.models import AgentTurnEvent, AgentStreamChunkEvent
@@ -599,6 +778,27 @@ class DSPyAgentHandle:
599
778
 
600
779
  # Check for errors
601
780
  if result_holder["error"] is not None:
781
+ error = result_holder["error"]
782
+
783
+ # Unwrap ExceptionGroup to find the real error
784
+ original_error = error
785
+ if hasattr(error, "__class__") and error.__class__.__name__ == "ExceptionGroup":
786
+ # Python 3.11+ ExceptionGroup
787
+ if hasattr(error, "exceptions") and error.exceptions:
788
+ original_error = error.exceptions[0]
789
+
790
+ # Check if it's an authentication error (in original or wrapped)
791
+ error_str = str(original_error).lower()
792
+ error_type = str(type(original_error).__name__)
793
+
794
+ if "authenticationerror" in error_type.lower() or "api_key" in error_str:
795
+ from tactus.core.exceptions import TactusRuntimeError
796
+
797
+ raise TactusRuntimeError(
798
+ f"API authentication failed for agent '{self.name}': "
799
+ f"Missing or invalid API key. Please configure your API key in Settings (Cmd+,)."
800
+ ) from error
801
+
602
802
  raise result_holder["error"]
603
803
 
604
804
  # If streaming failed to produce a result, fall back to non-streaming
@@ -623,9 +823,112 @@ class DSPyAgentHandle:
623
823
  # Add assistant response to new_messages
624
824
  if hasattr(result_holder["result"], "response"):
625
825
  assistant_msg = {"role": "assistant", "content": result_holder["result"].response}
826
+
827
+ # Include tool calls in the message if present (before wrapping)
828
+ has_tc = hasattr(result_holder["result"], "tool_calls")
829
+ tc_value = getattr(result_holder["result"], "tool_calls", None)
830
+ logger.info(
831
+ f"[ASYNC_STREAMING] Agent '{self.name}' result: has_tool_calls={has_tc}, tool_calls={tc_value}"
832
+ )
833
+ if (
834
+ hasattr(result_holder["result"], "tool_calls")
835
+ and result_holder["result"].tool_calls
836
+ ):
837
+ # Convert tool calls to JSON-serializable format
838
+ logger.info("[ASYNC_STREAMING] Converting tool_calls to dict format")
839
+ tool_calls_list = []
840
+ tc_obj = result_holder["result"].tool_calls
841
+ has_tc_attr = hasattr(tc_obj, "tool_calls")
842
+ logger.info(
843
+ f"[ASYNC_STREAMING] tool_calls object: type={type(tc_obj)}, has_tool_calls_attr={has_tc_attr}"
844
+ )
845
+ for tc in (
846
+ result_holder["result"].tool_calls.tool_calls
847
+ if hasattr(result_holder["result"].tool_calls, "tool_calls")
848
+ else []
849
+ ):
850
+ logger.info(
851
+ f"[ASYNC_STREAMING] Processing tool call: name={tc.name} args={tc.args}"
852
+ )
853
+ tool_calls_list.append(
854
+ {
855
+ "id": f"call_{tc.name}", # Generate a simple ID
856
+ "type": "function",
857
+ "function": {
858
+ "name": tc.name,
859
+ "arguments": (
860
+ json.dumps(tc.args) if isinstance(tc.args, dict) else tc.args
861
+ ),
862
+ },
863
+ }
864
+ )
865
+ logger.info(
866
+ f"[ASYNC_STREAMING] Built tool_calls_list with {len(tool_calls_list)} items"
867
+ )
868
+ if tool_calls_list:
869
+ assistant_msg["tool_calls"] = tool_calls_list
870
+ logger.info("[ASYNC_STREAMING] Added tool_calls to assistant_msg")
871
+
626
872
  new_messages.append(assistant_msg)
627
873
  self._history.add(assistant_msg)
628
874
 
875
+ # Execute tool calls and add tool result messages to history
876
+ if assistant_msg.get("tool_calls"):
877
+ logger.info(
878
+ f"[ASYNC_STREAMING] Agent '{self.name}' executing {len(assistant_msg['tool_calls'])} tool calls"
879
+ )
880
+ for tc in assistant_msg["tool_calls"]:
881
+ tool_name = tc["function"]["name"]
882
+ tool_args_str = tc["function"]["arguments"]
883
+ tool_args = (
884
+ json.loads(tool_args_str)
885
+ if isinstance(tool_args_str, str)
886
+ else tool_args_str
887
+ )
888
+ tool_id = tc["id"]
889
+
890
+ logger.info(
891
+ f"[ASYNC_STREAMING] Executing tool: {tool_name} with args: {tool_args}"
892
+ )
893
+
894
+ # Execute the tool using toolsets
895
+ tool_result = self._execute_tool(tool_name, tool_args)
896
+ logger.info(f"[ASYNC_STREAMING] Tool executed successfully: {tool_result}")
897
+
898
+ # Record the tool call so Lua can check if it was called
899
+ tool_primitive = getattr(self, "_tool_primitive", None)
900
+ if tool_primitive:
901
+ # Remove agent name prefix from tool name if present
902
+ # Tool names are stored as "agent_name_tool_name" in the primitive
903
+ clean_tool_name = tool_name.replace(f"{self.name}_", "")
904
+ tool_primitive.record_call(
905
+ clean_tool_name, tool_args, tool_result, agent_name=self.name
906
+ )
907
+ logger.info(f"[ASYNC_STREAMING] Recorded tool call: {clean_tool_name}")
908
+
909
+ # Add tool result to history in OpenAI's expected format
910
+ # OpenAI requires: role="tool", tool_call_id=<id>, content=<result>
911
+ tool_result_str = (
912
+ json.dumps(tool_result)
913
+ if isinstance(tool_result, dict)
914
+ else str(tool_result)
915
+ )
916
+ tool_result_msg = {
917
+ "role": "tool",
918
+ "tool_call_id": tool_id,
919
+ "name": tool_name,
920
+ "content": tool_result_str,
921
+ }
922
+ logger.info(f"[ASYNC_STREAMING] Created tool result message: {tool_result_msg}")
923
+ new_messages.append(tool_result_msg)
924
+ logger.info(
925
+ f"[ASYNC_STREAMING] Added tool result to new_messages, count={len(new_messages)}"
926
+ )
927
+ self._history.add(tool_result_msg)
928
+ logger.info(
929
+ f"[ASYNC_STREAMING] Added tool result to history for tool_call_id={tool_id}, history size={len(self._history)}"
930
+ )
931
+
629
932
  # Wrap the result with message tracking
630
933
  wrapped_result = wrap_prediction(
631
934
  result_holder["result"],
@@ -705,6 +1008,38 @@ class DSPyAgentHandle:
705
1008
  # Add assistant response to new_messages
706
1009
  if hasattr(dspy_result, "response"):
707
1010
  assistant_msg = {"role": "assistant", "content": dspy_result.response}
1011
+
1012
+ # Include tool calls in the message if present (before wrapping)
1013
+ has_tc = hasattr(dspy_result, "tool_calls")
1014
+ tc_value = getattr(dspy_result, "tool_calls", None)
1015
+ logger.info(
1016
+ f"Agent '{self.name}' dspy_result: has_tool_calls={has_tc}, tool_calls={tc_value}"
1017
+ )
1018
+ if hasattr(dspy_result, "tool_calls") and dspy_result.tool_calls:
1019
+ # Convert tool calls to JSON-serializable format
1020
+ tool_calls_list = []
1021
+ for tc in (
1022
+ dspy_result.tool_calls.tool_calls
1023
+ if hasattr(dspy_result.tool_calls, "tool_calls")
1024
+ else []
1025
+ ):
1026
+ tool_calls_list.append(
1027
+ {
1028
+ "id": f"call_{tc['name']}", # Generate a simple ID
1029
+ "type": "function",
1030
+ "function": {
1031
+ "name": tc["name"],
1032
+ "arguments": (
1033
+ json.dumps(tc["args"])
1034
+ if isinstance(tc["args"], dict)
1035
+ else tc["args"]
1036
+ ),
1037
+ },
1038
+ }
1039
+ )
1040
+ if tool_calls_list:
1041
+ assistant_msg["tool_calls"] = tool_calls_list
1042
+
708
1043
  new_messages.append(assistant_msg)
709
1044
  self._history.add(assistant_msg)
710
1045
 
@@ -798,6 +1133,54 @@ class DSPyAgentHandle:
798
1133
  if context:
799
1134
  opts["context"] = context
800
1135
 
1136
+ # If execution_context is available, wrap in checkpoint for transparent durability
1137
+ if self.execution_context:
1138
+
1139
+ def checkpoint_fn():
1140
+ return self._execute_turn(opts)
1141
+
1142
+ result = self.execution_context.checkpoint(checkpoint_fn, f"agent_{self.name}_turn")
1143
+ else:
1144
+ # No checkpointing - execute directly
1145
+ result = self._execute_turn(opts)
1146
+
1147
+ # Mirror AgentHandle convenience for Lua patterns like `agent(); return agent.output`.
1148
+ output_text = None
1149
+ if result is not None:
1150
+ for attr in ("response", "message"):
1151
+ try:
1152
+ value = getattr(result, attr, None)
1153
+ except Exception:
1154
+ value = None
1155
+ if isinstance(value, str):
1156
+ output_text = value
1157
+ break
1158
+
1159
+ if output_text is None and isinstance(result, dict):
1160
+ for key in ("response", "message"):
1161
+ value = result.get(key)
1162
+ if isinstance(value, str):
1163
+ output_text = value
1164
+ break
1165
+
1166
+ if output_text is None:
1167
+ output_text = str(result)
1168
+
1169
+ self.output = output_text
1170
+ return result
1171
+
1172
+ def _execute_turn(self, opts: Dict[str, Any]) -> Any:
1173
+ """
1174
+ Execute a single agent turn (internal method for checkpointing).
1175
+
1176
+ This method contains the core agent execution logic that gets checkpointed.
1177
+
1178
+ Args:
1179
+ opts: Turn options with message, context, and per-turn overrides
1180
+
1181
+ Returns:
1182
+ Result object with response and other fields
1183
+ """
801
1184
  # Execute the turn (inlined from old turn() method)
802
1185
  self._turn_count += 1
803
1186
  logger.debug(f"Agent '{self.name}' turn {self._turn_count}")
@@ -827,6 +1210,9 @@ class DSPyAgentHandle:
827
1210
  config_kwargs["max_tokens"] = self.max_tokens
828
1211
  if self.model_type is not None:
829
1212
  config_kwargs["model_type"] = self.model_type
1213
+ if self.tool_choice is not None and (self.tools or self.toolsets):
1214
+ config_kwargs["tool_choice"] = self.tool_choice
1215
+ logger.info(f"Configuring LM with tool_choice={self.tool_choice}")
830
1216
 
831
1217
  configure_lm(model_for_litellm, **config_kwargs)
832
1218
 
@@ -846,20 +1232,12 @@ class DSPyAgentHandle:
846
1232
  "user_message": user_message or "",
847
1233
  }
848
1234
 
849
- # Add available tools if agent has them
1235
+ # Add tools as structured DSPy Tool objects if agent has them
1236
+ # DSPy's adapter will convert these to OpenAI function call format
850
1237
  if self.tools or self.toolsets:
851
- # Format tools for the prompt
852
- tool_descriptions = []
853
- if self.toolsets:
854
- # Convert toolsets to strings if they're not already
855
- toolset_names = [str(ts) if not isinstance(ts, str) else ts for ts in self.toolsets]
856
- tool_descriptions.append(f"Available toolsets: {', '.join(toolset_names)}")
857
- tool_descriptions.append(
858
- "Use the 'done' tool with a 'reason' parameter to complete the task."
859
- )
860
- prompt_context["available_tools"] = (
861
- "\n".join(tool_descriptions) if tool_descriptions else "No tools available"
862
- )
1238
+ dspy_tools = self._convert_toolsets_to_dspy_tools_sync()
1239
+ prompt_context["tools"] = dspy_tools
1240
+ logger.info(f"Agent '{self.name}' passing {len(dspy_tools)} DSPy tools to module")
863
1241
 
864
1242
  # Add any injected context (user_message is already in prompt_context)
865
1243
  if context:
@@ -1073,6 +1451,7 @@ def create_dspy_agent(
1073
1451
  config: Dict[str, Any],
1074
1452
  registry: Any = None,
1075
1453
  mock_manager: Any = None,
1454
+ execution_context: Any = None,
1076
1455
  ) -> DSPyAgentHandle:
1077
1456
  """
1078
1457
  Create a DSPy-based Agent from configuration.
@@ -1090,6 +1469,7 @@ def create_dspy_agent(
1090
1469
  - Other optional configuration
1091
1470
  registry: Optional Registry instance for accessing mocks
1092
1471
  mock_manager: Optional MockManager instance for checking mocks
1472
+ execution_context: Optional ExecutionContext for checkpointing agent calls
1093
1473
 
1094
1474
  Returns:
1095
1475
  A DSPyAgentHandle instance
@@ -1120,6 +1500,7 @@ def create_dspy_agent(
1120
1500
  mock_manager=mock_manager,
1121
1501
  log_handler=config.get("log_handler"),
1122
1502
  disable_streaming=config.get("disable_streaming", False),
1503
+ execution_context=execution_context,
1123
1504
  **{
1124
1505
  k: v
1125
1506
  for k, v in config.items()