tactus 0.25.0__py3-none-any.whl → 0.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tactus/__init__.py +1 -1
- tactus/adapters/cli_hitl.py +2 -2
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/cli/app.py +99 -10
- tactus/core/config_manager.py +2 -2
- tactus/core/dsl_stubs.py +51 -43
- tactus/core/execution_context.py +2 -2
- tactus/core/lua_sandbox.py +2 -2
- tactus/docker/Dockerfile +4 -0
- tactus/dspy/agent.py +22 -7
- tactus/primitives/handles.py +12 -12
- tactus/sandbox/container_runner.py +122 -5
- tactus/sandbox/docker_manager.py +16 -4
- tactus/sandbox/entrypoint.py +30 -2
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/loader.py +1 -1
- {tactus-0.25.0.dist-info → tactus-0.26.0.dist-info}/METADATA +16 -1
- {tactus-0.25.0.dist-info → tactus-0.26.0.dist-info}/RECORD +21 -19
- {tactus-0.25.0.dist-info → tactus-0.26.0.dist-info}/WHEEL +0 -0
- {tactus-0.25.0.dist-info → tactus-0.26.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.25.0.dist-info → tactus-0.26.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/adapters/cli_hitl.py
CHANGED
|
@@ -32,7 +32,7 @@ class CLIHITLHandler:
|
|
|
32
32
|
console: Rich Console instance (creates new one if not provided)
|
|
33
33
|
"""
|
|
34
34
|
self.console = console or Console()
|
|
35
|
-
logger.
|
|
35
|
+
logger.debug("CLIHITLHandler initialized")
|
|
36
36
|
|
|
37
37
|
def request_interaction(self, procedure_id: str, request: HITLRequest) -> HITLResponse:
|
|
38
38
|
"""
|
|
@@ -45,7 +45,7 @@ class CLIHITLHandler:
|
|
|
45
45
|
Returns:
|
|
46
46
|
HITLResponse with user's response
|
|
47
47
|
"""
|
|
48
|
-
logger.
|
|
48
|
+
logger.debug(f"HITL request: {request.request_type} - {request.message}")
|
|
49
49
|
|
|
50
50
|
# Display the request in a panel
|
|
51
51
|
self.console.print()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost-only log handler for headless/sandbox runs.
|
|
3
|
+
|
|
4
|
+
Collects CostEvent instances so the runtime can report total_cost/total_tokens,
|
|
5
|
+
without enabling streaming UI behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import json
|
|
12
|
+
from typing import List
|
|
13
|
+
|
|
14
|
+
from tactus.protocols.models import CostEvent, LogEvent
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CostCollectorLogHandler:
|
|
20
|
+
"""
|
|
21
|
+
Minimal LogHandler for sandbox runs.
|
|
22
|
+
|
|
23
|
+
This is useful in environments like Docker sandboxes where stdout is reserved
|
|
24
|
+
for protocol output, but we still want:
|
|
25
|
+
- accurate cost accounting (CostEvent)
|
|
26
|
+
- basic procedure logging (LogEvent) via stderr/Python logging
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
supports_streaming = False
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.cost_events: List[CostEvent] = []
|
|
33
|
+
logger.debug("CostCollectorLogHandler initialized")
|
|
34
|
+
|
|
35
|
+
def log(self, event: LogEvent) -> None:
|
|
36
|
+
if isinstance(event, CostEvent):
|
|
37
|
+
self.cost_events.append(event)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Preserve useful procedure logs even when no IDE callback is present.
|
|
41
|
+
if isinstance(event, LogEvent):
|
|
42
|
+
event_logger = logging.getLogger(event.logger_name or "procedure")
|
|
43
|
+
|
|
44
|
+
msg = event.message
|
|
45
|
+
if event.context:
|
|
46
|
+
msg = f"{msg}\nContext: {json.dumps(event.context, indent=2, default=str)}"
|
|
47
|
+
|
|
48
|
+
level = (event.level or "INFO").upper()
|
|
49
|
+
if level == "DEBUG":
|
|
50
|
+
event_logger.debug(msg)
|
|
51
|
+
elif level in ("WARN", "WARNING"):
|
|
52
|
+
event_logger.warning(msg)
|
|
53
|
+
elif level == "ERROR":
|
|
54
|
+
event_logger.error(msg)
|
|
55
|
+
else:
|
|
56
|
+
event_logger.info(msg)
|
tactus/cli/app.py
CHANGED
|
@@ -134,15 +134,92 @@ def load_tactus_config():
|
|
|
134
134
|
return {}
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
logging.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
137
|
+
_LOG_LEVELS = {
|
|
138
|
+
"debug": logging.DEBUG,
|
|
139
|
+
"info": logging.INFO,
|
|
140
|
+
"warning": logging.WARNING,
|
|
141
|
+
"warn": logging.WARNING,
|
|
142
|
+
"error": logging.ERROR,
|
|
143
|
+
"critical": logging.CRITICAL,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_LOG_FORMATS = {"rich", "terminal", "raw"}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _TerminalLogHandler(logging.Handler):
|
|
150
|
+
"""Minimal, high-signal terminal logger (no timestamps/levels)."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, console: Console):
|
|
153
|
+
super().__init__()
|
|
154
|
+
self._console = console
|
|
155
|
+
self.setFormatter(logging.Formatter("%(message)s"))
|
|
156
|
+
|
|
157
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
158
|
+
try:
|
|
159
|
+
message = self.format(record)
|
|
160
|
+
|
|
161
|
+
# Make procedure-level logs the most prominent.
|
|
162
|
+
if record.name.startswith("procedure"):
|
|
163
|
+
style = "bold"
|
|
164
|
+
elif record.levelno >= logging.ERROR:
|
|
165
|
+
style = "bold red"
|
|
166
|
+
elif record.levelno >= logging.WARNING:
|
|
167
|
+
style = "yellow"
|
|
168
|
+
elif record.levelno <= logging.DEBUG:
|
|
169
|
+
style = "dim"
|
|
170
|
+
else:
|
|
171
|
+
style = ""
|
|
172
|
+
|
|
173
|
+
self._console.print(message, style=style, markup=False, highlight=False)
|
|
174
|
+
except Exception:
|
|
175
|
+
self.handleError(record)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def setup_logging(
|
|
179
|
+
verbose: bool = False,
|
|
180
|
+
log_level: Optional[str] = None,
|
|
181
|
+
log_format: str = "rich",
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Setup CLI logging (level + format)."""
|
|
184
|
+
if log_level is None:
|
|
185
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
186
|
+
else:
|
|
187
|
+
key = str(log_level).strip().lower()
|
|
188
|
+
if key not in _LOG_LEVELS:
|
|
189
|
+
raise typer.BadParameter(
|
|
190
|
+
f"Invalid --log-level '{log_level}'. "
|
|
191
|
+
f"Use one of: {', '.join(sorted(_LOG_LEVELS.keys()))}"
|
|
192
|
+
)
|
|
193
|
+
level = _LOG_LEVELS[key]
|
|
194
|
+
|
|
195
|
+
fmt = (log_format or "rich").strip().lower()
|
|
196
|
+
if fmt not in _LOG_FORMATS:
|
|
197
|
+
raise typer.BadParameter(
|
|
198
|
+
f"Invalid --log-format '{log_format}'. Use one of: {', '.join(sorted(_LOG_FORMATS))}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Default: rich logs (group repeated timestamps).
|
|
202
|
+
if fmt == "rich":
|
|
203
|
+
handler: logging.Handler = RichHandler(
|
|
204
|
+
console=console,
|
|
205
|
+
show_path=False,
|
|
206
|
+
rich_tracebacks=True,
|
|
207
|
+
omit_repeated_times=True,
|
|
208
|
+
)
|
|
209
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
210
|
+
logging.basicConfig(level=level, format="%(message)s", handlers=[handler], force=True)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Raw logs: one line per entry, CloudWatch-friendly.
|
|
214
|
+
if fmt == "raw":
|
|
215
|
+
handler = logging.StreamHandler(stream=sys.stderr)
|
|
216
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
217
|
+
logging.basicConfig(level=level, handlers=[handler], force=True)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Terminal logs: no timestamps/levels, color by signal.
|
|
221
|
+
handler = _TerminalLogHandler(console)
|
|
222
|
+
logging.basicConfig(level=level, handlers=[handler], force=True)
|
|
146
223
|
|
|
147
224
|
|
|
148
225
|
def _parse_value(value_str: str, field_type: str) -> Any:
|
|
@@ -352,6 +429,12 @@ def run(
|
|
|
352
429
|
None, envvar="OPENAI_API_KEY", help="OpenAI API key"
|
|
353
430
|
),
|
|
354
431
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
|
|
432
|
+
log_level: Optional[str] = typer.Option(
|
|
433
|
+
None, "--log-level", help="Log level: debug, info, warning, error, critical"
|
|
434
|
+
),
|
|
435
|
+
log_format: str = typer.Option(
|
|
436
|
+
"rich", "--log-format", help="Log format: rich (default), terminal, raw"
|
|
437
|
+
),
|
|
355
438
|
param: Optional[list[str]] = typer.Option(None, help="Parameters in format key=value"),
|
|
356
439
|
interactive: bool = typer.Option(
|
|
357
440
|
False, "--interactive", "-i", help="Interactively prompt for all inputs"
|
|
@@ -399,7 +482,7 @@ def run(
|
|
|
399
482
|
# Use real implementation for specific tools while mocking others
|
|
400
483
|
tactus run workflow.tac --mock-all --real done
|
|
401
484
|
"""
|
|
402
|
-
setup_logging(verbose)
|
|
485
|
+
setup_logging(verbose=verbose, log_level=log_level, log_format=log_format)
|
|
403
486
|
|
|
404
487
|
# Check if file exists
|
|
405
488
|
if not workflow_file.exists():
|
|
@@ -530,6 +613,12 @@ def run(
|
|
|
530
613
|
sandbox_config_dict["enabled"] = sandbox
|
|
531
614
|
sandbox_config = SandboxConfig(**sandbox_config_dict)
|
|
532
615
|
|
|
616
|
+
# Pass logging preferences through to the sandbox container so container stderr matches CLI UX.
|
|
617
|
+
sandbox_config.env.setdefault(
|
|
618
|
+
"TACTUS_LOG_LEVEL", str(log_level or ("debug" if verbose else "info"))
|
|
619
|
+
)
|
|
620
|
+
sandbox_config.env.setdefault("TACTUS_LOG_FORMAT", str(log_format))
|
|
621
|
+
|
|
533
622
|
# Check Docker availability
|
|
534
623
|
docker_available, docker_reason = is_docker_available()
|
|
535
624
|
|
tactus/core/config_manager.py
CHANGED
|
@@ -150,7 +150,7 @@ class ConfigManager:
|
|
|
150
150
|
sidecar_config = self._load_yaml_file(sidecar_path)
|
|
151
151
|
if sidecar_config:
|
|
152
152
|
configs.append(("sidecar", sidecar_config))
|
|
153
|
-
logger.
|
|
153
|
+
logger.debug(f"Loaded sidecar config: {sidecar_path}")
|
|
154
154
|
|
|
155
155
|
# Store for debugging
|
|
156
156
|
self.loaded_configs = configs
|
|
@@ -158,7 +158,7 @@ class ConfigManager:
|
|
|
158
158
|
# Merge all configs (later configs override earlier ones)
|
|
159
159
|
merged = self._merge_configs([c[1] for c in configs])
|
|
160
160
|
|
|
161
|
-
logger.
|
|
161
|
+
logger.debug(f"Merged configuration from {len(configs)} source(s)")
|
|
162
162
|
return merged
|
|
163
163
|
|
|
164
164
|
def _find_sidecar_config(self, tac_path: Path) -> Optional[Path]:
|
tactus/core/dsl_stubs.py
CHANGED
|
@@ -915,43 +915,49 @@ def create_dsl_stubs(
|
|
|
915
915
|
if not isinstance(mock_config, dict):
|
|
916
916
|
continue
|
|
917
917
|
|
|
918
|
-
#
|
|
919
|
-
|
|
920
|
-
|
|
918
|
+
# Tool mocks use explicit keys.
|
|
919
|
+
tool_mock_keys = {"returns", "temporal", "conditional", "error"}
|
|
920
|
+
if any(k in mock_config for k in tool_mock_keys):
|
|
921
|
+
# Convert DSL syntax to MockConfig format
|
|
922
|
+
processed_config = {}
|
|
923
|
+
|
|
924
|
+
# Static mocking with 'returns' key
|
|
925
|
+
if "returns" in mock_config:
|
|
926
|
+
processed_config["output"] = mock_config["returns"]
|
|
927
|
+
|
|
928
|
+
# Temporal mocking
|
|
929
|
+
elif "temporal" in mock_config:
|
|
930
|
+
processed_config["temporal"] = mock_config["temporal"]
|
|
931
|
+
|
|
932
|
+
# Conditional mocking
|
|
933
|
+
elif "conditional" in mock_config:
|
|
934
|
+
# Convert DSL conditional format to MockManager format
|
|
935
|
+
conditionals = []
|
|
936
|
+
for cond in mock_config["conditional"]:
|
|
937
|
+
if isinstance(cond, dict) and "when" in cond and "returns" in cond:
|
|
938
|
+
conditionals.append({"when": cond["when"], "return": cond["returns"]})
|
|
939
|
+
processed_config["conditional_mocks"] = conditionals
|
|
940
|
+
|
|
941
|
+
# Error simulation
|
|
942
|
+
elif "error" in mock_config:
|
|
943
|
+
processed_config["error"] = mock_config["error"]
|
|
944
|
+
|
|
945
|
+
# Register the tool mock configuration
|
|
946
|
+
builder.register_mock(name, processed_config)
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
# Agent mocks can be message-only, tool_calls-only, or both.
|
|
950
|
+
if any(k in mock_config for k in ("tool_calls", "message", "data")):
|
|
921
951
|
agent_config = {
|
|
922
952
|
"tool_calls": mock_config.get("tool_calls", []),
|
|
923
953
|
"message": mock_config.get("message", ""),
|
|
954
|
+
"data": mock_config.get("data"),
|
|
924
955
|
}
|
|
925
956
|
builder.register_agent_mock(name, agent_config)
|
|
926
957
|
continue
|
|
927
958
|
|
|
928
|
-
# Otherwise,
|
|
929
|
-
|
|
930
|
-
processed_config = {}
|
|
931
|
-
|
|
932
|
-
# Static mocking with 'returns' key
|
|
933
|
-
if "returns" in mock_config:
|
|
934
|
-
processed_config["output"] = mock_config["returns"]
|
|
935
|
-
|
|
936
|
-
# Temporal mocking
|
|
937
|
-
elif "temporal" in mock_config:
|
|
938
|
-
processed_config["temporal"] = mock_config["temporal"]
|
|
939
|
-
|
|
940
|
-
# Conditional mocking
|
|
941
|
-
elif "conditional" in mock_config:
|
|
942
|
-
# Convert DSL conditional format to MockManager format
|
|
943
|
-
conditionals = []
|
|
944
|
-
for cond in mock_config["conditional"]:
|
|
945
|
-
if isinstance(cond, dict) and "when" in cond and "returns" in cond:
|
|
946
|
-
conditionals.append({"when": cond["when"], "return": cond["returns"]})
|
|
947
|
-
processed_config["conditional_mocks"] = conditionals
|
|
948
|
-
|
|
949
|
-
# Error simulation
|
|
950
|
-
elif "error" in mock_config:
|
|
951
|
-
processed_config["error"] = mock_config["error"]
|
|
952
|
-
|
|
953
|
-
# Register the tool mock configuration
|
|
954
|
-
builder.register_mock(name, processed_config)
|
|
959
|
+
# Otherwise, ignore unknown mock config.
|
|
960
|
+
continue
|
|
955
961
|
|
|
956
962
|
def _history(messages=None):
|
|
957
963
|
"""
|
|
@@ -1413,14 +1419,14 @@ def create_dsl_stubs(
|
|
|
1413
1419
|
|
|
1414
1420
|
logger = logging.getLogger(__name__)
|
|
1415
1421
|
|
|
1416
|
-
logger.
|
|
1422
|
+
logger.debug(
|
|
1417
1423
|
f"[AGENT_CREATION] Agent '{agent_name}': runtime_context={bool(_runtime_context)}, skip_agents={_runtime_context.get('skip_agents', 'N/A') if _runtime_context else 'N/A'}, has_log_handler={('log_handler' in _runtime_context) if _runtime_context else False}"
|
|
1418
1424
|
)
|
|
1419
1425
|
|
|
1420
1426
|
if _runtime_context and not _runtime_context.get("skip_agents", False):
|
|
1421
1427
|
from tactus.dspy.agent import create_dspy_agent
|
|
1422
1428
|
|
|
1423
|
-
logger.
|
|
1429
|
+
logger.debug(f"[AGENT_CREATION] Attempting immediate creation for agent '{agent_name}'")
|
|
1424
1430
|
|
|
1425
1431
|
try:
|
|
1426
1432
|
# Create the actual agent primitive NOW
|
|
@@ -1450,7 +1456,7 @@ def create_dsl_stubs(
|
|
|
1450
1456
|
handle._set_primitive(
|
|
1451
1457
|
agent_primitive, execution_context=_runtime_context.get("execution_context")
|
|
1452
1458
|
)
|
|
1453
|
-
logger.
|
|
1459
|
+
logger.debug(
|
|
1454
1460
|
f"[AGENT_CREATION] Agent '{agent_name}' created immediately during declaration, has_log_handler={hasattr(agent_primitive, 'log_handler') and agent_primitive.log_handler is not None}"
|
|
1455
1461
|
)
|
|
1456
1462
|
|
|
@@ -1458,7 +1464,9 @@ def create_dsl_stubs(
|
|
|
1458
1464
|
if "_created_agents" not in _runtime_context:
|
|
1459
1465
|
_runtime_context["_created_agents"] = {}
|
|
1460
1466
|
_runtime_context["_created_agents"][agent_name] = agent_primitive
|
|
1461
|
-
logger.
|
|
1467
|
+
logger.debug(
|
|
1468
|
+
f"[AGENT_CREATION] Stored agent '{agent_name}' in _created_agents dict"
|
|
1469
|
+
)
|
|
1462
1470
|
|
|
1463
1471
|
except Exception as e:
|
|
1464
1472
|
logger.error(
|
|
@@ -1567,14 +1575,14 @@ def create_dsl_stubs(
|
|
|
1567
1575
|
|
|
1568
1576
|
logger = logging.getLogger(__name__)
|
|
1569
1577
|
|
|
1570
|
-
logger.
|
|
1578
|
+
logger.debug(
|
|
1571
1579
|
f"[AGENT_CREATION] Agent '{temp_name}': runtime_context={bool(_runtime_context)}, skip_agents={_runtime_context.get('skip_agents', 'N/A') if _runtime_context else 'N/A'}, has_log_handler={('log_handler' in _runtime_context) if _runtime_context else False}"
|
|
1572
1580
|
)
|
|
1573
1581
|
|
|
1574
1582
|
if _runtime_context and not _runtime_context.get("skip_agents", False):
|
|
1575
1583
|
from tactus.dspy.agent import create_dspy_agent
|
|
1576
1584
|
|
|
1577
|
-
logger.
|
|
1585
|
+
logger.debug(f"[AGENT_CREATION] Attempting immediate creation for agent '{temp_name}'")
|
|
1578
1586
|
|
|
1579
1587
|
try:
|
|
1580
1588
|
# Create the actual agent primitive NOW
|
|
@@ -1593,7 +1601,7 @@ def create_dsl_stubs(
|
|
|
1593
1601
|
if "log_handler" in _runtime_context:
|
|
1594
1602
|
agent_config["log_handler"] = _runtime_context["log_handler"]
|
|
1595
1603
|
|
|
1596
|
-
logger.
|
|
1604
|
+
logger.debug(
|
|
1597
1605
|
f"[AGENT_CREATION] Creating agent immediately: name={temp_name}, has_log_handler={'log_handler' in agent_config}"
|
|
1598
1606
|
)
|
|
1599
1607
|
agent_primitive = create_dspy_agent(
|
|
@@ -1607,7 +1615,7 @@ def create_dsl_stubs(
|
|
|
1607
1615
|
handle._set_primitive(
|
|
1608
1616
|
agent_primitive, execution_context=_runtime_context.get("execution_context")
|
|
1609
1617
|
)
|
|
1610
|
-
logger.
|
|
1618
|
+
logger.debug(
|
|
1611
1619
|
f"[AGENT_CREATION] Agent '{temp_name}' created immediately during declaration, has_log_handler={hasattr(agent_primitive, 'log_handler') and agent_primitive.log_handler is not None}"
|
|
1612
1620
|
)
|
|
1613
1621
|
|
|
@@ -1615,7 +1623,7 @@ def create_dsl_stubs(
|
|
|
1615
1623
|
if "_created_agents" not in _runtime_context:
|
|
1616
1624
|
_runtime_context["_created_agents"] = {}
|
|
1617
1625
|
_runtime_context["_created_agents"][temp_name] = agent_primitive
|
|
1618
|
-
logger.
|
|
1626
|
+
logger.debug(f"[AGENT_CREATION] Stored agent '{temp_name}' in _created_agents dict")
|
|
1619
1627
|
|
|
1620
1628
|
except Exception as e:
|
|
1621
1629
|
import traceback
|
|
@@ -1757,13 +1765,13 @@ def _make_binding_callback(
|
|
|
1757
1765
|
old_name = value.name
|
|
1758
1766
|
if old_name.startswith("_temp_agent_"):
|
|
1759
1767
|
# Rename the agent handle
|
|
1760
|
-
callback_logger.
|
|
1768
|
+
callback_logger.debug(f"[AGENT_RENAME] Renaming agent '{old_name}' to '{name}'")
|
|
1761
1769
|
value.name = name
|
|
1762
1770
|
|
|
1763
1771
|
# Also rename the underlying primitive if it exists
|
|
1764
1772
|
if value._primitive is not None:
|
|
1765
1773
|
value._primitive.name = name
|
|
1766
|
-
callback_logger.
|
|
1774
|
+
callback_logger.debug(
|
|
1767
1775
|
f"[AGENT_RENAME] Updated primitive name: '{old_name}' -> '{name}'"
|
|
1768
1776
|
)
|
|
1769
1777
|
|
|
@@ -1777,7 +1785,7 @@ def _make_binding_callback(
|
|
|
1777
1785
|
if hasattr(builder, "registry") and old_name in builder.registry.agents:
|
|
1778
1786
|
agent_data = builder.registry.agents.pop(old_name)
|
|
1779
1787
|
builder.registry.agents[name] = agent_data
|
|
1780
|
-
callback_logger.
|
|
1788
|
+
callback_logger.debug(
|
|
1781
1789
|
f"[AGENT_RENAME] Re-registered agent '{name}' in builder.registry.agents"
|
|
1782
1790
|
)
|
|
1783
1791
|
|
|
@@ -1786,7 +1794,7 @@ def _make_binding_callback(
|
|
|
1786
1794
|
if old_name in runtime_context["_created_agents"]:
|
|
1787
1795
|
agent_primitive = runtime_context["_created_agents"].pop(old_name)
|
|
1788
1796
|
runtime_context["_created_agents"][name] = agent_primitive
|
|
1789
|
-
callback_logger.
|
|
1797
|
+
callback_logger.debug(
|
|
1790
1798
|
f"[AGENT_RENAME] Updated _created_agents dict: '{old_name}' -> '{name}'"
|
|
1791
1799
|
)
|
|
1792
1800
|
|
tactus/core/execution_context.py
CHANGED
|
@@ -187,7 +187,7 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
187
187
|
On replay, returns cached result from execution log.
|
|
188
188
|
On first execution, runs fn(), records in log, and returns result.
|
|
189
189
|
"""
|
|
190
|
-
logger.
|
|
190
|
+
logger.debug(
|
|
191
191
|
f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, has_log_handler={self.log_handler is not None}"
|
|
192
192
|
)
|
|
193
193
|
current_position = self.metadata.replay_index
|
|
@@ -259,7 +259,7 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
259
259
|
source_location=source_location,
|
|
260
260
|
procedure_id=self.procedure_id,
|
|
261
261
|
)
|
|
262
|
-
logger.
|
|
262
|
+
logger.debug(
|
|
263
263
|
f"[CHECKPOINT] Emitting CheckpointCreatedEvent: position={current_position}, type={checkpoint_type}, duration_ms={duration_ms}"
|
|
264
264
|
)
|
|
265
265
|
self.log_handler.log(event)
|
tactus/core/lua_sandbox.py
CHANGED
|
@@ -73,7 +73,7 @@ class LuaSandbox:
|
|
|
73
73
|
# Setup safe globals
|
|
74
74
|
self._setup_safe_globals()
|
|
75
75
|
|
|
76
|
-
logger.
|
|
76
|
+
logger.debug("Lua sandbox initialized successfully")
|
|
77
77
|
|
|
78
78
|
def _attribute_filter(self, obj, attr_name, is_setting):
|
|
79
79
|
"""
|
|
@@ -274,7 +274,7 @@ class LuaSandbox:
|
|
|
274
274
|
self.lua.globals()["math"] = safe_math_table
|
|
275
275
|
self.lua.globals()["os"] = safe_os_table
|
|
276
276
|
|
|
277
|
-
logger.
|
|
277
|
+
logger.debug("Installed safe math and os libraries with determinism checking")
|
|
278
278
|
return # Skip default os.date setup below
|
|
279
279
|
|
|
280
280
|
# Add safe subset of os module (only date function for timestamps)
|
tactus/docker/Dockerfile
CHANGED
|
@@ -36,6 +36,10 @@ COPY pyproject.toml ./
|
|
|
36
36
|
COPY README.md ./
|
|
37
37
|
COPY tactus/ ./tactus/
|
|
38
38
|
|
|
39
|
+
# Ensure the non-root runtime user can read the codebase even if the host
|
|
40
|
+
# working tree has restrictive permissions (e.g., umask 077).
|
|
41
|
+
RUN chmod -R a+rX /app/tactus
|
|
42
|
+
|
|
39
43
|
# Install Tactus and its dependencies
|
|
40
44
|
RUN pip install --no-cache-dir -e .
|
|
41
45
|
|
tactus/dspy/agent.py
CHANGED
|
@@ -191,11 +191,19 @@ class DSPyAgentHandle:
|
|
|
191
191
|
if response and hasattr(response, "_hidden_params"):
|
|
192
192
|
total_cost = response._hidden_params.get("response_cost")
|
|
193
193
|
|
|
194
|
-
if total_cost is None and
|
|
194
|
+
if total_cost is None and total_tokens > 0:
|
|
195
195
|
try:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
# We already have token counts, so compute cost from tokens to avoid relying
|
|
197
|
+
# on provider-specific response object shapes.
|
|
198
|
+
from litellm.cost_calculator import cost_per_token
|
|
199
|
+
|
|
200
|
+
prompt_cost, completion_cost = cost_per_token(
|
|
201
|
+
model=str(model) if model is not None else "",
|
|
202
|
+
prompt_tokens=prompt_tokens,
|
|
203
|
+
completion_tokens=completion_tokens,
|
|
204
|
+
call_type="completion",
|
|
205
|
+
)
|
|
206
|
+
total_cost = float(prompt_cost) + float(completion_cost)
|
|
199
207
|
except Exception as e:
|
|
200
208
|
logger.warning(f"[COST] Agent '{self.name}': failed to calculate cost: {e}")
|
|
201
209
|
total_cost = 0.0
|
|
@@ -326,6 +334,7 @@ class DSPyAgentHandle:
|
|
|
326
334
|
|
|
327
335
|
Streaming is enabled when:
|
|
328
336
|
- log_handler is available (for emitting events)
|
|
337
|
+
- log_handler supports streaming events
|
|
329
338
|
- disable_streaming is False
|
|
330
339
|
- No structured output schema (streaming only works with plain text)
|
|
331
340
|
|
|
@@ -337,6 +346,14 @@ class DSPyAgentHandle:
|
|
|
337
346
|
logger.debug(f"[STREAMING] Agent '{self.name}': no log_handler, streaming disabled")
|
|
338
347
|
return False
|
|
339
348
|
|
|
349
|
+
# Allow log handlers to opt out of streaming (e.g., cost-only collectors)
|
|
350
|
+
supports_streaming = getattr(self.log_handler, "supports_streaming", True)
|
|
351
|
+
if not supports_streaming:
|
|
352
|
+
logger.debug(
|
|
353
|
+
f"[STREAMING] Agent '{self.name}': log_handler supports_streaming=False, streaming disabled"
|
|
354
|
+
)
|
|
355
|
+
return False
|
|
356
|
+
|
|
340
357
|
# Respect explicit disable flag
|
|
341
358
|
if self.disable_streaming:
|
|
342
359
|
logger.debug(
|
|
@@ -673,9 +690,7 @@ class DSPyAgentHandle:
|
|
|
673
690
|
result = worker({message = "Process this task"})
|
|
674
691
|
print(result.response)
|
|
675
692
|
"""
|
|
676
|
-
logger.
|
|
677
|
-
f"[CHECKPOINT] DSPyAgentHandle.__call__ invoked directly for agent '{self.name}' - THIS BYPASSES AgentHandle checkpoint logic!"
|
|
678
|
-
)
|
|
693
|
+
logger.debug(f"Agent '{self.name}' invoked via __call__()")
|
|
679
694
|
inputs = inputs or {}
|
|
680
695
|
|
|
681
696
|
# Handle string input as a convenience: Agent("message") -> Agent({message="message"})
|
tactus/primitives/handles.py
CHANGED
|
@@ -108,9 +108,9 @@ class AgentHandle:
|
|
|
108
108
|
result = worker({message = "Process this task"})
|
|
109
109
|
print(result.response)
|
|
110
110
|
"""
|
|
111
|
-
logger.
|
|
112
|
-
f"[CHECKPOINT] AgentHandle '{self.name}'.__call__ invoked, _primitive={self._primitive is not None}, _execution_context={self._execution_context is not None}"
|
|
113
|
-
)
|
|
111
|
+
logger.debug(
|
|
112
|
+
f"[CHECKPOINT] AgentHandle '{self.name}'.__call__ invoked, _primitive={self._primitive is not None}, _execution_context={self._execution_context is not None}"
|
|
113
|
+
)
|
|
114
114
|
if self._primitive is None:
|
|
115
115
|
raise RuntimeError(
|
|
116
116
|
f"Agent '{self.name}' initialization failed.\n"
|
|
@@ -121,9 +121,9 @@ class AgentHandle:
|
|
|
121
121
|
converted_inputs = _convert_lua_table(inputs) if inputs is not None else None
|
|
122
122
|
|
|
123
123
|
# If we have an execution context, checkpoint the agent call
|
|
124
|
-
logger.
|
|
125
|
-
f"[CHECKPOINT] AgentHandle '{self.name}' called, has_execution_context={self._execution_context is not None}"
|
|
126
|
-
)
|
|
124
|
+
logger.debug(
|
|
125
|
+
f"[CHECKPOINT] AgentHandle '{self.name}' called, has_execution_context={self._execution_context is not None}"
|
|
126
|
+
)
|
|
127
127
|
if self._execution_context is not None:
|
|
128
128
|
|
|
129
129
|
def agent_call():
|
|
@@ -146,9 +146,9 @@ class AgentHandle:
|
|
|
146
146
|
except Exception as e:
|
|
147
147
|
logger.debug(f"Could not capture source location: {e}")
|
|
148
148
|
|
|
149
|
-
logger.
|
|
150
|
-
f"[CHECKPOINT] Creating checkpoint for agent '{self.name}', type=agent_turn, source_info={source_info}"
|
|
151
|
-
)
|
|
149
|
+
logger.debug(
|
|
150
|
+
f"[CHECKPOINT] Creating checkpoint for agent '{self.name}', type=agent_turn, source_info={source_info}"
|
|
151
|
+
)
|
|
152
152
|
return self._execution_context.checkpoint(
|
|
153
153
|
agent_call, checkpoint_type="agent_turn", source_info=source_info
|
|
154
154
|
)
|
|
@@ -170,9 +170,9 @@ class AgentHandle:
|
|
|
170
170
|
"""
|
|
171
171
|
self._primitive = primitive
|
|
172
172
|
self._execution_context = execution_context
|
|
173
|
-
logger.
|
|
174
|
-
f"[CHECKPOINT] AgentHandle '{self.name}' connected to primitive (checkpointing={'enabled' if execution_context else 'disabled'}, execution_context={execution_context})"
|
|
175
|
-
)
|
|
173
|
+
logger.debug(
|
|
174
|
+
f"[CHECKPOINT] AgentHandle '{self.name}' connected to primitive (checkpointing={'enabled' if execution_context else 'disabled'}, execution_context={execution_context})"
|
|
175
|
+
)
|
|
176
176
|
|
|
177
177
|
def __repr__(self) -> str:
|
|
178
178
|
connected = "connected" if self._primitive else "disconnected"
|
|
@@ -8,7 +8,9 @@ and collecting results via stdio communication.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
|
+
import re
|
|
11
12
|
import shutil
|
|
13
|
+
import sys
|
|
12
14
|
import tempfile
|
|
13
15
|
import time
|
|
14
16
|
import uuid
|
|
@@ -25,6 +27,22 @@ from .protocol import (
|
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
27
29
|
|
|
30
|
+
_CONTAINER_LOG_RE = re.compile(
|
|
31
|
+
r"^(?P<asctime>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) "
|
|
32
|
+
r"\[(?P<level>[A-Z]+)\] "
|
|
33
|
+
r"(?P<logger>[^:]+): "
|
|
34
|
+
r"(?P<message>.*)$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_LEVEL_MAP = {
|
|
38
|
+
"DEBUG": logging.DEBUG,
|
|
39
|
+
"INFO": logging.INFO,
|
|
40
|
+
"WARNING": logging.WARNING,
|
|
41
|
+
"WARN": logging.WARNING,
|
|
42
|
+
"ERROR": logging.ERROR,
|
|
43
|
+
"CRITICAL": logging.CRITICAL,
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
|
|
29
47
|
class SandboxError(Exception):
|
|
30
48
|
"""Raised when sandbox execution fails."""
|
|
@@ -158,6 +176,7 @@ class ContainerRunner:
|
|
|
158
176
|
extra_env: Optional[Dict[str, str]] = None,
|
|
159
177
|
execution_id: Optional[str] = None,
|
|
160
178
|
callback_url: Optional[str] = None,
|
|
179
|
+
volume_base_dir: Optional[Path] = None,
|
|
161
180
|
) -> List[str]:
|
|
162
181
|
"""
|
|
163
182
|
Build the docker run command.
|
|
@@ -218,7 +237,7 @@ class ContainerRunner:
|
|
|
218
237
|
|
|
219
238
|
# Additional user-configured volumes
|
|
220
239
|
for volume in self.config.volumes:
|
|
221
|
-
cmd.extend(["-v", volume])
|
|
240
|
+
cmd.extend(["-v", self._normalize_volume_spec(volume, base_dir=volume_base_dir)])
|
|
222
241
|
|
|
223
242
|
# Pass through environment variables
|
|
224
243
|
for var_name in self.PASSTHROUGH_ENV_VARS:
|
|
@@ -247,6 +266,42 @@ class ContainerRunner:
|
|
|
247
266
|
|
|
248
267
|
return cmd
|
|
249
268
|
|
|
269
|
+
def _normalize_volume_spec(self, volume: str, base_dir: Optional[Path]) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Normalize a docker volume spec.
|
|
272
|
+
|
|
273
|
+
Docker only accepts absolute host paths for bind mounts. For convenience,
|
|
274
|
+
allow sidecar configs to use relative paths and normalize them here.
|
|
275
|
+
|
|
276
|
+
Expected formats:
|
|
277
|
+
- /abs/host:/container[:mode]
|
|
278
|
+
- ./rel/host:/container[:mode]
|
|
279
|
+
- ../rel/host:/container[:mode]
|
|
280
|
+
- volume_name:/container[:mode] (left unchanged)
|
|
281
|
+
"""
|
|
282
|
+
# Basic split: host:container[:mode]
|
|
283
|
+
parts = volume.split(":")
|
|
284
|
+
if len(parts) < 2:
|
|
285
|
+
return volume
|
|
286
|
+
|
|
287
|
+
host = parts[0]
|
|
288
|
+
container = parts[1]
|
|
289
|
+
mode = parts[2] if len(parts) > 2 else None
|
|
290
|
+
|
|
291
|
+
host_is_path = host.startswith(("/", "./", "../", "~"))
|
|
292
|
+
if not host_is_path:
|
|
293
|
+
# Named volume (or other special form) - leave unchanged
|
|
294
|
+
return volume
|
|
295
|
+
|
|
296
|
+
host_path = Path(host).expanduser()
|
|
297
|
+
if not host_path.is_absolute():
|
|
298
|
+
host_path = (base_dir or Path.cwd()) / host_path
|
|
299
|
+
host_path = host_path.resolve()
|
|
300
|
+
|
|
301
|
+
if mode:
|
|
302
|
+
return f"{host_path}:{container}:{mode}"
|
|
303
|
+
return f"{host_path}:{container}"
|
|
304
|
+
|
|
250
305
|
async def run(
|
|
251
306
|
self,
|
|
252
307
|
source: str,
|
|
@@ -302,12 +357,22 @@ class ContainerRunner:
|
|
|
302
357
|
# Get MCP servers path
|
|
303
358
|
mcp_path = self.config.get_mcp_servers_path()
|
|
304
359
|
|
|
360
|
+
# Resolve relative bind-mount paths in sandbox.volumes relative to the procedure file
|
|
361
|
+
# when available (makes sidecar configs portable).
|
|
362
|
+
volume_base_dir = None
|
|
363
|
+
if source_file_path:
|
|
364
|
+
try:
|
|
365
|
+
volume_base_dir = Path(source_file_path).resolve().parent
|
|
366
|
+
except Exception:
|
|
367
|
+
volume_base_dir = None
|
|
368
|
+
|
|
305
369
|
# Build docker command
|
|
306
370
|
docker_cmd = self._build_docker_command(
|
|
307
371
|
working_dir=working_dir,
|
|
308
372
|
mcp_servers_path=mcp_path if mcp_path.exists() else None,
|
|
309
373
|
execution_id=execution_id,
|
|
310
374
|
callback_url=callback_url,
|
|
375
|
+
volume_base_dir=volume_base_dir,
|
|
311
376
|
)
|
|
312
377
|
|
|
313
378
|
logger.debug(f"Docker command: {' '.join(docker_cmd)}")
|
|
@@ -389,10 +454,7 @@ class ContainerRunner:
|
|
|
389
454
|
stdout = stdout_data.decode("utf-8", errors="replace")
|
|
390
455
|
stderr = stderr_data.decode("utf-8", errors="replace")
|
|
391
456
|
|
|
392
|
-
|
|
393
|
-
if stderr:
|
|
394
|
-
for line in stderr.strip().split("\n"):
|
|
395
|
-
logger.info(f"[container] {line}")
|
|
457
|
+
self._handle_container_stderr(stderr)
|
|
396
458
|
|
|
397
459
|
# Extract result from stdout
|
|
398
460
|
result = extract_result_from_stdout(stdout)
|
|
@@ -437,6 +499,61 @@ class ContainerRunner:
|
|
|
437
499
|
pass
|
|
438
500
|
raise
|
|
439
501
|
|
|
502
|
+
def _handle_container_stderr(self, stderr: str) -> None:
|
|
503
|
+
"""
|
|
504
|
+
Forward container stderr into the host log UX.
|
|
505
|
+
|
|
506
|
+
- raw: pass through container stderr as-is (CloudWatch-friendly)
|
|
507
|
+
- rich/terminal: parse container log lines and re-emit with host formatting
|
|
508
|
+
"""
|
|
509
|
+
if not stderr:
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
fmt = str(self.config.env.get("TACTUS_LOG_FORMAT", "rich")).strip().lower()
|
|
513
|
+
|
|
514
|
+
# Raw mode: avoid double timestamps by forwarding container stderr directly.
|
|
515
|
+
if fmt == "raw":
|
|
516
|
+
sys.stderr.write(stderr)
|
|
517
|
+
sys.stderr.flush()
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
# Rich/terminal: parse our container log format and re-emit.
|
|
521
|
+
current: tuple[str, int, list[str]] | None = None # (logger_name, levelno, lines)
|
|
522
|
+
|
|
523
|
+
def flush_current() -> None:
|
|
524
|
+
nonlocal current
|
|
525
|
+
if current is None:
|
|
526
|
+
return
|
|
527
|
+
logger_name, levelno, lines = current
|
|
528
|
+
message = "\n".join(lines).rstrip("\n")
|
|
529
|
+
logging.getLogger(logger_name).log(levelno, message)
|
|
530
|
+
current = None
|
|
531
|
+
|
|
532
|
+
for line in stderr.splitlines():
|
|
533
|
+
m = _CONTAINER_LOG_RE.match(line)
|
|
534
|
+
if m:
|
|
535
|
+
flush_current()
|
|
536
|
+
levelno = _LEVEL_MAP.get(m.group("level"), logging.INFO)
|
|
537
|
+
current = (m.group("logger"), levelno, [m.group("message")])
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
# Continuation heuristic: keep multi-line LogEvent context attached.
|
|
541
|
+
if current is not None and (
|
|
542
|
+
line == ""
|
|
543
|
+
or line.startswith((" ", "\t"))
|
|
544
|
+
or line.startswith("Context:")
|
|
545
|
+
or line.startswith("{")
|
|
546
|
+
or line.startswith("[")
|
|
547
|
+
):
|
|
548
|
+
current[2].append(line)
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
# Otherwise treat as standalone stderr (warnings/tracebacks/etc).
|
|
552
|
+
flush_current()
|
|
553
|
+
logging.getLogger("container.stderr").warning(line)
|
|
554
|
+
|
|
555
|
+
flush_current()
|
|
556
|
+
|
|
440
557
|
def run_sync(
|
|
441
558
|
self,
|
|
442
559
|
source: str,
|
tactus/sandbox/docker_manager.py
CHANGED
|
@@ -34,9 +34,12 @@ def calculate_source_hash(tactus_root: Path) -> str:
|
|
|
34
34
|
# Key paths that affect sandbox behavior
|
|
35
35
|
paths_to_hash = [
|
|
36
36
|
tactus_root / "tactus" / "dspy",
|
|
37
|
+
tactus_root / "tactus" / "adapters",
|
|
37
38
|
tactus_root / "tactus" / "core",
|
|
38
39
|
tactus_root / "tactus" / "primitives",
|
|
39
40
|
tactus_root / "tactus" / "sandbox",
|
|
41
|
+
tactus_root / "tactus" / "stdlib",
|
|
42
|
+
tactus_root / "tactus" / "docker",
|
|
40
43
|
tactus_root / "pyproject.toml", # Dependencies affect sandbox
|
|
41
44
|
]
|
|
42
45
|
|
|
@@ -50,12 +53,21 @@ def calculate_source_hash(tactus_root: Path) -> str:
|
|
|
50
53
|
# Hash file contents
|
|
51
54
|
hasher.update(path.read_bytes())
|
|
52
55
|
elif path.is_dir():
|
|
53
|
-
# Hash
|
|
54
|
-
for
|
|
56
|
+
# Hash directory files (recursively), skipping caches.
|
|
57
|
+
for file in sorted(path.rglob("*")):
|
|
58
|
+
if not file.is_file():
|
|
59
|
+
continue
|
|
60
|
+
if "__pycache__" in file.parts:
|
|
61
|
+
continue
|
|
62
|
+
if file.suffix == ".pyc":
|
|
63
|
+
continue
|
|
64
|
+
if file.name == ".DS_Store":
|
|
65
|
+
continue
|
|
66
|
+
|
|
55
67
|
# Hash relative path + contents for reproducibility
|
|
56
|
-
rel_path = str(
|
|
68
|
+
rel_path = str(file.relative_to(tactus_root))
|
|
57
69
|
hasher.update(rel_path.encode())
|
|
58
|
-
hasher.update(
|
|
70
|
+
hasher.update(file.read_bytes())
|
|
59
71
|
|
|
60
72
|
# Return short hash (16 chars is plenty for collision avoidance)
|
|
61
73
|
return hasher.hexdigest()[:16]
|
tactus/sandbox/entrypoint.py
CHANGED
|
@@ -22,13 +22,35 @@ if TYPE_CHECKING:
|
|
|
22
22
|
from tactus.sandbox.protocol import ExecutionResult
|
|
23
23
|
|
|
24
24
|
# Configure logging to stderr (stdout is reserved for result)
|
|
25
|
+
_LOG_LEVELS = {
|
|
26
|
+
"debug": logging.DEBUG,
|
|
27
|
+
"info": logging.INFO,
|
|
28
|
+
"warning": logging.WARNING,
|
|
29
|
+
"warn": logging.WARNING,
|
|
30
|
+
"error": logging.ERROR,
|
|
31
|
+
"critical": logging.CRITICAL,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_log_level_str = os.environ.get("TACTUS_LOG_LEVEL", "info").strip().lower()
|
|
35
|
+
_log_level = _LOG_LEVELS.get(_log_level_str, logging.INFO)
|
|
36
|
+
|
|
37
|
+
# CloudWatch-friendly, one line per record.
|
|
38
|
+
_log_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
39
|
+
|
|
25
40
|
logging.basicConfig(
|
|
26
|
-
level=
|
|
27
|
-
format=
|
|
41
|
+
level=_log_level,
|
|
42
|
+
format=_log_fmt,
|
|
28
43
|
stream=sys.stderr,
|
|
29
44
|
)
|
|
30
45
|
logger = logging.getLogger(__name__)
|
|
31
46
|
|
|
47
|
+
# Keep container stderr focused on procedure logs by default.
|
|
48
|
+
# Use `TACTUS_LOG_LEVEL=debug` to include internal runtime logs.
|
|
49
|
+
if _log_level > logging.DEBUG:
|
|
50
|
+
logging.getLogger("tactus.core").setLevel(logging.WARNING)
|
|
51
|
+
logging.getLogger("tactus.primitives").setLevel(logging.WARNING)
|
|
52
|
+
logging.getLogger("tactus.stdlib").setLevel(logging.WARNING)
|
|
53
|
+
|
|
32
54
|
|
|
33
55
|
def read_request_from_stdin() -> Optional[Dict[str, Any]]:
|
|
34
56
|
"""Read the execution request from stdin as JSON."""
|
|
@@ -81,6 +103,7 @@ async def execute_procedure(
|
|
|
81
103
|
from tactus.core import TactusRuntime
|
|
82
104
|
from tactus.adapters.memory import MemoryStorage
|
|
83
105
|
from tactus.adapters.http_callback_log import HTTPCallbackLogHandler
|
|
106
|
+
from tactus.adapters.cost_collector_log import CostCollectorLogHandler
|
|
84
107
|
|
|
85
108
|
# Create a unique procedure ID
|
|
86
109
|
import uuid
|
|
@@ -93,6 +116,11 @@ async def execute_procedure(
|
|
|
93
116
|
logger.info(
|
|
94
117
|
f"[SANDBOX] Using HTTP callback log handler: {os.environ.get('TACTUS_CALLBACK_URL')}"
|
|
95
118
|
)
|
|
119
|
+
else:
|
|
120
|
+
# Provide cost collection + checkpoint event handling even without IDE callbacks.
|
|
121
|
+
# This avoids misleading 0-cost summaries for sandbox runs, while keeping streaming off.
|
|
122
|
+
log_handler = CostCollectorLogHandler()
|
|
123
|
+
logger.info("[SANDBOX] No callback URL set; using CostCollectorLogHandler")
|
|
96
124
|
|
|
97
125
|
# Create runtime with log handler for event streaming
|
|
98
126
|
runtime = TactusRuntime(
|
tactus/stdlib/io/fs.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.io.fs - Filesystem helpers for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides safe directory listing and globbing, sandboxed to the procedure's base directory.
|
|
5
|
+
|
|
6
|
+
Usage in .tac files:
|
|
7
|
+
local fs = require("tactus.io.fs")
|
|
8
|
+
|
|
9
|
+
-- List files in a directory
|
|
10
|
+
local entries = fs.list_dir("chapters")
|
|
11
|
+
|
|
12
|
+
-- Glob files (sorted by default)
|
|
13
|
+
local qmd_files = fs.glob("chapters/*.qmd")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import glob as _glob
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
# Get context (injected by loader)
|
|
25
|
+
_ctx = getattr(sys.modules[__name__], "__tactus_context__", None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _base_path() -> str:
|
|
29
|
+
if _ctx and getattr(_ctx, "base_path", None):
|
|
30
|
+
return str(_ctx.base_path)
|
|
31
|
+
return os.getcwd()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_relative_path(path: str) -> None:
|
|
35
|
+
# Keep semantics consistent with other stdlib IO modules:
|
|
36
|
+
# - no absolute paths
|
|
37
|
+
# - no traversal segments
|
|
38
|
+
p = Path(path)
|
|
39
|
+
if p.is_absolute():
|
|
40
|
+
raise PermissionError(f"Absolute paths not allowed: {path}")
|
|
41
|
+
if any(part == ".." for part in p.parts):
|
|
42
|
+
raise PermissionError(f"Path traversal not allowed: {path}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_dir(dirpath: str, options: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
46
|
+
"""
|
|
47
|
+
List entries in a directory.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
dirpath: Directory path (relative to working directory)
|
|
51
|
+
options:
|
|
52
|
+
- files_only: bool (default True) - include only files
|
|
53
|
+
- dirs_only: bool (default False) - include only directories
|
|
54
|
+
- sort: bool (default True) - sort results
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of entry paths (relative to working directory)
|
|
58
|
+
"""
|
|
59
|
+
options = options or {}
|
|
60
|
+
files_only = bool(options.get("files_only", True))
|
|
61
|
+
dirs_only = bool(options.get("dirs_only", False))
|
|
62
|
+
sort = bool(options.get("sort", True))
|
|
63
|
+
|
|
64
|
+
_validate_relative_path(dirpath)
|
|
65
|
+
|
|
66
|
+
base = os.path.realpath(_base_path())
|
|
67
|
+
abs_dir = os.path.realpath(os.path.join(base, dirpath))
|
|
68
|
+
|
|
69
|
+
# Ensure the directory itself is within base path (symlink-safe via realpath)
|
|
70
|
+
if abs_dir != base and not abs_dir.startswith(base + os.sep):
|
|
71
|
+
raise PermissionError(f"Access denied: path outside working directory: {dirpath}")
|
|
72
|
+
|
|
73
|
+
if not os.path.isdir(abs_dir):
|
|
74
|
+
raise FileNotFoundError(f"Directory not found: {dirpath}")
|
|
75
|
+
|
|
76
|
+
entries: List[str] = []
|
|
77
|
+
for name in os.listdir(abs_dir):
|
|
78
|
+
abs_child = os.path.realpath(os.path.join(abs_dir, name))
|
|
79
|
+
if abs_child != base and not abs_child.startswith(base + os.sep):
|
|
80
|
+
# Skip symlink escapes
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
is_dir = os.path.isdir(abs_child)
|
|
84
|
+
is_file = os.path.isfile(abs_child)
|
|
85
|
+
|
|
86
|
+
if dirs_only and not is_dir:
|
|
87
|
+
continue
|
|
88
|
+
if files_only and not is_file:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
rel = os.path.relpath(abs_child, base).replace("\\", "/")
|
|
92
|
+
entries.append(rel)
|
|
93
|
+
|
|
94
|
+
if sort:
|
|
95
|
+
entries.sort()
|
|
96
|
+
|
|
97
|
+
return entries
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def glob(pattern: str, options: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
101
|
+
"""
|
|
102
|
+
Glob files within the working directory.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
pattern: Glob pattern (relative to working directory), e.g. "chapters/*.qmd"
|
|
106
|
+
options:
|
|
107
|
+
- recursive: bool (default False) - enable ** patterns
|
|
108
|
+
- files_only: bool (default True) - include only files
|
|
109
|
+
- dirs_only: bool (default False) - include only directories
|
|
110
|
+
- sort: bool (default True) - sort results
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of matched paths (relative to working directory)
|
|
114
|
+
"""
|
|
115
|
+
options = options or {}
|
|
116
|
+
recursive = bool(options.get("recursive", False))
|
|
117
|
+
files_only = bool(options.get("files_only", True))
|
|
118
|
+
dirs_only = bool(options.get("dirs_only", False))
|
|
119
|
+
sort = bool(options.get("sort", True))
|
|
120
|
+
|
|
121
|
+
_validate_relative_path(pattern)
|
|
122
|
+
|
|
123
|
+
base = os.path.realpath(_base_path())
|
|
124
|
+
abs_pattern = os.path.realpath(os.path.join(base, pattern))
|
|
125
|
+
|
|
126
|
+
# Ensure the pattern root is within base path (symlink-safe via realpath)
|
|
127
|
+
if abs_pattern != base and not abs_pattern.startswith(base + os.sep):
|
|
128
|
+
raise PermissionError(f"Access denied: path outside working directory: {pattern}")
|
|
129
|
+
|
|
130
|
+
matches: List[str] = []
|
|
131
|
+
for match in _glob.glob(abs_pattern, recursive=recursive):
|
|
132
|
+
abs_match = os.path.realpath(match)
|
|
133
|
+
if abs_match != base and not abs_match.startswith(base + os.sep):
|
|
134
|
+
# Skip symlink escapes
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
is_dir = os.path.isdir(abs_match)
|
|
138
|
+
is_file = os.path.isfile(abs_match)
|
|
139
|
+
|
|
140
|
+
if dirs_only and not is_dir:
|
|
141
|
+
continue
|
|
142
|
+
if files_only and not is_file:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
rel = os.path.relpath(abs_match, base).replace("\\", "/")
|
|
146
|
+
matches.append(rel)
|
|
147
|
+
|
|
148
|
+
if sort:
|
|
149
|
+
matches.sort()
|
|
150
|
+
|
|
151
|
+
return matches
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__tactus_exports__ = ["list_dir", "glob"]
|
tactus/stdlib/loader.py
CHANGED
|
@@ -134,7 +134,7 @@ class StdlibModuleLoader:
|
|
|
134
134
|
# Cache
|
|
135
135
|
self.loaded_modules[module_name] = lua_table
|
|
136
136
|
|
|
137
|
-
logger.
|
|
137
|
+
logger.debug(f"Loaded stdlib Python module: {module_name}")
|
|
138
138
|
return lua_table
|
|
139
139
|
|
|
140
140
|
def _get_module_exports(self, module) -> Dict[str, Callable]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tactus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Tactus: Lua-based DSL for agentic workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/AnthusAI/Tactus
|
|
6
6
|
Project-URL: Documentation, https://github.com/AnthusAI/Tactus/tree/main/docs
|
|
@@ -1488,6 +1488,21 @@ tactus run workflow.tac # If procedure has required inputs, you'll be prompted
|
|
|
1488
1488
|
tactus run workflow.tac --storage file --storage-path ./data
|
|
1489
1489
|
```
|
|
1490
1490
|
|
|
1491
|
+
#### Logging Options
|
|
1492
|
+
|
|
1493
|
+
The `run` command supports filtering and formatting logs:
|
|
1494
|
+
|
|
1495
|
+
```bash
|
|
1496
|
+
# Show less/more output
|
|
1497
|
+
tactus run workflow.tac --log-level warning
|
|
1498
|
+
tactus run workflow.tac --log-level debug
|
|
1499
|
+
|
|
1500
|
+
# Choose a log format
|
|
1501
|
+
tactus run workflow.tac --log-format rich # default, grouped timestamps
|
|
1502
|
+
tactus run workflow.tac --log-format terminal # no timestamps, higher-signal terminal output
|
|
1503
|
+
tactus run workflow.tac --log-format raw # one-line-per-record, timestamped (CloudWatch-friendly)
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1491
1506
|
The CLI automatically parses parameter types:
|
|
1492
1507
|
- **Strings**: Direct values or quoted strings
|
|
1493
1508
|
- **Numbers**: Integers or floats are auto-detected
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
tactus/__init__.py,sha256=
|
|
1
|
+
tactus/__init__.py,sha256=DvHJYE4l1j3LCTY2nrgfiK0uQ5pzjUURsQydmI9QeOY,1245
|
|
2
2
|
tactus/adapters/__init__.py,sha256=lU8uUxuryFRIpVrn_KeVK7aUhsvOT1tYsuE3FOOIFpI,289
|
|
3
|
-
tactus/adapters/cli_hitl.py,sha256=
|
|
3
|
+
tactus/adapters/cli_hitl.py,sha256=3dH58du0lN4k-OvQrAHrAqHFqBjolqNKFb94JaNHtn8,6964
|
|
4
4
|
tactus/adapters/cli_log.py,sha256=JKD693goi_wT_Kei4mTc2KJ-0QfgFZTpV3Prb8zfNZo,9779
|
|
5
|
+
tactus/adapters/cost_collector_log.py,sha256=Ha3pFMdC0HJ8eBnZpM1VRLm-94RKelu5PEPDgFgNKow,1701
|
|
5
6
|
tactus/adapters/file_storage.py,sha256=c4prD6-NSEMixrpQsWsdr3xuKzxptoEaLjUkZl9SnKk,12759
|
|
6
7
|
tactus/adapters/http_callback_log.py,sha256=likXNiLGiHXNHAeWGzUPhbd4Di94PJVjXz5ABNRfZig,3687
|
|
7
8
|
tactus/adapters/ide_log.py,sha256=JfJE5rGM_bPfBImod-2_QDhyyk2ObVEmkvhsWSzaBKU,2026
|
|
@@ -14,14 +15,14 @@ tactus/backends/http_backend.py,sha256=D2N91I5bnjhHMLG84-U-BRS-mIuwoQq72Feffi7At
|
|
|
14
15
|
tactus/backends/model_backend.py,sha256=P8dCUpDxJmA8_EO1snZuXyIyUZ_BlqReeC6zenO7Kv0,763
|
|
15
16
|
tactus/backends/pytorch_backend.py,sha256=I7H7UTa_Scx9_FtmPWn-G4noadaNVEQj-9Kjtjpgl6E,3305
|
|
16
17
|
tactus/cli/__init__.py,sha256=kVhdCkwWEPdt3vn9si-iKvh6M9817aOH6rLSsNzRuyg,80
|
|
17
|
-
tactus/cli/app.py,sha256=
|
|
18
|
+
tactus/cli/app.py,sha256=KPTS7vAxMQE-sog7uAaZOyaF73XMthrM5iKuPWXQmOk,81825
|
|
18
19
|
tactus/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
20
|
tactus/core/__init__.py,sha256=TK5rWr3HmOO_igFa5ESGp6teWwS58vnvQhIWqkcgqwk,880
|
|
20
|
-
tactus/core/config_manager.py,sha256=
|
|
21
|
-
tactus/core/dsl_stubs.py,sha256=
|
|
21
|
+
tactus/core/config_manager.py,sha256=u90XChTeb2reZ3I6c3o_zI2UlzlFCNtL9-NhE1Vm6L0,31577
|
|
22
|
+
tactus/core/dsl_stubs.py,sha256=JhXJiFQNNAMkW43fFeq7sNgnzbbqZaucLwcObfJpFDg,67261
|
|
22
23
|
tactus/core/exceptions.py,sha256=HruvJQ4yIi78hOvvHviNZolcraMwXrlGQzxHqYFQapA,1468
|
|
23
|
-
tactus/core/execution_context.py,sha256=
|
|
24
|
-
tactus/core/lua_sandbox.py,sha256=
|
|
24
|
+
tactus/core/execution_context.py,sha256=WNd55_JhmkDifA9Td3xIs8to5bwGDlCgPcbvkvLYAv0,16658
|
|
25
|
+
tactus/core/lua_sandbox.py,sha256=WuXRuXSl9ylU0y3ZMvyDWjWiG6GdLVn2csMY-2Zmrbg,18262
|
|
25
26
|
tactus/core/message_history_manager.py,sha256=e3_seV6bWSJVicrZYf2LmaQBvroDXDRc5Tho49fZ_ns,7596
|
|
26
27
|
tactus/core/mocking.py,sha256=1MKtg8FHCYj-Goihi5b8xNNYo-MDuzSpsHEX_e7bnfg,9217
|
|
27
28
|
tactus/core/output_validator.py,sha256=GDLGbIJAustb4__mThzLWQFpMyvRj84bgR4K28xMTjE,7777
|
|
@@ -31,10 +32,10 @@ tactus/core/template_resolver.py,sha256=6zRq4zT1lRHdLoa7M-yoLx4hFZ9RcjH9Y0EdaAtW
|
|
|
31
32
|
tactus/core/yaml_parser.py,sha256=VbKej95wzen07JWP1K2x2eIOhWkZIDex_fHAm5Qitao,13890
|
|
32
33
|
tactus/core/dependencies/__init__.py,sha256=28-TM7_i-JqTD3hvkq1kMzr__A8VjfIKXymdW9mn5NM,362
|
|
33
34
|
tactus/core/dependencies/registry.py,sha256=bgRdqJJTUrnQlu0wvjv2In1EPq7prZq-b9eBDhE3rmE,6114
|
|
34
|
-
tactus/docker/Dockerfile,sha256=
|
|
35
|
+
tactus/docker/Dockerfile,sha256=fnK5kZlgM-L7vboiwfTqcs70OsZsvh1ba4YzRxie138,1887
|
|
35
36
|
tactus/docker/entrypoint.sh,sha256=-qYq5RQIGS6KirJ6NO0XiHZf_oAdfmk5HzHOBsiqxRM,1870
|
|
36
37
|
tactus/dspy/__init__.py,sha256=beUkvMUFdPvZE9-bEOfRo2TH-FoCvPT_L9_dpJPW324,1226
|
|
37
|
-
tactus/dspy/agent.py,sha256=
|
|
38
|
+
tactus/dspy/agent.py,sha256=H1QU_LPknF3vLL4uAg7MdrZXWOjxpXU8PQCZWOI3bWc,38699
|
|
38
39
|
tactus/dspy/config.py,sha256=oXwYzfVCTBHRnbH_vvm591FhE0zKep7wgmujLSXKf_c,6013
|
|
39
40
|
tactus/dspy/history.py,sha256=0yGi3P5ruRUPoRyaCWsUDeuEYYsfproc_7pMVZuhmUo,5980
|
|
40
41
|
tactus/dspy/module.py,sha256=sJdFS-5A4SpuiMLjbwiZJCvg3pTtEx8x8MRVaqjCQ2I,15423
|
|
@@ -46,7 +47,7 @@ tactus/ide/server.py,sha256=KALEdXcoUHLjJPWFrbyMbPkQeoHKfhSu7J22HeXx7yA,98912
|
|
|
46
47
|
tactus/primitives/__init__.py,sha256=2NHEGBCuasVJlD5bHE6OSYivZ3WEp90DJDTvoKehVzg,1712
|
|
47
48
|
tactus/primitives/control.py,sha256=PjRt_Pegcj2L1Uy-IUBQKTYFRMXy7b9q1z2kzJNH8qw,4683
|
|
48
49
|
tactus/primitives/file.py,sha256=-kz0RCst_i_3V860-LtGntYpE0Mm371U_KGHqELbMx0,7186
|
|
49
|
-
tactus/primitives/handles.py,sha256=
|
|
50
|
+
tactus/primitives/handles.py,sha256=5UUpoyASKZPaE3UcF6IOwTYicmGqYjuABncNNF2lAbY,11753
|
|
50
51
|
tactus/primitives/human.py,sha256=yjh5LF4PNeKaFgMWMVJd-qkp9XSfNqQM4n4X-Qg60Ow,12397
|
|
51
52
|
tactus/primitives/json.py,sha256=XQQQEwUvix_Djscq_thBTHeCf5VCex48rj4m6yp4f7Y,5722
|
|
52
53
|
tactus/primitives/log.py,sha256=rLYgHeXgU1Pr3lYFXZxfuorZDdEFPGh4Mjeosjz-7Zc,6243
|
|
@@ -79,16 +80,17 @@ tactus/providers/google.py,sha256=wgZ3eiQif1rq1T8BK5V2kL_QVCmqBQZuWLz37y9cxOQ,31
|
|
|
79
80
|
tactus/providers/openai.py,sha256=3qSXfdELTHdU7vuRSxQrtnfNctt0HhrePOLFj3YlViA,2692
|
|
80
81
|
tactus/sandbox/__init__.py,sha256=UCBvPD63szvSdwSzpznLW-cnJOgGkVHiKcmJtsAmnuA,1424
|
|
81
82
|
tactus/sandbox/config.py,sha256=zbv2ewzwN381HuJRaPnDgzdbZlxNIUYIWAbj9udYf0A,3713
|
|
82
|
-
tactus/sandbox/container_runner.py,sha256=
|
|
83
|
-
tactus/sandbox/docker_manager.py,sha256=
|
|
84
|
-
tactus/sandbox/entrypoint.py,sha256=
|
|
83
|
+
tactus/sandbox/container_runner.py,sha256=lGpWK0e4_E0_eCyogXMNQ4uz1bKVGpfqn71yLX58-Y0,20249
|
|
84
|
+
tactus/sandbox/docker_manager.py,sha256=SGv0IEN9usgyQRvbonrgOLm6GICel8ifzBAopAwOyt0,13949
|
|
85
|
+
tactus/sandbox/entrypoint.py,sha256=GdJv_GHHKoAK9VjtkZ5LNt0oqYiz4x8F1exwkbAIjkA,6277
|
|
85
86
|
tactus/sandbox/protocol.py,sha256=hk8yYj6hDmGA32bet4AJ3d3CKiFLMBGoJnc1OrD3nLc,6397
|
|
86
87
|
tactus/stdlib/__init__.py,sha256=26AUJ2IGp6OuhWaUeGSRwTa1r7W4e6-fhoLa4u7RjLQ,288
|
|
87
|
-
tactus/stdlib/loader.py,sha256=
|
|
88
|
+
tactus/stdlib/loader.py,sha256=qjVnz5mn3Uu7g1O4vjSREHkR-YdRoON1vqJQq-oiFIE,8679
|
|
88
89
|
tactus/stdlib/io/__init__.py,sha256=lOUgkUsV6x1ZdGKnFjh-v8wBJwn2UEM28h-mEal8aMw,379
|
|
89
90
|
tactus/stdlib/io/csv.py,sha256=KsqlgO8gxJ01c9g50Zsc3U3I7aCeAohy9opy2qSWCVU,2295
|
|
90
91
|
tactus/stdlib/io/excel.py,sha256=-6oxY-Rj__OV2YSt9K2YJ6-g--BFCV55Wt9KJ8cwSmI,3494
|
|
91
92
|
tactus/stdlib/io/file.py,sha256=1_lvpn1oCTHZnZToNQEUNfC62Qa0f_Mp9yJVlnI-IGY,1977
|
|
93
|
+
tactus/stdlib/io/fs.py,sha256=Bx6Jxc3VBTWqabwq109ok-BMvL25k51ozGy8watiTj8,4802
|
|
92
94
|
tactus/stdlib/io/hdf5.py,sha256=7ELLaQZI1GOsycU1a60J6RTPNLH7EMdaPAYlNx0dQLA,3025
|
|
93
95
|
tactus/stdlib/io/json.py,sha256=P6C6rIwAxY97MivCanxKF8TmRotIUxBlHcuv8etmLu8,2641
|
|
94
96
|
tactus/stdlib/io/parquet.py,sha256=hycr0pjqysjhggHx7_UZJL_jkeP1wz8BCozL39EWk-0,2045
|
|
@@ -142,8 +144,8 @@ tactus/validation/generated/LuaParserVisitor.py,sha256=ageKSmHPxnO3jBS2fBtkmYBOd
|
|
|
142
144
|
tactus/validation/generated/__init__.py,sha256=5gWlwRI0UvmHw2fnBpj_IG6N8oZeabr5tbj1AODDvjc,196
|
|
143
145
|
tactus/validation/grammar/LuaLexer.g4,sha256=t2MXiTCr127RWAyQGvamkcU_m4veqPzSuHUtAKwalw4,2771
|
|
144
146
|
tactus/validation/grammar/LuaParser.g4,sha256=ceZenb90BdiZmVdOxMGj9qJk3QbbWVZe5HUqPgoePfY,3202
|
|
145
|
-
tactus-0.
|
|
146
|
-
tactus-0.
|
|
147
|
-
tactus-0.
|
|
148
|
-
tactus-0.
|
|
149
|
-
tactus-0.
|
|
147
|
+
tactus-0.26.0.dist-info/METADATA,sha256=ji52FYxdpMlkMoubu8rIc7tDmDHdmvTKdOAPmYt9OyA,55752
|
|
148
|
+
tactus-0.26.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
149
|
+
tactus-0.26.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
|
|
150
|
+
tactus-0.26.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
|
|
151
|
+
tactus-0.26.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|