tactus 0.33.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 +382 -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/server.py +300 -9
- 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.33.0.dist-info → tactus-0.34.1.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.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
|
|
@@ -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
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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":
|
|
144
|
-
"message":
|
|
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":
|
|
227
|
+
"finish_reason": "tool_calls" if tool_calls_data else "stop",
|
|
228
|
+
"message": message_data,
|
|
178
229
|
}
|
|
179
230
|
],
|
|
180
231
|
usage=usage,
|