tactus 0.27.0__py3-none-any.whl → 0.29.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 +25 -4
- tactus/broker/client.py +5 -18
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +81 -69
- tactus/cli/app.py +2 -2
- tactus/core/dsl_stubs.py +236 -21
- tactus/core/runtime.py +48 -24
- tactus/docker/entrypoint.sh +3 -2
- tactus/dspy/agent.py +54 -36
- tactus/ide/server.py +2 -1
- tactus/primitives/tool_handle.py +3 -1
- tactus/sandbox/config.py +29 -7
- tactus/sandbox/container_runner.py +83 -6
- tactus/stdlib/tac/tactus/tools/done.tac +3 -3
- tactus/stdlib/tac/tactus/tools/log.tac +1 -0
- tactus/testing/context.py +0 -1
- tactus/validation/semantic_visitor.py +32 -22
- {tactus-0.27.0.dist-info → tactus-0.29.0.dist-info}/METADATA +118 -15
- {tactus-0.27.0.dist-info → tactus-0.29.0.dist-info}/RECORD +23 -22
- {tactus-0.27.0.dist-info → tactus-0.29.0.dist-info}/WHEEL +0 -0
- {tactus-0.27.0.dist-info → tactus-0.29.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.27.0.dist-info → tactus-0.29.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/adapters/broker_log.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
await
|
|
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.
|
|
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.
|
|
128
|
-
self.
|
|
129
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
188
|
+
await self._handle_tool_call(req_id, params, byte_stream)
|
|
173
189
|
return
|
|
174
190
|
|
|
175
|
-
await
|
|
176
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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],
|
|
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
|
|
210
|
-
|
|
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
|
|
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],
|
|
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
|
|
233
|
-
|
|
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
|
|
253
|
-
|
|
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
|
|
263
|
-
|
|
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
|
|
295
|
-
|
|
309
|
+
await _write_event_anyio(
|
|
310
|
+
byte_stream, {"id": req_id, "event": "delta", "data": {"text": text}}
|
|
296
311
|
)
|
|
297
312
|
|
|
298
|
-
await
|
|
299
|
-
|
|
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
|
|
329
|
-
|
|
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
|
|
342
|
-
|
|
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],
|
|
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
|
|
358
|
-
|
|
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
|
|
368
|
-
|
|
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
|
|
381
|
-
|
|
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
|
|
395
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
"
|
|
459
|
+
"tcp",
|
|
460
460
|
"--sandbox-broker",
|
|
461
|
-
help="Broker transport for sandbox runtime:
|
|
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,
|