tactus 0.27.0__py3-none-any.whl → 0.28.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 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.27.0"
8
+ __version__ = "0.28.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
@@ -7,10 +7,10 @@ host-side broker without requiring container networking.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
11
+ import threading
10
12
  from typing import Optional
11
13
 
12
- from asyncer import syncify
13
-
14
14
  from tactus.broker.client import BrokerClient
15
15
  from tactus.protocols.models import LogEvent, CostEvent
16
16
 
@@ -25,6 +25,19 @@ class BrokerLogHandler:
25
25
  def __init__(self, client: BrokerClient):
26
26
  self._client = client
27
27
  self.cost_events: list[CostEvent] = []
28
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
29
+ self._loop_lock = threading.Lock()
30
+
31
+ def _get_or_create_loop(self) -> asyncio.AbstractEventLoop:
32
+ """Get the event loop, creating one if needed for cross-thread calls."""
33
+ with self._loop_lock:
34
+ if self._loop is None or self._loop.is_closed():
35
+ try:
36
+ self._loop = asyncio.get_running_loop()
37
+ except RuntimeError:
38
+ # No running loop - create a new one
39
+ self._loop = asyncio.new_event_loop()
40
+ return self._loop
28
41
 
29
42
  @classmethod
30
43
  def from_environment(cls) -> Optional["BrokerLogHandler"]:
@@ -49,7 +62,15 @@ class BrokerLogHandler:
49
62
 
50
63
  # Best-effort forwarding; never crash the procedure due to streaming.
51
64
  try:
52
- syncify(self._client.emit_event)(event_dict)
65
+ # Try to get the running loop first
66
+ try:
67
+ loop = asyncio.get_running_loop()
68
+ # We're in an async context - schedule and don't wait
69
+ loop.create_task(self._client.emit_event(event_dict))
70
+ except RuntimeError:
71
+ # No running loop - we're being called from a sync thread.
72
+ # Use asyncio.run() which creates a new event loop for this call.
73
+ asyncio.run(self._client.emit_event(event_dict))
53
74
  except Exception:
54
75
  # Swallow errors; container remains networkless and secretless even if streaming fails.
55
- return
76
+ pass
tactus/broker/client.py CHANGED
@@ -17,6 +17,7 @@ import uuid
17
17
  from pathlib import Path
18
18
  from typing import Any, AsyncIterator, Optional
19
19
 
20
+ from tactus.broker.protocol import read_message, write_message
20
21
  from tactus.broker.stdio import STDIO_REQUEST_PREFIX, STDIO_TRANSPORT_VALUE
21
22
 
22
23
 
@@ -161,19 +162,11 @@ class BrokerClient:
161
162
  ssl_ctx.verify_mode = ssl.CERT_NONE
162
163
 
163
164
  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()
165
+ await write_message(writer, {"id": req_id, "method": method, "params": params})
170
166
 
171
167
  try:
172
168
  while True:
173
- line = await reader.readline()
174
- if not line:
175
- return
176
- event = json.loads(line.decode("utf-8"))
169
+ event = await read_message(reader)
177
170
  if event.get("id") != req_id:
178
171
  continue
179
172
  yield event
@@ -187,17 +180,11 @@ class BrokerClient:
187
180
  pass
188
181
 
189
182
  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()
183
+ await write_message(writer, {"id": req_id, "method": method, "params": params})
194
184
 
195
185
  try:
196
186
  while True:
197
- line = await reader.readline()
198
- if not line:
199
- return
200
- event = json.loads(line.decode("utf-8"))
187
+ event = await read_message(reader)
201
188
  # Ignore unrelated messages (defensive; current server is 1-req/conn).
202
189
  if event.get("id") != req_id:
203
190
  continue
