tactus 0.34.1__py3-none-any.whl → 0.35.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/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +40 -25
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/METADATA +15 -3
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/licenses/LICENSE +0 -0
tactus/adapters/file_storage.py
CHANGED
|
@@ -6,7 +6,7 @@ Stores procedure metadata and execution log as JSON files on disk.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Optional
|
|
9
|
+
from typing import Any, Optional
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
|
|
12
12
|
from tactus.protocols.models import (
|
|
@@ -67,20 +67,20 @@ class FileStorage:
|
|
|
67
67
|
return {}
|
|
68
68
|
|
|
69
69
|
try:
|
|
70
|
-
with open(file_path, "r") as
|
|
71
|
-
return json.load(
|
|
72
|
-
except (json.JSONDecodeError, IOError) as
|
|
73
|
-
raise RuntimeError(f"Failed to read procedure file {file_path}: {
|
|
70
|
+
with open(file_path, "r") as file_handle:
|
|
71
|
+
return json.load(file_handle)
|
|
72
|
+
except (json.JSONDecodeError, IOError) as error:
|
|
73
|
+
raise RuntimeError(f"Failed to read procedure file {file_path}: {error}")
|
|
74
74
|
|
|
75
75
|
def _write_file(self, procedure_id: str, data: dict) -> None:
|
|
76
76
|
"""Write procedure data to file."""
|
|
77
77
|
file_path = self._get_file_path(procedure_id)
|
|
78
78
|
|
|
79
79
|
try:
|
|
80
|
-
with open(file_path, "w") as
|
|
81
|
-
json.dump(data,
|
|
82
|
-
except (IOError, OSError) as
|
|
83
|
-
raise RuntimeError(f"Failed to write procedure file {file_path}: {
|
|
80
|
+
with open(file_path, "w") as file_handle:
|
|
81
|
+
json.dump(data, file_handle, indent=2, default=str)
|
|
82
|
+
except (IOError, OSError) as error:
|
|
83
|
+
raise RuntimeError(f"Failed to write procedure file {file_path}: {error}")
|
|
84
84
|
|
|
85
85
|
def _deserialize_result(self, result: Any) -> Any:
|
|
86
86
|
"""Deserialize checkpoint result, reconstructing Pydantic models."""
|
|
@@ -189,25 +189,25 @@ class FileStorage:
|
|
|
189
189
|
self, procedure_id: str, status: str, waiting_on_message_id: Optional[str] = None
|
|
190
190
|
) -> None:
|
|
191
191
|
"""Update procedure status."""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
self.save_procedure_metadata(procedure_id,
|
|
192
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
193
|
+
procedure_metadata.status = status
|
|
194
|
+
procedure_metadata.waiting_on_message_id = waiting_on_message_id
|
|
195
|
+
self.save_procedure_metadata(procedure_id, procedure_metadata)
|
|
196
196
|
|
|
197
|
-
def get_state(self, procedure_id: str) ->
|
|
197
|
+
def get_state(self, procedure_id: str) -> dict[str, Any]:
|
|
198
198
|
"""Get mutable state dictionary."""
|
|
199
|
-
|
|
200
|
-
return
|
|
199
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
200
|
+
return procedure_metadata.state
|
|
201
201
|
|
|
202
|
-
def set_state(self, procedure_id: str, state:
|
|
202
|
+
def set_state(self, procedure_id: str, state: dict[str, Any]) -> None:
|
|
203
203
|
"""Set mutable state dictionary."""
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
self.save_procedure_metadata(procedure_id,
|
|
204
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
205
|
+
procedure_metadata.state = state
|
|
206
|
+
self.save_procedure_metadata(procedure_id, procedure_metadata)
|
|
207
207
|
|
|
208
208
|
# Tracing & Debugging Methods
|
|
209
209
|
|
|
210
|
-
def _load_index(self) ->
|
|
210
|
+
def _load_index(self) -> dict[str, Any]:
|
|
211
211
|
"""Load the run index."""
|
|
212
212
|
if not self.index_file.exists():
|
|
213
213
|
return {}
|
|
@@ -218,13 +218,13 @@ class FileStorage:
|
|
|
218
218
|
except (json.JSONDecodeError, IOError):
|
|
219
219
|
return {}
|
|
220
220
|
|
|
221
|
-
def _save_index(self, index:
|
|
221
|
+
def _save_index(self, index: dict[str, Any]) -> None:
|
|
222
222
|
"""Save the run index."""
|
|
223
223
|
try:
|
|
224
|
-
with open(self.index_file, "w") as
|
|
225
|
-
json.dump(index,
|
|
226
|
-
except (IOError, OSError) as
|
|
227
|
-
raise RuntimeError(f"Failed to write index file: {
|
|
224
|
+
with open(self.index_file, "w") as file_handle:
|
|
225
|
+
json.dump(index, file_handle, indent=2, default=str)
|
|
226
|
+
except (IOError, OSError) as error:
|
|
227
|
+
raise RuntimeError(f"Failed to write index file: {error}")
|
|
228
228
|
|
|
229
229
|
def _update_index(self, run: ExecutionRun) -> None:
|
|
230
230
|
"""Update index with run metadata."""
|
|
@@ -263,10 +263,10 @@ class FileStorage:
|
|
|
263
263
|
checkpoint["timestamp"] = checkpoint["timestamp"].isoformat()
|
|
264
264
|
|
|
265
265
|
try:
|
|
266
|
-
with open(run_path, "w") as
|
|
267
|
-
json.dump(data,
|
|
268
|
-
except (IOError, OSError) as
|
|
269
|
-
raise RuntimeError(f"Failed to save run {run.run_id}: {
|
|
266
|
+
with open(run_path, "w") as file_handle:
|
|
267
|
+
json.dump(data, file_handle, indent=2, default=str)
|
|
268
|
+
except (IOError, OSError) as error:
|
|
269
|
+
raise RuntimeError(f"Failed to save run {run.run_id}: {error}")
|
|
270
270
|
|
|
271
271
|
# Update index
|
|
272
272
|
self._update_index(run)
|
|
@@ -290,10 +290,10 @@ class FileStorage:
|
|
|
290
290
|
raise FileNotFoundError(f"Run {run_id} not found")
|
|
291
291
|
|
|
292
292
|
try:
|
|
293
|
-
with open(run_path, "r") as
|
|
294
|
-
data = json.load(
|
|
295
|
-
except (json.JSONDecodeError, IOError) as
|
|
296
|
-
raise RuntimeError(f"Failed to load run {run_id}: {
|
|
293
|
+
with open(run_path, "r") as file_handle:
|
|
294
|
+
data = json.load(file_handle)
|
|
295
|
+
except (json.JSONDecodeError, IOError) as error:
|
|
296
|
+
raise RuntimeError(f"Failed to load run {run_id}: {error}")
|
|
297
297
|
|
|
298
298
|
# Convert timestamps back to datetime objects
|
|
299
299
|
if data.get("start_time"):
|
|
@@ -303,28 +303,30 @@ class FileStorage:
|
|
|
303
303
|
|
|
304
304
|
# Convert checkpoint timestamps and rebuild CheckpointEntry objects
|
|
305
305
|
execution_log = []
|
|
306
|
-
for
|
|
307
|
-
if
|
|
308
|
-
|
|
306
|
+
for checkpoint_data in data.get("execution_log", []):
|
|
307
|
+
if checkpoint_data.get("timestamp"):
|
|
308
|
+
checkpoint_data["timestamp"] = datetime.fromisoformat(checkpoint_data["timestamp"])
|
|
309
309
|
|
|
310
310
|
# Rebuild SourceLocation if present
|
|
311
|
-
if
|
|
312
|
-
|
|
311
|
+
if checkpoint_data.get("source_location"):
|
|
312
|
+
checkpoint_data["source_location"] = SourceLocation(
|
|
313
|
+
**checkpoint_data["source_location"]
|
|
314
|
+
)
|
|
313
315
|
|
|
314
|
-
execution_log.append(CheckpointEntry(**
|
|
316
|
+
execution_log.append(CheckpointEntry(**checkpoint_data))
|
|
315
317
|
|
|
316
318
|
data["execution_log"] = execution_log
|
|
317
319
|
|
|
318
320
|
# Rebuild Breakpoint objects
|
|
319
321
|
breakpoints = []
|
|
320
|
-
for
|
|
321
|
-
breakpoints.append(Breakpoint(**
|
|
322
|
+
for breakpoint_data in data.get("breakpoints", []):
|
|
323
|
+
breakpoints.append(Breakpoint(**breakpoint_data))
|
|
322
324
|
|
|
323
325
|
data["breakpoints"] = breakpoints
|
|
324
326
|
|
|
325
327
|
return ExecutionRun(**data)
|
|
326
328
|
|
|
327
|
-
def list_runs(self, procedure_name: Optional[str] = None) ->
|
|
329
|
+
def list_runs(self, procedure_name: Optional[str] = None) -> list[ExecutionRun]:
|
|
328
330
|
"""
|
|
329
331
|
List all runs, optionally filtered by procedure name.
|
|
330
332
|
|
|
@@ -339,7 +341,9 @@ class FileStorage:
|
|
|
339
341
|
# Filter by procedure name if specified
|
|
340
342
|
if procedure_name:
|
|
341
343
|
run_ids = [
|
|
342
|
-
|
|
344
|
+
run_id
|
|
345
|
+
for run_id, info in index.items()
|
|
346
|
+
if info.get("procedure_name") == procedure_name
|
|
343
347
|
]
|
|
344
348
|
else:
|
|
345
349
|
run_ids = list(index.keys())
|
|
@@ -358,7 +362,7 @@ class FileStorage:
|
|
|
358
362
|
|
|
359
363
|
return runs
|
|
360
364
|
|
|
361
|
-
def save_breakpoints(self, procedure_name: str, breakpoints:
|
|
365
|
+
def save_breakpoints(self, procedure_name: str, breakpoints: list[Breakpoint]) -> None:
|
|
362
366
|
"""
|
|
363
367
|
Save breakpoints for a procedure.
|
|
364
368
|
|
|
@@ -371,12 +375,12 @@ class FileStorage:
|
|
|
371
375
|
data = [bp.model_dump() for bp in breakpoints]
|
|
372
376
|
|
|
373
377
|
try:
|
|
374
|
-
with open(bp_path, "w") as
|
|
375
|
-
json.dump(data,
|
|
376
|
-
except (IOError, OSError) as
|
|
377
|
-
raise RuntimeError(f"Failed to save breakpoints for {procedure_name}: {
|
|
378
|
+
with open(bp_path, "w") as file_handle:
|
|
379
|
+
json.dump(data, file_handle, indent=2)
|
|
380
|
+
except (IOError, OSError) as error:
|
|
381
|
+
raise RuntimeError(f"Failed to save breakpoints for {procedure_name}: {error}")
|
|
378
382
|
|
|
379
|
-
def load_breakpoints(self, procedure_name: str) ->
|
|
383
|
+
def load_breakpoints(self, procedure_name: str) -> list[Breakpoint]:
|
|
380
384
|
"""
|
|
381
385
|
Load breakpoints for a procedure.
|
|
382
386
|
|
|
@@ -392,8 +396,8 @@ class FileStorage:
|
|
|
392
396
|
return []
|
|
393
397
|
|
|
394
398
|
try:
|
|
395
|
-
with open(bp_path, "r") as
|
|
396
|
-
data = json.load(
|
|
399
|
+
with open(bp_path, "r") as file_handle:
|
|
400
|
+
data = json.load(file_handle)
|
|
397
401
|
except (json.JSONDecodeError, IOError):
|
|
398
402
|
return []
|
|
399
403
|
|
|
@@ -7,7 +7,7 @@ Used when TACTUS_CALLBACK_URL environment variable is set.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
-
from typing import Optional
|
|
10
|
+
from typing import Optional
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
from requests.adapters import HTTPAdapter
|
|
@@ -42,7 +42,7 @@ class HTTPCallbackLogHandler:
|
|
|
42
42
|
"""
|
|
43
43
|
self.callback_url = callback_url
|
|
44
44
|
self.timeout = timeout
|
|
45
|
-
self.cost_events:
|
|
45
|
+
self.cost_events: list[CostEvent] = [] # Track cost events for aggregation
|
|
46
46
|
|
|
47
47
|
# Setup session with retry logic
|
|
48
48
|
self.session = requests.Session()
|
|
@@ -55,7 +55,7 @@ class HTTPCallbackLogHandler:
|
|
|
55
55
|
self.session.mount("http://", adapter)
|
|
56
56
|
self.session.mount("https://", adapter)
|
|
57
57
|
|
|
58
|
-
logger.info(
|
|
58
|
+
logger.info("[HTTP_CALLBACK] Initialized with URL: %s", callback_url)
|
|
59
59
|
|
|
60
60
|
def log(self, event: LogEvent) -> None:
|
|
61
61
|
"""
|
|
@@ -70,29 +70,36 @@ class HTTPCallbackLogHandler:
|
|
|
70
70
|
|
|
71
71
|
try:
|
|
72
72
|
# Serialize event to JSON
|
|
73
|
-
|
|
73
|
+
event_payload = event.model_dump(mode="json")
|
|
74
74
|
|
|
75
75
|
# Format timestamp to ensure ISO format with Z suffix
|
|
76
76
|
iso_string = event.timestamp.isoformat()
|
|
77
|
-
|
|
77
|
+
has_timezone_marker = (
|
|
78
|
+
iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2
|
|
79
|
+
)
|
|
80
|
+
if not has_timezone_marker:
|
|
78
81
|
iso_string += "Z"
|
|
79
|
-
|
|
82
|
+
event_payload["timestamp"] = iso_string
|
|
80
83
|
|
|
81
84
|
# POST to callback URL
|
|
82
85
|
response = self.session.post(
|
|
83
86
|
self.callback_url,
|
|
84
|
-
json=
|
|
87
|
+
json=event_payload,
|
|
85
88
|
timeout=self.timeout,
|
|
86
89
|
)
|
|
87
90
|
response.raise_for_status()
|
|
88
|
-
logger.debug(
|
|
91
|
+
logger.debug("[HTTP_CALLBACK] Event posted: type=%s", event.event_type)
|
|
89
92
|
|
|
90
|
-
except requests.exceptions.RequestException as
|
|
93
|
+
except requests.exceptions.RequestException as error:
|
|
91
94
|
# Log but don't fail - event streaming is best-effort
|
|
92
|
-
logger.warning(
|
|
93
|
-
|
|
95
|
+
logger.warning(
|
|
96
|
+
"[HTTP_CALLBACK] Failed to POST event to %s: %s",
|
|
97
|
+
self.callback_url,
|
|
98
|
+
error,
|
|
99
|
+
)
|
|
100
|
+
except Exception as error:
|
|
94
101
|
# Catch any other errors to prevent crashing the procedure
|
|
95
|
-
logger.warning(
|
|
102
|
+
logger.warning("[HTTP_CALLBACK] Unexpected error posting event: %s", error)
|
|
96
103
|
|
|
97
104
|
@classmethod
|
|
98
105
|
def from_environment(cls) -> Optional["HTTPCallbackLogHandler"]:
|
|
@@ -104,6 +111,9 @@ class HTTPCallbackLogHandler:
|
|
|
104
111
|
"""
|
|
105
112
|
callback_url = os.environ.get("TACTUS_CALLBACK_URL")
|
|
106
113
|
if callback_url:
|
|
107
|
-
logger.info(
|
|
114
|
+
logger.info(
|
|
115
|
+
"[HTTP_CALLBACK] Creating handler from environment: %s",
|
|
116
|
+
callback_url,
|
|
117
|
+
)
|
|
108
118
|
return cls(callback_url=callback_url)
|
|
109
119
|
return None
|
tactus/adapters/ide_log.py
CHANGED
|
@@ -6,9 +6,8 @@ Collects log events in a queue for streaming to IDE frontend.
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
import queue
|
|
9
|
-
from typing import List
|
|
10
9
|
|
|
11
|
-
from tactus.protocols.models import LogEvent
|
|
10
|
+
from tactus.protocols.models import CostEvent, LogEvent
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
@@ -25,8 +24,8 @@ class IDELogHandler:
|
|
|
25
24
|
|
|
26
25
|
def __init__(self):
|
|
27
26
|
"""Initialize IDE log handler."""
|
|
28
|
-
self.events = queue.Queue()
|
|
29
|
-
self.cost_events = [] # Track cost events for aggregation
|
|
27
|
+
self.events: queue.Queue[LogEvent] = queue.Queue()
|
|
28
|
+
self.cost_events: list[CostEvent] = [] # Track cost events for aggregation
|
|
30
29
|
logger.debug("IDELogHandler initialized")
|
|
31
30
|
|
|
32
31
|
def log(self, event: LogEvent) -> None:
|
|
@@ -37,7 +36,10 @@ class IDELogHandler:
|
|
|
37
36
|
event: Structured log event
|
|
38
37
|
"""
|
|
39
38
|
# CRITICAL DEBUG: Log every call to this method
|
|
40
|
-
logger.info(
|
|
39
|
+
logger.info(
|
|
40
|
+
"[IDE_LOG] log() called with event type: %s",
|
|
41
|
+
type(event).__name__,
|
|
42
|
+
)
|
|
41
43
|
|
|
42
44
|
# Track cost events for aggregation
|
|
43
45
|
from tactus.protocols.models import CostEvent, AgentStreamChunkEvent
|
|
@@ -48,16 +50,22 @@ class IDELogHandler:
|
|
|
48
50
|
# Debug logging for streaming events
|
|
49
51
|
if isinstance(event, AgentStreamChunkEvent):
|
|
50
52
|
logger.info(
|
|
51
|
-
|
|
53
|
+
"[IDE_LOG] Received AgentStreamChunkEvent: agent=%s, "
|
|
54
|
+
"chunk_len=%s, accumulated_len=%s",
|
|
55
|
+
event.agent_name,
|
|
56
|
+
len(event.chunk_text),
|
|
57
|
+
len(event.accumulated_text),
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
self.events.put(event)
|
|
55
61
|
# Use INFO level to ensure we see this in logs
|
|
56
62
|
logger.info(
|
|
57
|
-
|
|
63
|
+
"[IDE_LOG] Event queued: type=%s, queue_size=%s",
|
|
64
|
+
type(event).__name__,
|
|
65
|
+
self.events.qsize(),
|
|
58
66
|
)
|
|
59
67
|
|
|
60
|
-
def get_events(self, timeout: float = 0.1) ->
|
|
68
|
+
def get_events(self, timeout: float = 0.1) -> list[LogEvent]:
|
|
61
69
|
"""
|
|
62
70
|
Get all available events from the queue.
|
|
63
71
|
|
|
@@ -67,7 +75,7 @@ class IDELogHandler:
|
|
|
67
75
|
Returns:
|
|
68
76
|
List of LogEvent objects
|
|
69
77
|
"""
|
|
70
|
-
events = []
|
|
78
|
+
events: list[LogEvent] = []
|
|
71
79
|
while True:
|
|
72
80
|
try:
|
|
73
81
|
event = self.events.get(timeout=timeout)
|
tactus/adapters/lua_tools.py
CHANGED
|
@@ -193,8 +193,7 @@ class LuaToolsAdapter:
|
|
|
193
193
|
# Track the mock call
|
|
194
194
|
if self.tool_primitive:
|
|
195
195
|
self.tool_primitive.record_call(tool_name, kwargs, result_str)
|
|
196
|
-
|
|
197
|
-
self.mock_manager.record_call(tool_name, kwargs, result_str)
|
|
196
|
+
self.mock_manager.record_call(tool_name, kwargs, result_str)
|
|
198
197
|
return result_str
|
|
199
198
|
|
|
200
199
|
# Call Lua function directly (Lupa is NOT thread-safe, so we can't use executor)
|
|
@@ -221,8 +220,8 @@ class LuaToolsAdapter:
|
|
|
221
220
|
logger.debug(f"Lua tool '{tool_name}' executed successfully")
|
|
222
221
|
return result_str
|
|
223
222
|
|
|
224
|
-
except Exception as
|
|
225
|
-
error_msg = f"Error executing Lua tool '{tool_name}': {str(
|
|
223
|
+
except Exception as error:
|
|
224
|
+
error_msg = f"Error executing Lua tool '{tool_name}': {str(error)}"
|
|
226
225
|
logger.error(error_msg, exc_info=True)
|
|
227
226
|
|
|
228
227
|
# Record failed call
|
|
@@ -230,7 +229,7 @@ class LuaToolsAdapter:
|
|
|
230
229
|
self.tool_primitive.record_call(tool_name, kwargs, error_msg)
|
|
231
230
|
|
|
232
231
|
# Re-raise to let agent handle it
|
|
233
|
-
raise RuntimeError(error_msg) from
|
|
232
|
+
raise RuntimeError(error_msg) from error
|
|
234
233
|
|
|
235
234
|
# Build proper signature for Pydantic AI tool discovery
|
|
236
235
|
sig_params = []
|
tactus/adapters/mcp.py
CHANGED
|
@@ -54,8 +54,8 @@ class PydanticAIMCPAdapter:
|
|
|
54
54
|
"MCP client doesn't have list_tools() or get_tools(), trying direct call"
|
|
55
55
|
)
|
|
56
56
|
mcp_tools = await self.mcp_client() if callable(self.mcp_client) else []
|
|
57
|
-
except Exception as
|
|
58
|
-
logger.error(f"Failed to load tools from MCP server: {
|
|
57
|
+
except Exception as error:
|
|
58
|
+
logger.error(f"Failed to load tools from MCP server: {error}", exc_info=True)
|
|
59
59
|
return []
|
|
60
60
|
|
|
61
61
|
if not mcp_tools:
|
|
@@ -71,9 +71,9 @@ class PydanticAIMCPAdapter:
|
|
|
71
71
|
tool = self._convert_mcp_tool_to_pydantic_ai(mcp_tool)
|
|
72
72
|
if tool:
|
|
73
73
|
pydantic_tools.append(tool)
|
|
74
|
-
except Exception as
|
|
74
|
+
except Exception as error:
|
|
75
75
|
logger.error(
|
|
76
|
-
f"Failed to convert MCP tool {getattr(mcp_tool, 'name', 'unknown')}: {
|
|
76
|
+
f"Failed to convert MCP tool {getattr(mcp_tool, 'name', 'unknown')}: {error}",
|
|
77
77
|
exc_info=True,
|
|
78
78
|
)
|
|
79
79
|
|
|
@@ -91,16 +91,12 @@ class PydanticAIMCPAdapter:
|
|
|
91
91
|
pydantic_ai.Tool instance or None if conversion fails
|
|
92
92
|
"""
|
|
93
93
|
# Extract tool metadata
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
getattr(mcp_tool, "description", None) or mcp_tool.get("description", "")
|
|
101
|
-
if isinstance(mcp_tool, dict)
|
|
102
|
-
else ""
|
|
103
|
-
)
|
|
94
|
+
if isinstance(mcp_tool, dict):
|
|
95
|
+
tool_name = mcp_tool.get("name")
|
|
96
|
+
tool_description = mcp_tool.get("description", "")
|
|
97
|
+
else:
|
|
98
|
+
tool_name = getattr(mcp_tool, "name", None)
|
|
99
|
+
tool_description = getattr(mcp_tool, "description", None) or ""
|
|
104
100
|
|
|
105
101
|
if not tool_name:
|
|
106
102
|
logger.warning(f"MCP tool missing name: {mcp_tool}")
|
|
@@ -120,9 +116,10 @@ class PydanticAIMCPAdapter:
|
|
|
120
116
|
if input_schema:
|
|
121
117
|
try:
|
|
122
118
|
args_model = self._json_schema_to_pydantic_model(input_schema, tool_name)
|
|
123
|
-
except Exception as
|
|
119
|
+
except Exception as error:
|
|
124
120
|
logger.error(
|
|
125
|
-
f"Failed to create Pydantic model for tool '{tool_name}': {
|
|
121
|
+
f"Failed to create Pydantic model for tool '{tool_name}': {error}",
|
|
122
|
+
exc_info=True,
|
|
126
123
|
)
|
|
127
124
|
# Fallback: create a simple model that accepts any dict
|
|
128
125
|
args_model = create_model(
|
|
@@ -186,9 +183,9 @@ class PydanticAIMCPAdapter:
|
|
|
186
183
|
logger.debug(f"Tool '{tool_name}' returned: {result_str[:100]}...")
|
|
187
184
|
return result_str
|
|
188
185
|
|
|
189
|
-
except Exception as
|
|
190
|
-
logger.error(f"MCP tool '{tool_name}' execution failed: {
|
|
191
|
-
error_msg = f"Error executing tool '{tool_name}': {str(
|
|
186
|
+
except Exception as error:
|
|
187
|
+
logger.error(f"MCP tool '{tool_name}' execution failed: {error}", exc_info=True)
|
|
188
|
+
error_msg = f"Error executing tool '{tool_name}': {str(error)}"
|
|
192
189
|
# Still record the failed call
|
|
193
190
|
if self.tool_primitive:
|
|
194
191
|
self.tool_primitive.record_call(tool_name, args_dict, error_msg)
|
tactus/adapters/mcp_manager.py
CHANGED
|
@@ -10,7 +10,7 @@ import os
|
|
|
10
10
|
import re
|
|
11
11
|
import asyncio
|
|
12
12
|
from contextlib import AsyncExitStack
|
|
13
|
-
from typing import
|
|
13
|
+
from typing import Any
|
|
14
14
|
|
|
15
15
|
from pydantic_ai.mcp import MCPServerStdio
|
|
16
16
|
|
|
@@ -29,10 +29,10 @@ def substitute_env_vars(value: Any) -> Any:
|
|
|
29
29
|
"""
|
|
30
30
|
if isinstance(value, str):
|
|
31
31
|
# Replace ${VAR} or $VAR with environment variable value
|
|
32
|
-
return re.sub(r"\$\{(\w+)\}", lambda
|
|
33
|
-
|
|
32
|
+
return re.sub(r"\$\{(\w+)\}", lambda match: os.getenv(match.group(1), ""), value)
|
|
33
|
+
if isinstance(value, dict):
|
|
34
34
|
return {k: substitute_env_vars(v) for k, v in value.items()}
|
|
35
|
-
|
|
35
|
+
if isinstance(value, list):
|
|
36
36
|
return [substitute_env_vars(v) for v in value]
|
|
37
37
|
return value
|
|
38
38
|
|
|
@@ -45,7 +45,7 @@ class MCPServerManager:
|
|
|
45
45
|
tool prefixing. Handles connection lifecycle and tool call tracking.
|
|
46
46
|
"""
|
|
47
47
|
|
|
48
|
-
def __init__(self, server_configs:
|
|
48
|
+
def __init__(self, server_configs: dict[str, dict[str, Any]], tool_primitive=None):
|
|
49
49
|
"""
|
|
50
50
|
Initialize MCP server manager.
|
|
51
51
|
|
|
@@ -55,10 +55,10 @@ class MCPServerManager:
|
|
|
55
55
|
"""
|
|
56
56
|
self.configs = server_configs
|
|
57
57
|
self.tool_primitive = tool_primitive
|
|
58
|
-
self.servers:
|
|
59
|
-
self.server_toolsets:
|
|
58
|
+
self.servers: list[MCPServerStdio] = []
|
|
59
|
+
self.server_toolsets: dict[str, MCPServerStdio] = {} # Map server names to toolsets
|
|
60
60
|
self._exit_stack = AsyncExitStack()
|
|
61
|
-
logger.info(
|
|
61
|
+
logger.info("MCPServerManager initialized with %s server(s)", len(server_configs))
|
|
62
62
|
|
|
63
63
|
async def __aenter__(self):
|
|
64
64
|
"""Connect to all configured MCP servers."""
|
|
@@ -67,17 +67,21 @@ class MCPServerManager:
|
|
|
67
67
|
last_error: Exception | None = None
|
|
68
68
|
for attempt in range(1, 4):
|
|
69
69
|
try:
|
|
70
|
-
logger.info(
|
|
70
|
+
logger.info(
|
|
71
|
+
"Connecting to MCP server '%s' (attempt %s/3)...",
|
|
72
|
+
name,
|
|
73
|
+
attempt,
|
|
74
|
+
)
|
|
71
75
|
|
|
72
76
|
# Substitute environment variables in config
|
|
73
|
-
|
|
77
|
+
resolved_config = substitute_env_vars(config)
|
|
74
78
|
|
|
75
79
|
# Create base server
|
|
76
80
|
server = MCPServerStdio(
|
|
77
|
-
command=
|
|
78
|
-
args=
|
|
79
|
-
env=
|
|
80
|
-
cwd=
|
|
81
|
+
command=resolved_config["command"],
|
|
82
|
+
args=resolved_config.get("args", []),
|
|
83
|
+
env=resolved_config.get("env"),
|
|
84
|
+
cwd=resolved_config.get("cwd"),
|
|
81
85
|
process_tool_call=self._create_trace_callback(name), # Tracking hook
|
|
82
86
|
)
|
|
83
87
|
|
|
@@ -93,17 +97,19 @@ class MCPServerManager:
|
|
|
93
97
|
)
|
|
94
98
|
last_error = None
|
|
95
99
|
break
|
|
96
|
-
except Exception as
|
|
97
|
-
last_error =
|
|
100
|
+
except Exception as error:
|
|
101
|
+
last_error = error
|
|
98
102
|
|
|
99
103
|
# Check if this is a fileno error (common in test environments)
|
|
100
104
|
import io
|
|
101
105
|
|
|
102
|
-
error_str = str(
|
|
103
|
-
if "fileno" in error_str or isinstance(
|
|
106
|
+
error_str = str(error)
|
|
107
|
+
if "fileno" in error_str or isinstance(error, io.UnsupportedOperation):
|
|
104
108
|
logger.warning(
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
"Failed to connect to MCP server '%s': %s "
|
|
110
|
+
"(test environment with redirected streams)",
|
|
111
|
+
name,
|
|
112
|
+
error,
|
|
107
113
|
)
|
|
108
114
|
# Allow procedures to continue without MCP in this environment.
|
|
109
115
|
last_error = None
|
|
@@ -115,12 +121,19 @@ class MCPServerManager:
|
|
|
115
121
|
or "unhandled errors in a TaskGroup" in error_str
|
|
116
122
|
):
|
|
117
123
|
logger.warning(
|
|
118
|
-
|
|
124
|
+
"Transient MCP connection failure for '%s': %s (retrying)",
|
|
125
|
+
name,
|
|
126
|
+
error,
|
|
119
127
|
)
|
|
120
128
|
await asyncio.sleep(0.05 * attempt)
|
|
121
129
|
continue
|
|
122
130
|
|
|
123
|
-
logger.error(
|
|
131
|
+
logger.error(
|
|
132
|
+
"Failed to connect to MCP server '%s': %s",
|
|
133
|
+
name,
|
|
134
|
+
error,
|
|
135
|
+
exc_info=True,
|
|
136
|
+
)
|
|
124
137
|
break
|
|
125
138
|
|
|
126
139
|
if last_error is not None:
|
|
@@ -146,14 +159,17 @@ class MCPServerManager:
|
|
|
146
159
|
Async callback function for process_tool_call
|
|
147
160
|
"""
|
|
148
161
|
|
|
149
|
-
async def trace_tool_call(
|
|
162
|
+
async def trace_tool_call(execution_context, invoke_next, tool_name, tool_args):
|
|
150
163
|
"""Middleware to record tool calls in Tactus ToolPrimitive."""
|
|
151
164
|
logger.debug(
|
|
152
|
-
|
|
165
|
+
"MCP server '%s' calling tool '%s' with args: %s",
|
|
166
|
+
server_name,
|
|
167
|
+
tool_name,
|
|
168
|
+
tool_args,
|
|
153
169
|
)
|
|
154
170
|
|
|
155
171
|
try:
|
|
156
|
-
result = await
|
|
172
|
+
result = await invoke_next(tool_name, tool_args)
|
|
157
173
|
|
|
158
174
|
# Record in ToolPrimitive if available
|
|
159
175
|
if self.tool_primitive:
|
|
@@ -162,19 +178,19 @@ class MCPServerManager:
|
|
|
162
178
|
result_str = str(result) if not isinstance(result, str) else result
|
|
163
179
|
self.tool_primitive.record_call(tool_name, tool_args, result_str)
|
|
164
180
|
|
|
165
|
-
logger.debug(
|
|
181
|
+
logger.debug("Tool '%s' completed successfully", tool_name)
|
|
166
182
|
return result
|
|
167
|
-
except Exception as
|
|
168
|
-
logger.error(
|
|
183
|
+
except Exception as error:
|
|
184
|
+
logger.error("Tool '%s' failed: %s", tool_name, error, exc_info=True)
|
|
169
185
|
# Still record the failed call
|
|
170
186
|
if self.tool_primitive:
|
|
171
|
-
error_msg = f"Error: {str(
|
|
187
|
+
error_msg = f"Error: {str(error)}"
|
|
172
188
|
self.tool_primitive.record_call(tool_name, tool_args, error_msg)
|
|
173
189
|
raise
|
|
174
190
|
|
|
175
191
|
return trace_tool_call
|
|
176
192
|
|
|
177
|
-
def get_toolsets(self) ->
|
|
193
|
+
def get_toolsets(self) -> list[MCPServerStdio]:
|
|
178
194
|
"""
|
|
179
195
|
Return list of connected servers as toolsets.
|
|
180
196
|
|
tactus/adapters/memory.py
CHANGED
|
@@ -36,18 +36,18 @@ class MemoryStorage:
|
|
|
36
36
|
self, procedure_id: str, status: str, waiting_on_message_id: Optional[str] = None
|
|
37
37
|
) -> None:
|
|
38
38
|
"""Update procedure status."""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
self.save_procedure_metadata(procedure_id,
|
|
39
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
40
|
+
procedure_metadata.status = status
|
|
41
|
+
procedure_metadata.waiting_on_message_id = waiting_on_message_id
|
|
42
|
+
self.save_procedure_metadata(procedure_id, procedure_metadata)
|
|
43
43
|
|
|
44
44
|
def get_state(self, procedure_id: str) -> Dict[str, Any]:
|
|
45
45
|
"""Get mutable state dictionary."""
|
|
46
|
-
|
|
47
|
-
return
|
|
46
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
47
|
+
return procedure_metadata.state
|
|
48
48
|
|
|
49
49
|
def set_state(self, procedure_id: str, state: Dict[str, Any]) -> None:
|
|
50
50
|
"""Set mutable state dictionary."""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
self.save_procedure_metadata(procedure_id,
|
|
51
|
+
procedure_metadata = self.load_procedure_metadata(procedure_id)
|
|
52
|
+
procedure_metadata.state = state
|
|
53
|
+
self.save_procedure_metadata(procedure_id, procedure_metadata)
|