tactus 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +55 -0
  3. tactus/adapters/cli_hitl.py +2 -2
  4. tactus/adapters/cli_log.py +0 -25
  5. tactus/adapters/cost_collector_log.py +56 -0
  6. tactus/broker/__init__.py +12 -0
  7. tactus/broker/client.py +260 -0
  8. tactus/broker/server.py +505 -0
  9. tactus/broker/stdio.py +12 -0
  10. tactus/cli/app.py +137 -12
  11. tactus/core/config_manager.py +2 -2
  12. tactus/core/dsl_stubs.py +52 -43
  13. tactus/core/execution_context.py +2 -2
  14. tactus/core/lua_sandbox.py +2 -2
  15. tactus/core/output_validator.py +6 -3
  16. tactus/core/registry.py +8 -1
  17. tactus/core/runtime.py +15 -1
  18. tactus/core/yaml_parser.py +1 -11
  19. tactus/docker/Dockerfile +4 -0
  20. tactus/dspy/agent.py +212 -109
  21. tactus/dspy/broker_lm.py +181 -0
  22. tactus/dspy/config.py +21 -8
  23. tactus/dspy/prediction.py +71 -5
  24. tactus/ide/server.py +37 -142
  25. tactus/primitives/__init__.py +2 -0
  26. tactus/primitives/handles.py +46 -19
  27. tactus/primitives/host.py +94 -0
  28. tactus/primitives/log.py +4 -0
  29. tactus/primitives/model.py +20 -2
  30. tactus/primitives/procedure.py +106 -51
  31. tactus/primitives/tool.py +0 -2
  32. tactus/protocols/__init__.py +0 -7
  33. tactus/protocols/log_handler.py +2 -2
  34. tactus/protocols/models.py +1 -1
  35. tactus/sandbox/config.py +33 -5
  36. tactus/sandbox/container_runner.py +619 -64
  37. tactus/sandbox/docker_manager.py +16 -4
  38. tactus/sandbox/entrypoint.py +56 -15
  39. tactus/sandbox/protocol.py +0 -9
  40. tactus/stdlib/io/fs.py +154 -0
  41. tactus/stdlib/loader.py +1 -1
  42. tactus/testing/README.md +0 -4
  43. tactus/testing/mock_agent.py +80 -23
  44. tactus/testing/test_runner.py +0 -18
  45. {tactus-0.25.0.dist-info → tactus-0.27.0.dist-info}/METADATA +16 -1
  46. {tactus-0.25.0.dist-info → tactus-0.27.0.dist-info}/RECORD +49 -40
  47. {tactus-0.25.0.dist-info → tactus-0.27.0.dist-info}/WHEEL +0 -0
  48. {tactus-0.25.0.dist-info → tactus-0.27.0.dist-info}/entry_points.txt +0 -0
  49. {tactus-0.25.0.dist-info → tactus-0.27.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py CHANGED
@@ -5,7 +5,7 @@ Tactus provides a declarative workflow engine for AI agents with pluggable
5
5
  backends for storage, HITL, and chat recording.
6
6
  """
7
7
 
8
- __version__ = "0.25.0"
8
+ __version__ = "0.27.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
@@ -0,0 +1,55 @@
1
+ """
2
+ Broker log handler for container event streaming over UDS.
3
+
4
+ Used inside the runtime container to forward structured log events to the
5
+ host-side broker without requiring container networking.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from asyncer import syncify
13
+
14
+ from tactus.broker.client import BrokerClient
15
+ from tactus.protocols.models import LogEvent, CostEvent
16
+
17
+
18
+ class BrokerLogHandler:
19
+ """
20
+ Log handler that forwards events to the broker via Unix domain socket.
21
+
22
+ The broker socket path is read from `TACTUS_BROKER_SOCKET`.
23
+ """
24
+
25
+ def __init__(self, client: BrokerClient):
26
+ self._client = client
27
+ self.cost_events: list[CostEvent] = []
28
+
29
+ @classmethod
30
+ def from_environment(cls) -> Optional["BrokerLogHandler"]:
31
+ client = BrokerClient.from_environment()
32
+ if client is None:
33
+ return None
34
+ return cls(client)
35
+
36
+ def log(self, event: LogEvent) -> None:
37
+ # Track cost events for aggregation (mirrors IDELogHandler behavior)
38
+ if isinstance(event, CostEvent):
39
+ self.cost_events.append(event)
40
+
41
+ # Serialize to JSON-friendly dict
42
+ event_dict = event.model_dump(mode="json")
43
+
44
+ # Normalize timestamp formatting for downstream consumers.
45
+ iso_string = event.timestamp.isoformat()
46
+ if not (iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2):
47
+ iso_string += "Z"
48
+ event_dict["timestamp"] = iso_string
49
+
50
+ # Best-effort forwarding; never crash the procedure due to streaming.
51
+ try:
52
+ syncify(self._client.emit_event)(event_dict)
53
+ except Exception:
54
+ # Swallow errors; container remains networkless and secretless even if streaming fails.
55
+ return
@@ -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.info("CLIHITLHandler initialized")
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.info(f"HITL request: {request.request_type} - {request.message}")
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()
@@ -72,13 +72,6 @@ class CLILogHandler:
72
72
  self._display_checkpoint_event(event)
73
73
  return
74
74
 
75
- # Handle system alert events
76
- from tactus.protocols.models import SystemAlertEvent
77
-
78
- if isinstance(event, SystemAlertEvent):
79
- self._display_system_alert_event(event)
80
- return
81
-
82
75
  # Handle ExecutionSummaryEvent specially
83
76
  if event.event_type == "execution_summary":
84
77
  self._display_execution_summary(event)
@@ -191,24 +184,6 @@ class CLILogHandler:
191
184
  f"{f' (saved ${event.cache_cost:.6f})' if event.cache_cost else ''}[/green]"
192
185
  )