@@ -0,0 +1,183 @@
1
+ """
2
+ Length-prefixed JSON protocol for broker communication.
3
+
4
+ This module provides utilities for sending and receiving JSON messages
5
+ with a length prefix, avoiding the buffer size limitations of newline-delimited JSON.
6
+
7
+ Wire format:
8
+ <10-digit-decimal-length>\n<json-payload>
9
+
10
+ Example:
11
+ 0000000123
12
+ {"id":"abc","method":"llm.chat","params":{...}}
13
+ """
14
+
15
+ import json
16
+ import asyncio
17
+ import logging
18
+ from typing import Any, Dict, AsyncIterator
19
+
20
+ import anyio
21
+ from anyio.streams.buffered import BufferedByteReceiveStream
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Length prefix is exactly 10 decimal digits + newline
26
+ LENGTH_PREFIX_SIZE = 11 # "0000000123\n"
27
+ MAX_MESSAGE_SIZE = 100 * 1024 * 1024 # 100MB safety limit
28
+
29
+
30
+ async def write_message(writer: asyncio.StreamWriter, message: Dict[str, Any]) -> None:
31
+ """
32
+ Write a JSON message with length prefix.
33
+
34
+ Args:
35
+ writer: asyncio StreamWriter
36
+ message: Dictionary to encode as JSON
37
+
38
+ Raises:
39
+ ValueError: If message is too large
40
+ """
41
+ json_bytes = json.dumps(message).encode("utf-8")
42
+ length = len(json_bytes)
43
+
44
+ if length > MAX_MESSAGE_SIZE:
45
+ raise ValueError(f"Message size {length} exceeds maximum {MAX_MESSAGE_SIZE}")
46
+
47
+ # Write 10-digit length prefix + newline
48
+ length_prefix = f"{length:010d}\n".encode("ascii")
49
+ writer.write(length_prefix)
50
+ writer.write(json_bytes)
51
+ await writer.drain()
52
+
53
+
54
+ async def read_message(reader: asyncio.StreamReader) -> Dict[str, Any]:
55
+ """
56
+ Read a JSON message with length prefix.
57
+
58
+ Args:
59
+ reader: asyncio StreamReader
60
+
61
+ Returns:
62
+ Parsed JSON message as dictionary
63
+
64
+ Raises:
65
+ EOFError: If connection closed
66
+ ValueError: If message is invalid or too large
67
+ """
68
+ # Read exactly 11 bytes for length prefix
69
+ length_bytes = await reader.readexactly(LENGTH_PREFIX_SIZE)
70
+
71
+ if not length_bytes:
72
+ raise EOFError("Connection closed")
73
+
74
+ try:
75
+ length_str = length_bytes[:10].decode("ascii")
76
+ length = int(length_str)
77
+ except (ValueError, UnicodeDecodeError) as e:
78
+ raise ValueError(f"Invalid length prefix: {length_bytes!r}") from e
79
+
80
+ if length > MAX_MESSAGE_SIZE:
81
+ raise ValueError(f"Message size {length} exceeds maximum {MAX_MESSAGE_SIZE}")
82
+
83
+ if length == 0:
84
+ raise ValueError("Zero-length message not allowed")
85
+
86
+ # Read exactly that many bytes for the JSON payload
87
+ json_bytes = await reader.readexactly(length)
88
+
89
+ try:
90
+ message = json.loads(json_bytes.decode("utf-8"))
91
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
92
+ raise ValueError("Invalid JSON payload") from e
93
+
94
+ return message
95
+
96
+
97
+ async def read_messages(reader: asyncio.StreamReader) -> AsyncIterator[Dict[str, Any]]:
98
+ """
99
+ Read a stream of length-prefixed JSON messages.
100
+
101
+ Args:
102
+ reader: asyncio StreamReader
103
+
104
+ Yields:
105
+ Parsed JSON messages as dictionaries
106
+
107
+ Stops when connection is closed or error occurs.
108
+ """
109
+ try:
110
+ while True:
111
+ message = await read_message(reader)
112
+ yield message
113
+ except EOFError:
114
+ return
115
+ except asyncio.IncompleteReadError:
116
+ return
117
+
118
+
119
+ # AnyIO-compatible versions for broker server
120
+ async def write_message_anyio(stream: anyio.abc.ByteStream, message: Dict[str, Any]) -> None:
121
+ """
122
+ Write a JSON message with length prefix using AnyIO streams.
123
+
124
+ Args:
125
+ stream: anyio ByteStream
126
+ message: Dictionary to encode as JSON
127
+
128
+ Raises:
129
+ ValueError: If message is too large
130
+ """
131
+ json_bytes = json.dumps(message).encode("utf-8")
132
+ length = len(json_bytes)
133
+
134
+ if length > MAX_MESSAGE_SIZE:
135
+ raise ValueError(f"Message size {length} exceeds maximum {MAX_MESSAGE_SIZE}")
136
+
137
+ # Write 10-digit length prefix + newline
138
+ length_prefix = f"{length:010d}\n".encode("ascii")
139
+ await stream.send(length_prefix)
140
+ await stream.send(json_bytes)
141
+
142
+
143
+ async def read_message_anyio(stream: BufferedByteReceiveStream) -> Dict[str, Any]:
144
+ """
145
+ Read a JSON message with length prefix using AnyIO streams.
146
+
147
+ Args:
148
+ stream: anyio BufferedByteReceiveStream
149
+
150
+ Returns:
151
+ Parsed JSON message as dictionary
152
+
153
+ Raises:
154
+ EOFError: If connection closed
155
+ ValueError: If message is invalid or too large
156
+ """
157
+ # Read exactly 11 bytes for length prefix
158
+ length_bytes = await stream.receive_exactly(LENGTH_PREFIX_SIZE)
159
+
160
+ if not length_bytes:
161
+ raise EOFError("Connection closed")
162
+
163
+ try:
164
+ length_str = length_bytes[:10].decode("ascii")
165
+ length = int(length_str)
166
+ except (ValueError, UnicodeDecodeError) as e:
167
+ raise ValueError(f"Invalid length prefix: {length_bytes!r}") from e
168
+
169
+ if length > MAX_MESSAGE_SIZE:
170
+ raise ValueError(f"Message size {length} exceeds maximum {MAX_MESSAGE_SIZE}")
171
+
172
+ if length == 0:
173
+ raise ValueError("Zero-length message not allowed")
174
+
175
+ # Read exactly that many bytes for the JSON payload
176
+ json_bytes = await stream.receive_exactly(length)
177
+
178
+ try:
179
+ message = json.loads(json_bytes.decode("utf-8"))
180
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
181
+ raise ValueError("Invalid JSON payload") from e
182
+
183
+ return message
tactus/broker/server.py CHANGED
@@ -15,6 +15,12 @@ from dataclasses import dataclass
15
15
  from pathlib import Path
