tactus 0.26.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.
- tactus/__init__.py +1 -1
- tactus/adapters/broker_log.py +55 -0
- tactus/adapters/cli_log.py +0 -25
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +260 -0
- tactus/broker/server.py +505 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/app.py +38 -2
- tactus/core/dsl_stubs.py +2 -1
- tactus/core/output_validator.py +6 -3
- tactus/core/registry.py +8 -1
- tactus/core/runtime.py +15 -1
- tactus/core/yaml_parser.py +1 -11
- tactus/dspy/agent.py +190 -102
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +21 -8
- tactus/dspy/prediction.py +71 -5
- tactus/ide/server.py +37 -142
- tactus/primitives/__init__.py +2 -0
- tactus/primitives/handles.py +34 -7
- tactus/primitives/host.py +94 -0
- tactus/primitives/log.py +4 -0
- tactus/primitives/model.py +20 -2
- tactus/primitives/procedure.py +106 -51
- tactus/primitives/tool.py +0 -2
- tactus/protocols/__init__.py +0 -7
- tactus/protocols/log_handler.py +2 -2
- tactus/protocols/models.py +1 -1
- tactus/sandbox/config.py +33 -5
- tactus/sandbox/container_runner.py +498 -60
- tactus/sandbox/entrypoint.py +30 -17
- tactus/sandbox/protocol.py +0 -9
- tactus/testing/README.md +0 -4
- tactus/testing/mock_agent.py +80 -23
- tactus/testing/test_runner.py +0 -18
- {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/METADATA +1 -1
- {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/RECORD +40 -33
- {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/WHEEL +0 -0
- {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
|
@@ -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
|
tactus/adapters/cli_log.py
CHANGED
|
@@ -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,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"]
|
tactus/broker/client.py
ADDED
|
@@ -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
|