193
186
 
194
- def _display_system_alert_event(self, event) -> None:
195
- """Display a System.alert() event."""
196
- level = (event.level or "info").lower()
197
- style = {
198
- "info": "blue",
199
- "warning": "yellow",
200
- "error": "red",
201
- "critical": "red bold",
202
- }.get(level, "blue")
203
-
204
- source_str = f" ({event.source})" if getattr(event, "source", None) else ""
205
- self.console.print(f"[{style}]• Alert{source_str}:[/{style}] {event.message}")
206
-
207
- if getattr(event, "context", None):
208
- import json
209
-
210
- self.console.print(f" Context: {json.dumps(event.context, indent=2, default=str)}")
211
-
212
187
  def _display_execution_summary(self, event) -> None:
213
188
  """Display execution summary with cost breakdown."""
214
189
  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)
@@ -0,0 +1,12 @@
1
+ """
2
+ Brokered capabilities for Tactus.
3
+
4
+ The broker is a trusted host-side process that holds credentials and performs
5
+ privileged operations (e.g., LLM API calls) on behalf of a secretless, networkless
6
+ runtime container.
7
+ """
8
+
9
+ from tactus.broker.client import BrokerClient
10
+ from tactus.broker.server import BrokerServer, TcpBrokerServer
11
+
12
+ __all__ = ["BrokerClient", "BrokerServer", "TcpBrokerServer"]
@@ -0,0 +1,260 @@
1
+ """
2
+ Broker client for use inside the runtime container.
3
+
4
+ Uses a broker transport selected at runtime:
5
+ - `stdio` (recommended for Docker Desktop): requests are written to stderr with a marker and
6
+ responses are read from stdin as NDJSON.
7
+ - Unix domain sockets (UDS): retained for non-Docker/host testing.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import ssl
14
+ import sys
15
+ import threading
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Any, AsyncIterator, Optional
19
+
20
+ from tactus.broker.stdio import STDIO_REQUEST_PREFIX, STDIO_TRANSPORT_VALUE
21
+
22
+
23
+ def _json_dumps(obj: Any) -> str:
24
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
25
+
26
+
27
+ class _StdioBrokerTransport:
28
+ def __init__(self):
29
+ self._write_lock = threading.Lock()
30
+ self._pending: dict[
31
+ str, tuple[asyncio.AbstractEventLoop, asyncio.Queue[dict[str, Any]]]
32
+ ] = {}
33
+ self._pending_lock = threading.Lock()
34
+ self._reader_thread: Optional[threading.Thread] = None
35
+ self._stop = threading.Event()
36
+
37
+ def _ensure_reader_thread(self) -> None:
38
+ if self._reader_thread is not None and self._reader_thread.is_alive():
39
+ return
40
+
41
+ self._reader_thread = threading.Thread(
42
+ target=self._read_loop,
43
+ name="tactus-broker-stdio-reader",
44
+ daemon=True,
45
+ )
46
+ self._reader_thread.start()
47
+
48
+ def _read_loop(self) -> None:
49
+ while not self._stop.is_set():
50
+ line = sys.stdin.buffer.readline()
51
+ if not line:
52
+ return
53
+ try:
54
+ event = json.loads(line.decode("utf-8"))
55
+ except json.JSONDecodeError:
56
+ continue
57
+
58
+ req_id = event.get("id")
59
+ if not isinstance(req_id, str):
60
+ continue
61
+
62
+ with self._pending_lock:
63
+ pending = self._pending.get(req_id)
64
+ if pending is None:
65
+ continue
66
+
67
+ loop, queue = pending
68
+ try:
69
+ loop.call_soon_threadsafe(queue.put_nowait, event)
70
+ except RuntimeError:
71
+ # Loop is closed or unavailable; ignore.
72
+ continue
73
+
74
+ async def aclose(self) -> None:
75
+ self._stop.set()
76
+ thread = self._reader_thread
77
+ if thread is None or not thread.is_alive():
78
+ return
79
+ try:
80
+ await asyncio.to_thread(thread.join, 0.5)
81
+ except Exception:
82
+ return
83
+
84
+ async def request(
85
+ self, req_id: str, method: str, params: dict[str, Any]
86
+ ) -> AsyncIterator[dict[str, Any]]:
87
+ self._ensure_reader_thread()
88
+ loop = asyncio.get_running_loop()
89
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
90
+ with self._pending_lock:
91
+ self._pending[req_id] = (loop, queue)
92
+
93
+ try:
94
+ payload = _json_dumps({"id": req_id, "method": method, "params": params})
95
+ with self._write_lock:
96
+ sys.stderr.write(f"{STDIO_REQUEST_PREFIX}{payload}\n")
97
+ sys.stderr.flush()
98
+
99
+ while True:
100
+ event = await queue.get()
101
+ yield event
102
+ if event.get("event") in ("done", "error"):
103
+ return
104
+ finally:
105
+ with self._pending_lock:
106
+ self._pending.pop(req_id, None)
107
+
108
+
109
+ _STDIO_TRANSPORT = _StdioBrokerTransport()
110
+
111
+
112
+ async def close_stdio_transport() -> None:
113
+ await _STDIO_TRANSPORT.aclose()
114
+
115
+
116
+ class BrokerClient:
117
+ def __init__(self, socket_path: str | Path):
118
+ self.socket_path = str(socket_path)
119
+
120
+ @classmethod
121
+ def from_environment(cls) -> Optional["BrokerClient"]:
122
+ socket_path = os.environ.get("TACTUS_BROKER_SOCKET")
123
+ if not socket_path:
124
+ return None
125
+ return cls(socket_path)
126
+
127
+ async def _request(self, method: str, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
128
+ req_id = uuid.uuid4().hex
129
+
130
+ if self.socket_path == STDIO_TRANSPORT_VALUE:
131
+ async for event in _STDIO_TRANSPORT.request(req_id, method, params):
132
+ # Responses are already correlated by req_id; add a defensive filter anyway.
133
+ if event.get("id") == req_id:
134
+ yield event
135
+ return
136
+
137
+ if self.socket_path.startswith(("tcp://", "tls://")):
138
+ use_tls = self.socket_path.startswith("tls://")
139
+ host_port = self.socket_path.split("://", 1)[1]
140
+ if "/" in host_port:
141
+ host_port = host_port.split("/", 1)[0]
142
+ if ":" not in host_port:
143
+ raise ValueError(
144
+ f"Invalid broker endpoint: {self.socket_path}. Expected tcp://host:port or tls://host:port"
145
+ )
146
+ host, port_str = host_port.rsplit(":", 1)
147
+ try:
148
+ port = int(port_str)
149
+ except ValueError as e:
150
+ raise ValueError(f"Invalid broker port in endpoint: {self.socket_path}") from e
151
+
152
+ ssl_ctx: ssl.SSLContext | None = None
153
+ if use_tls:
154
+ ssl_ctx = ssl.create_default_context()
155
+ cafile = os.environ.get("TACTUS_BROKER_TLS_CA_FILE")
156
+ if cafile:
157
+ ssl_ctx.load_verify_locations(cafile=cafile)
158
+
159
+ if os.environ.get("TACTUS_BROKER_TLS_INSECURE") in ("1", "true", "yes"):
160
+ ssl_ctx.check_hostname = False
161
+ ssl_ctx.verify_mode = ssl.CERT_NONE
162
+
163
+ reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx)
164
+ writer.write(
165
+ (_json_dumps({"id": req_id, "method": method, "params": params}) + "\n").encode(
166
+ "utf-8"
167
+ )
168
+ )
169
+ await writer.drain()
170
+
171
+ try:
172
+ while True:
173
+ line = await reader.readline()
174
+ if not line:
175
+ return
176
+ event = json.loads(line.decode("utf-8"))
177
+ if event.get("id") != req_id:
178
+ continue
179
+ yield event
180
+ if event.get("event") in ("done", "error"):
181
+ return
182
+ finally:
183
+ try:
184
+ writer.close()
185
+ await writer.wait_closed()
186
+ except Exception:
187
+ pass
188
+
189
+ reader, writer = await asyncio.open_unix_connection(self.socket_path)
190
+ writer.write(
191
+ (_json_dumps({"id": req_id, "method": method, "params": params}) + "\n").encode("utf-8")
192
+ )
193
+ await writer.drain()
194
+
195
+ try:
196
+ while True:
197
+ line = await reader.readline()
198
+ if not line:
199
+ return
200
+ event = json.loads(line.decode("utf-8"))
201
+ # Ignore unrelated messages (defensive; current server is 1-req/conn).
202
+ if event.get("id") != req_id:
203
+ continue
204
+ yield event
205
+ if event.get("event") in ("done", "error"):
206
+ return
207
+ finally:
208
+ try:
209
+ writer.close()
210
+ await writer.wait_closed()
211
+ except Exception:
212
+ pass
213
+
214
+ def llm_chat(
215
+ self,
216
+ *,
217
+ provider: str,
218
+ model: str,
219
+ messages: list[dict[str, Any]],
220
+ temperature: Optional[float] = None,
221
+ max_tokens: Optional[int] = None,
222
+ stream: bool,
223
+ ) -> AsyncIterator[dict[str, Any]]:
224
+ params: dict[str, Any] = {
225
+ "provider": provider,
226
+ "model": model,
227
+ "messages": messages,
228
+ "stream": stream,
229
+ }
230
+ if temperature is not None:
231
+ params["temperature"] = temperature
232
+ if max_tokens is not None:
233
+ params["max_tokens"] = max_tokens
234
+ return self._request("llm.chat", params)
235
+
236
+ async def call_tool(self, *, name: str, args: dict[str, Any]) -> Any:
237
+ """
238
+ Call an allowlisted host tool via the broker.
239
+
240
+ Returns the decoded `result` payload from the broker.
241
+ """
242
+ if not isinstance(name, str) or not name:
243
+ raise ValueError("tool name must be a non-empty string")
244
+ if not isinstance(args, dict):
245
+ raise ValueError("tool args must be an object")
246
+
247
+ async for event in self._request("tool.call", {"name": name, "args": args}):
248
+ event_type = event.get("event")
249
+ if event_type == "done":
250
+ data = event.get("data") or {}
251
+ return data.get("result")
252
+ if event_type == "error":
253
+ err = event.get("error") or {}
254
+ raise RuntimeError(err.get("message") or "Broker tool error")
255
+
256
+ raise RuntimeError("Broker tool call ended without a response")
257
+
258
+ async def emit_event(self, event: dict[str, Any]) -> None:
259
+ async for _ in self._request("events.emit", {"event": event}):
260
+ pass