16
16
  from typing import Any, Optional
17
17
 
18
+ import anyio
19
+ from anyio.streams.buffered import BufferedByteReceiveStream
20
+ from anyio.streams.tls import TLSStream
21
+
22
+ from tactus.broker.protocol import read_message_anyio, write_message_anyio
23
+
18
24
  logger = logging.getLogger(__name__)
19
25
 
20
26
 
@@ -22,9 +28,9 @@ def _json_dumps(obj: Any) -> str:
22
28
  return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
23
29
 
24
30
 
25
- async def _write_event(writer: asyncio.StreamWriter, event: dict[str, Any]) -> None:
26
- writer.write((_json_dumps(event) + "\n").encode("utf-8"))
27
- await writer.drain()
31
+ async def _write_event_anyio(stream: anyio.abc.ByteStream, event: dict[str, Any]) -> None:
32
+ """Write an event using length-prefixed protocol."""
33
+ await write_message_anyio(stream, event)
28
34
 
29
35
 
30
36
  @dataclass(frozen=True)
@@ -115,7 +121,7 @@ class _BaseBrokerServer:
115
121
  tool_registry: Optional[HostToolRegistry] = None,
116
122
  event_handler: Optional[Callable[[dict[str, Any]], None]] = None,
117
123
  ):
118
- self._server: Optional[asyncio.AbstractServer] = None
124
+ self._listener = None
119
125
  self._openai = openai_backend or OpenAIChatBackend()
