tactus 0.32.2__py3-none-any.whl → 0.34.0__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 (100) 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 +382 -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/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.dist-info → tactus-0.34.0.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
@@ -644,9 +823,112 @@ class DSPyAgentHandle:
644
823
  # Add assistant response to new_messages
645
824
  if hasattr(result_holder["result"], "response"):
646
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
+
647
872
  new_messages.append(assistant_msg)
648
873
  self._history.add(assistant_msg)
649
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
+
650
932
  # Wrap the result with message tracking
651
933
  wrapped_result = wrap_prediction(
652
934
  result_holder["result"],
@@ -726,6 +1008,38 @@ class DSPyAgentHandle:
726
1008
  # Add assistant response to new_messages
727
1009
  if hasattr(dspy_result, "response"):
728
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
+
729
1043
  new_messages.append(assistant_msg)
730
1044
  self._history.add(assistant_msg)
731
1045
 
@@ -819,6 +1133,54 @@ class DSPyAgentHandle:
819
1133
  if context:
820
1134
  opts["context"] = context
821
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
+ """
822
1184
  # Execute the turn (inlined from old turn() method)
823
1185
  self._turn_count += 1
824
1186
  logger.debug(f"Agent '{self.name}' turn {self._turn_count}")
@@ -848,6 +1210,9 @@ class DSPyAgentHandle:
848
1210
  config_kwargs["max_tokens"] = self.max_tokens
849
1211
  if self.model_type is not None:
850
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}")
851
1216
 
852
1217
  configure_lm(model_for_litellm, **config_kwargs)
853
1218
 
@@ -867,20 +1232,12 @@ class DSPyAgentHandle:
867
1232
  "user_message": user_message or "",
868
1233
  }
869
1234
 
870
- # 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
871
1237
  if self.tools or self.toolsets:
872
- # Format tools for the prompt
873
- tool_descriptions = []
874
- if self.toolsets:
875
- # Convert toolsets to strings if they're not already
876
- toolset_names = [str(ts) if not isinstance(ts, str) else ts for ts in self.toolsets]
877
- tool_descriptions.append(f"Available toolsets: {', '.join(toolset_names)}")
878
- tool_descriptions.append(
879
- "Use the 'done' tool with a 'reason' parameter to complete the task."
880
- )
881
- prompt_context["available_tools"] = (
882
- "\n".join(tool_descriptions) if tool_descriptions else "No tools available"
883
- )
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")
884
1241
 
885
1242
  # Add any injected context (user_message is already in prompt_context)
886
1243
  if context:
@@ -1094,6 +1451,7 @@ def create_dspy_agent(
1094
1451
  config: Dict[str, Any],
1095
1452
  registry: Any = None,
1096
1453
  mock_manager: Any = None,
1454
+ execution_context: Any = None,
1097
1455
  ) -> DSPyAgentHandle:
1098
1456
  """
1099
1457
  Create a DSPy-based Agent from configuration.
@@ -1111,6 +1469,7 @@ def create_dspy_agent(
1111
1469
  - Other optional configuration
1112
1470
  registry: Optional Registry instance for accessing mocks
1113
1471
  mock_manager: Optional MockManager instance for checking mocks
1472
+ execution_context: Optional ExecutionContext for checkpointing agent calls
1114
1473
 
1115
1474
  Returns:
1116
1475
  A DSPyAgentHandle instance
@@ -1141,6 +1500,7 @@ def create_dspy_agent(
1141
1500
  mock_manager=mock_manager,
1142
1501
  log_handler=config.get("log_handler"),
1143
1502
  disable_streaming=config.get("disable_streaming", False),
1503
+ execution_context=execution_context,
1144
1504
  **{
1145
1505
  k: v
1146
1506
  for k, v in config.items()
tactus/dspy/broker_lm.py CHANGED
@@ -10,6 +10,7 @@ while still supporting streaming via DSPy's `streamify()` mechanism.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import logging
13
14
  from typing import Any
14
15
 
15
16
  import dspy
@@ -19,6 +20,8 @@ from litellm import ModelResponse, ModelResponseStream
19
20
 
20
21
  from tactus.broker.client import BrokerClient
21
22
 
23
+ logger = logging.getLogger(__name__)
24
+
22
25
 
23
26
  def _split_provider_model(model: str) -> tuple[str, str]:
24
27
  if "/" not in model:
@@ -99,8 +102,16 @@ class BrokeredLM(dspy.BaseLM):
99
102
  caller_predict = dspy.settings.caller_predict
100
103
  caller_predict_id = id(caller_predict) if caller_predict else None
101
104
 
105
+ # Extract tools and tool_choice from kwargs
106
+ tools = merged_kwargs.get("tools")
107
+ tool_choice = merged_kwargs.get("tool_choice")
108
+
109
+ logger.debug(
110
+ f"[BROKER_LM] Calling LM with streaming={send_stream is not None}, tools={len(tools) if tools else 0}"
111
+ )
102
112
  if send_stream is not None:
103
113
  chunks: list[ModelResponseStream] = []
114
+ tool_calls_data = None
104
115
  async for event in self._client.llm_chat(
105
116
  provider="openai",
106
117
  model=model_id,
@@ -108,6 +119,8 @@ class BrokeredLM(dspy.BaseLM):
108
119
  temperature=temperature,
109
120
  max_tokens=max_tokens,
110
121
  stream=True,
122
+ tools=tools,
123
+ tool_choice=tool_choice,
111
124
  ):
112
125
  event_type = event.get("event")
113
126
  if event_type == "delta":
@@ -125,23 +138,51 @@ class BrokeredLM(dspy.BaseLM):
125
138
  continue
126
139
 
127
140
  if event_type == "done":
141
+ # Capture tool calls from done event
142
+ data = event.get("data") or {}
143
+ tool_calls_data = data.get("tool_calls")
144
+ logger.debug(
145
+ f"[BROKER_LM] Stream complete with {len(tool_calls_data) if tool_calls_data else 0} tool calls"
146
+ )
128
147
  break
129
148
 
130
149
  if event_type == "error":
131
150
  err = event.get("error") or {}
132
151
  raise RuntimeError(err.get("message") or "Broker LLM error")
133
152
 
153
+ # Build response manually to ensure tool_calls stay as plain dicts
154
+ # (stream_chunk_builder might convert them to typed objects)
155
+ full_text = ""
134
156
  if chunks:
135
- return litellm.stream_chunk_builder(chunks)
157
+ final_response = litellm.stream_chunk_builder(chunks)
158
+ if final_response.choices:
159
+ message = (
160
+ final_response.choices[0].get("message")
161
+ if isinstance(final_response.choices[0], dict)
162
+ else getattr(final_response.choices[0], "message", None)
163
+ )
164
+ if message:
165
+ full_text = (
166
+ message.get("content")
167
+ if isinstance(message, dict)
168
+ else getattr(message, "content", "") or ""
169
+ )
170
+
171
+ message_data = {"role": "assistant", "content": full_text}
172
+ finish_reason = "stop"
173
+
174
+ if tool_calls_data:
175
+ # Keep tool calls as plain dictionaries (already in OpenAI format from broker)
176
+ message_data["tool_calls"] = tool_calls_data
177
+ finish_reason = "tool_calls"
136
178
 
137
- # No streamed chunks; return an empty completion.
138
179
  return ModelResponse(
139
180
  model=model_id,
140
181
  choices=[
141
182
  {
142
183
  "index": 0,
143
- "finish_reason": "stop",
144
- "message": {"role": "assistant", "content": ""},
184
+ "finish_reason": finish_reason,
185
+ "message": message_data,
145
186
  }
146
187
  ],
147
188
  usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
@@ -149,6 +190,7 @@ class BrokeredLM(dspy.BaseLM):
149
190
 
150
191
  # Non-streaming path
151
192
  final_text = ""
193
+ tool_calls_data = None
152
194
  usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
153
195
  async for event in self._client.llm_chat(
154
196
  provider="openai",
@@ -157,24 +199,33 @@ class BrokeredLM(dspy.BaseLM):
157
199
  temperature=temperature,
158
200
  max_tokens=max_tokens,
159
201
  stream=False,
202
+ tools=tools,
203
+ tool_choice=tool_choice,
160
204
  ):
161
205
  event_type = event.get("event")
162
206
  if event_type == "done":
163
207
  data = event.get("data") or {}
164
208
  final_text = data.get("text") or ""
209
+ tool_calls_data = data.get("tool_calls")
165
210
  usage = data.get("usage") or usage
166
211
  break
167
212
  if event_type == "error":
168
213
  err = event.get("error") or {}
169
214
  raise RuntimeError(err.get("message") or "Broker LLM error")
170
215
 
216
+ # Build message response with tool calls if present
217
+ message_data = {"role": "assistant", "content": final_text}
218
+ if tool_calls_data:
219
+ # Keep tool calls as plain dictionaries (already in OpenAI format from broker)
220
+ message_data["tool_calls"] = tool_calls_data
221
+
171
222
  return ModelResponse(
172
223
  model=model_id,
173
224
  choices=[
174
225
  {
175
226
  "index": 0,
176
- "finish_reason": "stop",
177
- "message": {"role": "assistant", "content": final_text},
227
+ "finish_reason": "tool_calls" if tool_calls_data else "stop",
228
+ "message": message_data,
178
229
  }
179
230
  ],
180
231
  usage=usage,