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.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +403 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/config_server.py +536 -0
- tactus/ide/server.py +345 -21
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/METADATA +16 -2
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/RECORD +101 -49
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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()
|