120
126
  self._tools = tool_registry or HostToolRegistry.default()
121
127
  self._event_handler = event_handler
@@ -123,11 +129,16 @@ class _BaseBrokerServer:
123
129
  async def start(self) -> None:
124
130
  raise NotImplementedError
125
131
 
132
+ async def serve(self) -> None:
133
+ """Serve connections (blocks until listener is closed)."""
134
+ if self._listener is None:
135
+ raise RuntimeError("Server not started - call start() first")
136
+ await self._listener.serve(self._handle_connection)
137
+
126
138
  async def aclose(self) -> None:
127
- if self._server is not None:
128
- self._server.close()
129
- await self._server.wait_closed()
130
- self._server = None
139
+ if self._listener is not None:
140
+ await self._listener.aclose()
141
+ self._listener = None
131
142
 
132
143
  async def __aenter__(self) -> "_BaseBrokerServer":
133
144
  await self.start()
@@ -136,22 +147,27 @@ class _BaseBrokerServer:
136
147
  async def __aexit__(self, exc_type, exc, tb) -> None:
137
148
  await self.aclose()
138
149
 
139
- async def _handle_connection(
140
- self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
141
- ) -> None:
142
- try:
143
- line = await reader.readline()
144
- if not line:
145
- return
150
+ async def _handle_connection(self, byte_stream: anyio.abc.ByteStream) -> None:
151
+ # For TLS connections, wrap the stream with TLS
152
+ # Note: TcpBrokerServer subclass can override self.ssl_context
153
+ if hasattr(self, "ssl_context") and self.ssl_context is not None:
154
+ byte_stream = await TLSStream.wrap(
155
+ byte_stream, ssl_context=self.ssl_context, server_side=True
156
+ )
157
+
158
+ # Wrap the stream for buffered reading
159
+ buffered_stream = BufferedByteReceiveStream(byte_stream)
146
160
 
147
- req = json.loads(line.decode("utf-8"))
161
+ try:
162
+ # Use length-prefixed protocol to handle arbitrarily large messages
163
+ req = await read_message_anyio(buffered_stream)
148
164
  req_id = req.get("id")
149
165
  method = req.get("method")
150
166
  params = req.get("params") or {}
151
167
 
152
168
  if not req_id or not method:
153
- await _write_event(
154
- writer,
169
+ await _write_event_anyio(
170
+ byte_stream,
155
171
  {
156
172
  "id": req_id or "",
157
173
  "event": "error",
@@ -161,19 +177,19 @@ class _BaseBrokerServer:
161
177
  return
162
178
 
163
179
  if method == "events.emit":
164
- await self._handle_events_emit(req_id, params, writer)
180
+ await self._handle_events_emit(req_id, params, byte_stream)
165
181
  return
166
182
 
167
183
  if method == "llm.chat":
168
- await self._handle_llm_chat(req_id, params, writer)
184
+ await self._handle_llm_chat(req_id, params, byte_stream)
169
185
  return
170
186
 
171
187
  if method == "tool.call":
172
- await self._handle_tool_call(req_id, params, writer)
188
+ await self._handle_tool_call(req_id, params, byte_stream)
173
189
  return
174
190
 
175
- await _write_event(
176
- writer,
191
+ await _write_event_anyio(
192
+ byte_stream,
177
193
  {
178
194
  "id": req_id,
179
195
  "event": "error",
@@ -184,8 +200,8 @@ class _BaseBrokerServer:
184
200
  except Exception as e:
185
201
  logger.debug("[BROKER] Connection handler error", exc_info=True)
186
202
  try:
187
- await _write_event(
188
- writer,
203
+ await _write_event_anyio(
204
+ byte_stream,
189
205
  {
190
206
  "id": "",
191
207
  "event": "error",
@@ -196,18 +212,17 @@ class _BaseBrokerServer:
196
212
  pass
197
213
  finally:
198
214
  try:
199
- writer.close()
200
- await writer.wait_closed()
215
+ await byte_stream.aclose()
201
216
  except Exception:
202
217
  pass
203
218
 
204
219
  async def _handle_events_emit(
205
- self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
220
+ self, req_id: str, params: dict[str, Any], byte_stream: anyio.abc.ByteStream
206
221
  ) -> None:
207
222
  event = params.get("event")
208
223
  if not isinstance(event, dict):
209
- await _write_event(
210
- writer,
224
+ await _write_event_anyio(
225
+ byte_stream,
211
226
  {
212
227
  "id": req_id,
213
228
  "event": "error",
@@ -222,15 +237,15 @@ class _BaseBrokerServer:
222
237
  except Exception:
223
238
  logger.debug("[BROKER] event_handler raised", exc_info=True)
224
239
 
225
- await _write_event(writer, {"id": req_id, "event": "done", "data": {"ok": True}})
240
+ await _write_event_anyio(byte_stream, {"id": req_id, "event": "done", "data": {"ok": True}})
226
241
 
227
242
  async def _handle_llm_chat(
228
- self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
243
+ self, req_id: str, params: dict[str, Any], byte_stream: anyio.abc.ByteStream
229
244
  ) -> None:
230
245
  provider = params.get("provider") or "openai"
231
246
  if provider != "openai":
232
- await _write_event(
233
- writer,
247
+ await _write_event_anyio(
248
+ byte_stream,
234
249
  {
235
250
  "id": req_id,
236
251
  "event": "error",
@@ -249,8 +264,8 @@ class _BaseBrokerServer:
249
264
  max_tokens = params.get("max_tokens")
250
265
 
251
266
  if not isinstance(model, str) or not model:
252
- await _write_event(
253
- writer,
267
+ await _write_event_anyio(
268
+ byte_stream,
254
269
  {
255
270
  "id": req_id,
256
271
  "event": "error",
@@ -259,8 +274,8 @@ class _BaseBrokerServer:
259
274
  )
260
275
  return
261
276
  if not isinstance(messages, list):
262
- await _write_event(
263
- writer,
277
+ await _write_event_anyio(
278
+ byte_stream,
264
279
  {
265
280
  "id": req_id,
266
281
  "event": "error",
@@ -291,12 +306,12 @@ class _BaseBrokerServer:
291
306
  continue
292
307
 
293
308
  full_text += text
294
- await _write_event(
295
- writer, {"id": req_id, "event": "delta", "data": {"text": text}}
309
+ await _write_event_anyio(
310
+ byte_stream, {"id": req_id, "event": "delta", "data": {"text": text}}
296
311
  )
297
312
 
298
- await _write_event(
299
- writer,
313
+ await _write_event_anyio(
314
+ byte_stream,
300
315
  {
301
316
  "id": req_id,
302
317
  "event": "done",
@@ -325,8 +340,8 @@ class _BaseBrokerServer:
325
340
  except Exception:
326
341
  text = ""
327
342
 
328
- await _write_event(
329
- writer,
343
+ await _write_event_anyio(
344
+ byte_stream,
330
345
  {
331
346
  "id": req_id,
332
347
  "event": "done",
@@ -338,8 +353,8 @@ class _BaseBrokerServer:
338
353
  )
339
354
  except Exception as e:
340
355
  logger.debug("[BROKER] llm.chat error", exc_info=True)
341
- await _write_event(
342
- writer,
356
+ await _write_event_anyio(
357
+ byte_stream,
343
358
  {
344
359
  "id": req_id,
345
360
  "event": "error",
@@ -348,14 +363,14 @@ class _BaseBrokerServer:
348
363
  )
349
364
 
350
365
  async def _handle_tool_call(
351
- self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
366
+ self, req_id: str, params: dict[str, Any], byte_stream: anyio.abc.ByteStream
352
367
  ) -> None:
353
368
  name = params.get("name")
354
369
  args = params.get("args") or {}
355
370
 
356
371
  if not isinstance(name, str) or not name:
357
- await _write_event(
358
- writer,
372
+ await _write_event_anyio(
373
+ byte_stream,
359
374
  {
360
375
  "id": req_id,
361
376
  "event": "error",
@@ -364,8 +379,8 @@ class _BaseBrokerServer:
364
379
  )
365
380
  return
366
381
  if not isinstance(args, dict):
367
- await _write_event(
368
- writer,
382
+ await _write_event_anyio(
383
+ byte_stream,
369
384
  {
370
385
  "id": req_id,
371
386
  "event": "error",
@@ -377,8 +392,8 @@ class _BaseBrokerServer:
377
392
  try:
378
393
  result = self._tools.call(name, args)
379
394
  except KeyError:
380
- await _write_event(
381
- writer,
395
+ await _write_event_anyio(
396
+ byte_stream,
382
397
  {
383
398
  "id": req_id,
384
399
  "event": "error",
@@ -391,8 +406,8 @@ class _BaseBrokerServer:
391
406
  return
392
407
  except Exception as e:
393
408
  logger.debug("[BROKER] tool.call error", exc_info=True)
394
- await _write_event(
395
- writer,
409
+ await _write_event_anyio(
410
+ byte_stream,
396
411
  {
397
412
  "id": req_id,
398
413
  "event": "error",
@@ -401,7 +416,9 @@ class _BaseBrokerServer:
401
416
  )
402
417
  return
403
418
 
404
- await _write_event(writer, {"id": req_id, "event": "done", "data": {"result": result}})
419
+ await _write_event_anyio(
420
+ byte_stream, {"id": req_id, "event": "done", "data": {"result": result}}
421
+ )
405
422
 
406
423
 
407
424
  class BrokerServer(_BaseBrokerServer):
@@ -484,20 +501,15 @@ class TcpBrokerServer(_BaseBrokerServer):
484
501
  self.bound_port: int | None = None
485
502
 
486
503
  async def start(self) -> None:
487
- self._server = await asyncio.start_server(
488
- self._handle_connection,
489
- host=self.host,
490
- port=self.port,
491
- ssl=self.ssl_context,
492
- )
504
+ # Create AnyIO TCP listener (doesn't block, just binds to port)
505
+ self._listener = await anyio.create_tcp_listener(local_host=self.host, local_port=self.port)
493
506
 
494
- sockets = self._server.sockets or []
495
- if sockets:
496
- try:
497
- sockname = sockets[0].getsockname()
498
- self.bound_port = int(sockname[1])
499
- except Exception:
500
- self.bound_port = None
507
+ # Get the bound port
508
+ try:
509
+ sockname = self._listener.extra(anyio.abc.SocketAttribute.raw_socket).getsockname()
510
+ self.bound_port = int(sockname[1])
511
+ except Exception:
512
+ self.bound_port = None
501
513
 
502
514
  scheme = "tls" if self.ssl_context is not None else "tcp"
503
515
  logger.info(
tactus/cli/app.py CHANGED
@@ -456,9 +456,9 @@ def run(
456
456
  "Use --no-sandbox to run without isolation (security risk).",
457
457
  ),
458
458
  sandbox_broker: str = typer.Option(
459
- "stdio",
459
+ "tcp",
460
460
  "--sandbox-broker",
461
- help="Broker transport for sandbox runtime: stdio (default, --network none) or tcp/tls (remote-mode spike).",
461
+ help="Broker transport for sandbox runtime: tcp (default), tls, or stdio (deprecated due to buffering issues).",
462
462
  ),
463
463
  sandbox_network: Optional[str] = typer.Option(
464
464
  None,