tactus 0.30.0__py3-none-any.whl → 0.31.1__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/lua_tools.py +23 -1
- tactus/adapters/mcp_manager.py +62 -35
- tactus/broker/server.py +314 -0
- tactus/cli/app.py +11 -1
- tactus/core/dsl_stubs.py +138 -41
- tactus/core/output_validator.py +69 -15
- tactus/core/registry.py +13 -25
- tactus/core/runtime.py +208 -69
- tactus/dspy/agent.py +87 -30
- tactus/ide/server.py +0 -10
- tactus/primitives/__init__.py +0 -2
- tactus/primitives/handles.py +8 -3
- tactus/primitives/procedure_callable.py +36 -0
- tactus/protocols/config.py +0 -5
- tactus/protocols/result.py +3 -3
- tactus/stdlib/tac/tactus/tools/done.tac +1 -1
- tactus/stdlib/tac/tactus/tools/log.tac +1 -1
- tactus/testing/README.md +1 -12
- tactus/testing/behave_integration.py +12 -2
- tactus/testing/context.py +156 -46
- tactus/testing/mock_agent.py +43 -8
- tactus/testing/steps/builtin.py +264 -54
- tactus/testing/test_runner.py +6 -0
- tactus/validation/semantic_visitor.py +19 -11
- {tactus-0.30.0.dist-info → tactus-0.31.1.dist-info}/METADATA +9 -11
- {tactus-0.30.0.dist-info → tactus-0.31.1.dist-info}/RECORD +30 -31
- tactus/primitives/stage.py +0 -202
- {tactus-0.30.0.dist-info → tactus-0.31.1.dist-info}/WHEEL +0 -0
- {tactus-0.30.0.dist-info → tactus-0.31.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.30.0.dist-info → tactus-0.31.1.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/adapters/lua_tools.py
CHANGED
|
@@ -156,6 +156,15 @@ class LuaToolsAdapter:
|
|
|
156
156
|
Async Python function suitable for FunctionToolset
|
|
157
157
|
"""
|
|
158
158
|
lua_handler = tool_spec.get("handler")
|
|
159
|
+
if lua_handler is None:
|
|
160
|
+
# Tool/Toolset DSL blocks can specify the handler as an unnamed function value.
|
|
161
|
+
# `lua_table_to_dict()` preserves that as numeric key `1` for mixed tables.
|
|
162
|
+
try:
|
|
163
|
+
candidate = tool_spec.get(1)
|
|
164
|
+
except Exception:
|
|
165
|
+
candidate = None
|
|
166
|
+
if candidate is not None and callable(candidate):
|
|
167
|
+
lua_handler = candidate
|
|
159
168
|
description = tool_spec.get("description", f"Tool: {tool_name}")
|
|
160
169
|
# Only support 'input' field name (new DSL syntax only)
|
|
161
170
|
input_schema = tool_spec.get("input", {})
|
|
@@ -226,7 +235,20 @@ class LuaToolsAdapter:
|
|
|
226
235
|
# Build proper signature for Pydantic AI tool discovery
|
|
227
236
|
sig_params = []
|
|
228
237
|
logger.debug(f"Building signature for tool '{tool_name}' with schema: {input_schema}")
|
|
229
|
-
|
|
238
|
+
|
|
239
|
+
# Lua table iteration order is undefined, so ensure signature is always valid:
|
|
240
|
+
# required params (no defaults) must come before optional params (with defaults).
|
|
241
|
+
required_param_names: list[str] = []
|
|
242
|
+
optional_param_names: list[str] = []
|
|
243
|
+
for param_name in sorted(input_schema.keys()):
|
|
244
|
+
param_spec = input_schema.get(param_name, {}) or {}
|
|
245
|
+
if param_spec.get("required", True):
|
|
246
|
+
required_param_names.append(param_name)
|
|
247
|
+
else:
|
|
248
|
+
optional_param_names.append(param_name)
|
|
249
|
+
|
|
250
|
+
for param_name in required_param_names + optional_param_names:
|
|
251
|
+
param_spec = input_schema.get(param_name, {}) or {}
|
|
230
252
|
param_type = self._map_lua_type(param_spec.get("type", "string"))
|
|
231
253
|
required = param_spec.get("required", True)
|
|
232
254
|
|
tactus/adapters/mcp_manager.py
CHANGED
|
@@ -8,6 +8,7 @@ Handles lifecycle, tool prefixing, and tool call tracking.
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import asyncio
|
|
11
12
|
from contextlib import AsyncExitStack
|
|
12
13
|
from typing import Dict, Any, List
|
|
13
14
|
|
|
@@ -62,43 +63,69 @@ class MCPServerManager:
|
|
|
62
63
|
async def __aenter__(self):
|
|
63
64
|
"""Connect to all configured MCP servers."""
|
|
64
65
|
for name, config in self.configs.items():
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
# Connect the prefixed server
|
|
83
|
-
await self._exit_stack.enter_async_context(prefixed_server)
|
|
84
|
-
self.servers.append(prefixed_server)
|
|
85
|
-
self.server_toolsets[name] = prefixed_server # Store by name for lookup
|
|
86
|
-
logger.info(f"Successfully connected to MCP server '{name}' with prefix '{name}_'")
|
|
87
|
-
except Exception as e:
|
|
88
|
-
# Check if this is a fileno error (common in test environments)
|
|
89
|
-
import io
|
|
90
|
-
|
|
91
|
-
error_str = str(e)
|
|
92
|
-
if "fileno" in error_str or isinstance(e, io.UnsupportedOperation):
|
|
93
|
-
logger.warning(
|
|
94
|
-
f"Failed to connect to MCP server '{name}': {e} "
|
|
95
|
-
f"(test environment with redirected streams)"
|
|
66
|
+
# Retry a few times for transient stdio startup issues.
|
|
67
|
+
last_error: Exception | None = None
|
|
68
|
+
for attempt in range(1, 4):
|
|
69
|
+
try:
|
|
70
|
+
logger.info(f"Connecting to MCP server '{name}' (attempt {attempt}/3)...")
|
|
71
|
+
|
|
72
|
+
# Substitute environment variables in config
|
|
73
|
+
config = substitute_env_vars(config)
|
|
74
|
+
|
|
75
|
+
# Create base server
|
|
76
|
+
server = MCPServerStdio(
|
|
77
|
+
command=config["command"],
|
|
78
|
+
args=config.get("args", []),
|
|
79
|
+
env=config.get("env"),
|
|
80
|
+
cwd=config.get("cwd"),
|
|
81
|
+
process_tool_call=self._create_trace_callback(name), # Tracking hook
|
|
96
82
|
)
|
|
97
|
-
|
|
83
|
+
|
|
84
|
+
# Wrap with prefix to namespace tools
|
|
85
|
+
prefixed_server = server.prefixed(name)
|
|
86
|
+
|
|
87
|
+
# Connect the prefixed server
|
|
88
|
+
await self._exit_stack.enter_async_context(prefixed_server)
|
|
89
|
+
self.servers.append(prefixed_server)
|
|
90
|
+
self.server_toolsets[name] = prefixed_server # Store by name for lookup
|
|
91
|
+
logger.info(
|
|
92
|
+
f"Successfully connected to MCP server '{name}' with prefix '{name}_'"
|
|
93
|
+
)
|
|
94
|
+
last_error = None
|
|
95
|
+
break
|
|
96
|
+
except Exception as e:
|
|
97
|
+
last_error = e
|
|
98
|
+
|
|
99
|
+
# Check if this is a fileno error (common in test environments)
|
|
100
|
+
import io
|
|
101
|
+
|
|
102
|
+
error_str = str(e)
|
|
103
|
+
if "fileno" in error_str or isinstance(e, io.UnsupportedOperation):
|
|
104
|
+
logger.warning(
|
|
105
|
+
f"Failed to connect to MCP server '{name}': {e} "
|
|
106
|
+
f"(test environment with redirected streams)"
|
|
107
|
+
)
|
|
108
|
+
# Allow procedures to continue without MCP in this environment.
|
|
109
|
+
last_error = None
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
# Retry transient anyio TaskGroup/broken stream issues.
|
|
113
|
+
if (
|
|
114
|
+
"BrokenResourceError" in error_str
|
|
115
|
+
or "unhandled errors in a TaskGroup" in error_str
|
|
116
|
+
):
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"Transient MCP connection failure for '{name}': {e} (retrying)"
|
|
119
|
+
)
|
|
120
|
+
await asyncio.sleep(0.05 * attempt)
|
|
121
|
+
continue
|
|
122
|
+
|
|
98
123
|
logger.error(f"Failed to connect to MCP server '{name}': {e}", exc_info=True)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
if last_error is not None:
|
|
127
|
+
# For non-transient failures, raise so callers can decide whether to ignore.
|
|
128
|
+
raise last_error
|
|
102
129
|
|
|
103
130
|
return self
|
|
104
131
|
|
tactus/broker/server.py
CHANGED
|
@@ -38,6 +38,21 @@ async def _write_event_anyio(stream: anyio.abc.ByteStream, event: dict[str, Any]
|
|
|
38
38
|
await write_message_anyio(stream, event)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
async def _write_event_asyncio(writer: asyncio.StreamWriter, event: dict[str, Any]) -> None:
|
|
42
|
+
"""Write an event using length-prefixed protocol."""
|
|
43
|
+
await write_message(writer, event)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _flatten_exceptions(exc: BaseException) -> list[BaseException]:
|
|
47
|
+
"""Flatten BaseExceptionGroup into a list of leaf exceptions."""
|
|
48
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
49
|
+
leaves: list[BaseException] = []
|
|
50
|
+
for child in exc.exceptions:
|
|
51
|
+
leaves.extend(_flatten_exceptions(child))
|
|
52
|
+
return leaves
|
|
53
|
+
return [exc]
|
|
54
|
+
|
|
55
|
+
|
|
41
56
|
@dataclass(frozen=True)
|
|
42
57
|
class OpenAIChatConfig:
|
|
43
58
|
api_key_env: str = "OPENAI_API_KEY"
|
|
@@ -127,6 +142,7 @@ class _BaseBrokerServer:
|
|
|
127
142
|
event_handler: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
128
143
|
):
|
|
129
144
|
self._listener = None
|
|
145
|
+
self._serve_task: asyncio.Task[None] | None = None
|
|
130
146
|
self._openai = openai_backend or OpenAIChatBackend()
|
|
131
147
|
self._tools = tool_registry or HostToolRegistry.default()
|
|
132
148
|
self._event_handler = event_handler
|
|
@@ -145,8 +161,27 @@ class _BaseBrokerServer:
|
|
|
145
161
|
await self._listener.aclose()
|
|
146
162
|
self._listener = None
|
|
147
163
|
|
|
164
|
+
task = self._serve_task
|
|
165
|
+
self._serve_task = None
|
|
166
|
+
if task is not None:
|
|
167
|
+
try:
|
|
168
|
+
await task
|
|
169
|
+
except BaseExceptionGroup as eg:
|
|
170
|
+
# AnyIO raises ClosedResourceError during normal listener shutdown.
|
|
171
|
+
leaves = _flatten_exceptions(eg)
|
|
172
|
+
if leaves and all(isinstance(e, anyio.ClosedResourceError) for e in leaves):
|
|
173
|
+
return
|
|
174
|
+
raise
|
|
175
|
+
except asyncio.CancelledError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
148
178
|
async def __aenter__(self) -> "_BaseBrokerServer":
|
|
149
179
|
await self.start()
|
|
180
|
+
|
|
181
|
+
# AnyIO listeners (TCP/TLS) require an explicit serve loop. Run it in the background
|
|
182
|
+
# so `async with TcpBrokerServer(...)` is sufficient to accept connections.
|
|
183
|
+
if self._listener is not None:
|
|
184
|
+
self._serve_task = asyncio.create_task(self.serve(), name="tactus-broker-serve")
|
|
150
185
|
return self
|
|
151
186
|
|
|
152
187
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
@@ -221,6 +256,275 @@ class _BaseBrokerServer:
|
|
|
221
256
|
except Exception:
|
|
222
257
|
pass
|
|
223
258
|
|
|
259
|
+
async def _handle_connection_asyncio(
|
|
260
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
261
|
+
) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Handle a single broker request over asyncio streams.
|
|
264
|
+
|
|
265
|
+
UDS uses asyncio's StreamReader/StreamWriter APIs, while TCP uses AnyIO streams.
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
req = await read_message(reader)
|
|
269
|
+
req_id = req.get("id")
|
|
270
|
+
method = req.get("method")
|
|
271
|
+
params = req.get("params") or {}
|
|
272
|
+
|
|
273
|
+
if not req_id or not method:
|
|
274
|
+
await _write_event_asyncio(
|
|
275
|
+
writer,
|
|
276
|
+
{
|
|
277
|
+
"id": req_id or "",
|
|
278
|
+
"event": "error",
|
|
279
|
+
"error": {"type": "BadRequest", "message": "Missing id/method"},
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if method == "events.emit":
|
|
285
|
+
await self._handle_events_emit_asyncio(req_id, params, writer)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if method == "llm.chat":
|
|
289
|
+
await self._handle_llm_chat_asyncio(req_id, params, writer)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if method == "tool.call":
|
|
293
|
+
await self._handle_tool_call_asyncio(req_id, params, writer)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
await _write_event_asyncio(
|
|
297
|
+
writer,
|
|
298
|
+
{
|
|
299
|
+
"id": req_id,
|
|
300
|
+
"event": "error",
|
|
301
|
+
"error": {"type": "MethodNotFound", "message": f"Unknown method: {method}"},
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug("[BROKER] asyncio connection handler error", exc_info=True)
|
|
306
|
+
try:
|
|
307
|
+
await _write_event_asyncio(
|
|
308
|
+
writer,
|
|
309
|
+
{
|
|
310
|
+
"id": "",
|
|
311
|
+
"event": "error",
|
|
312
|
+
"error": {"type": type(e).__name__, "message": str(e)},
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
finally:
|
|
318
|
+
try:
|
|
319
|
+
writer.close()
|
|
320
|
+
await writer.wait_closed()
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
async def _handle_events_emit_asyncio(
|
|
325
|
+
self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
|
|
326
|
+
) -> None:
|
|
327
|
+
event = params.get("event")
|
|
328
|
+
if not isinstance(event, dict):
|
|
329
|
+
await _write_event_asyncio(
|
|
330
|
+
writer,
|
|
331
|
+
{
|
|
332
|
+
"id": req_id,
|
|
333
|
+
"event": "error",
|
|
334
|
+
"error": {"type": "BadRequest", "message": "params.event must be an object"},
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
if self._event_handler is not None:
|
|
341
|
+
self._event_handler(event)
|
|
342
|
+
except Exception:
|
|
343
|
+
logger.debug("[BROKER] event_handler raised", exc_info=True)
|
|
344
|
+
|
|
345
|
+
await _write_event_asyncio(writer, {"id": req_id, "event": "done", "data": {"ok": True}})
|
|
346
|
+
|
|
347
|
+
async def _handle_llm_chat_asyncio(
|
|
348
|
+
self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
|
|
349
|
+
) -> None:
|
|
350
|
+
provider = params.get("provider") or "openai"
|
|
351
|
+
if provider != "openai":
|
|
352
|
+
await _write_event_asyncio(
|
|
353
|
+
writer,
|
|
354
|
+
{
|
|
355
|
+
"id": req_id,
|
|
356
|
+
"event": "error",
|
|
357
|
+
"error": {
|
|
358
|
+
"type": "UnsupportedProvider",
|
|
359
|
+
"message": f"Unsupported provider: {provider}",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
model = params.get("model")
|
|
366
|
+
messages = params.get("messages")
|
|
367
|
+
stream = bool(params.get("stream", False))
|
|
368
|
+
temperature = params.get("temperature")
|
|
369
|
+
max_tokens = params.get("max_tokens")
|
|
370
|
+
|
|
371
|
+
if not isinstance(model, str) or not model:
|
|
372
|
+
await _write_event_asyncio(
|
|
373
|
+
writer,
|
|
374
|
+
{
|
|
375
|
+
"id": req_id,
|
|
376
|
+
"event": "error",
|
|
377
|
+
"error": {"type": "BadRequest", "message": "params.model must be a string"},
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
return
|
|
381
|
+
if not isinstance(messages, list):
|
|
382
|
+
await _write_event_asyncio(
|
|
383
|
+
writer,
|
|
384
|
+
{
|
|
385
|
+
"id": req_id,
|
|
386
|
+
"event": "error",
|
|
387
|
+
"error": {"type": "BadRequest", "message": "params.messages must be a list"},
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
if stream:
|
|
394
|
+
stream_iter = await self._openai.chat(
|
|
395
|
+
model=model,
|
|
396
|
+
messages=messages,
|
|
397
|
+
temperature=temperature,
|
|
398
|
+
max_tokens=max_tokens,
|
|
399
|
+
stream=True,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
full_text = ""
|
|
403
|
+
async for chunk in stream_iter:
|
|
404
|
+
try:
|
|
405
|
+
delta = chunk.choices[0].delta
|
|
406
|
+
text = getattr(delta, "content", None)
|
|
407
|
+
except Exception:
|
|
408
|
+
text = None
|
|
409
|
+
|
|
410
|
+
if not text:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
full_text += text
|
|
414
|
+
await _write_event_asyncio(
|
|
415
|
+
writer, {"id": req_id, "event": "delta", "data": {"text": text}}
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
await _write_event_asyncio(
|
|
419
|
+
writer,
|
|
420
|
+
{
|
|
421
|
+
"id": req_id,
|
|
422
|
+
"event": "done",
|
|
423
|
+
"data": {
|
|
424
|
+
"text": full_text,
|
|
425
|
+
"usage": {
|
|
426
|
+
"prompt_tokens": 0,
|
|
427
|
+
"completion_tokens": 0,
|
|
428
|
+
"total_tokens": 0,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
resp = await self._openai.chat(
|
|
436
|
+
model=model,
|
|
437
|
+
messages=messages,
|
|
438
|
+
temperature=temperature,
|
|
439
|
+
max_tokens=max_tokens,
|
|
440
|
+
stream=False,
|
|
441
|
+
)
|
|
442
|
+
text = ""
|
|
443
|
+
try:
|
|
444
|
+
text = resp.choices[0].message.content or ""
|
|
445
|
+
except Exception:
|
|
446
|
+
text = ""
|
|
447
|
+
|
|
448
|
+
await _write_event_asyncio(
|
|
449
|
+
writer,
|
|
450
|
+
{
|
|
451
|
+
"id": req_id,
|
|
452
|
+
"event": "done",
|
|
453
|
+
"data": {
|
|
454
|
+
"text": text,
|
|
455
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.debug("[BROKER] llm.chat error", exc_info=True)
|
|
461
|
+
await _write_event_asyncio(
|
|
462
|
+
writer,
|
|
463
|
+
{
|
|
464
|
+
"id": req_id,
|
|
465
|
+
"event": "error",
|
|
466
|
+
"error": {"type": type(e).__name__, "message": str(e)},
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def _handle_tool_call_asyncio(
|
|
471
|
+
self, req_id: str, params: dict[str, Any], writer: asyncio.StreamWriter
|
|
472
|
+
) -> None:
|
|
473
|
+
name = params.get("name")
|
|
474
|
+
args = params.get("args") or {}
|
|
475
|
+
|
|
476
|
+
if not isinstance(name, str) or not name:
|
|
477
|
+
await _write_event_asyncio(
|
|
478
|
+
writer,
|
|
479
|
+
{
|
|
480
|
+
"id": req_id,
|
|
481
|
+
"event": "error",
|
|
482
|
+
"error": {"type": "BadRequest", "message": "params.name must be a string"},
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
return
|
|
486
|
+
if not isinstance(args, dict):
|
|
487
|
+
await _write_event_asyncio(
|
|
488
|
+
writer,
|
|
489
|
+
{
|
|
490
|
+
"id": req_id,
|
|
491
|
+
"event": "error",
|
|
492
|
+
"error": {"type": "BadRequest", "message": "params.args must be an object"},
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
result = self._tools.call(name, args)
|
|
499
|
+
except KeyError:
|
|
500
|
+
await _write_event_asyncio(
|
|
501
|
+
writer,
|
|
502
|
+
{
|
|
503
|
+
"id": req_id,
|
|
504
|
+
"event": "error",
|
|
505
|
+
"error": {
|
|
506
|
+
"type": "ToolNotAllowed",
|
|
507
|
+
"message": f"Tool not allowlisted: {name}",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.debug("[BROKER] tool.call error", exc_info=True)
|
|
514
|
+
await _write_event_asyncio(
|
|
515
|
+
writer,
|
|
516
|
+
{
|
|
517
|
+
"id": req_id,
|
|
518
|
+
"event": "error",
|
|
519
|
+
"error": {"type": type(e).__name__, "message": str(e)},
|
|
520
|
+
},
|
|
521
|
+
)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
await _write_event_asyncio(
|
|
525
|
+
writer, {"id": req_id, "event": "done", "data": {"result": result}}
|
|
526
|
+
)
|
|
527
|
+
|
|
224
528
|
async def _handle_events_emit(
|
|
225
529
|
self, req_id: str, params: dict[str, Any], byte_stream: anyio.abc.ByteStream
|
|
226
530
|
) -> None:
|
|
@@ -472,6 +776,16 @@ class BrokerServer(_BaseBrokerServer):
|
|
|
472
776
|
logger.info(f"[BROKER] Listening on UDS: {self.socket_path}")
|
|
473
777
|
|
|
474
778
|
async def aclose(self) -> None:
|
|
779
|
+
server = getattr(self, "_server", None)
|
|
780
|
+
if server is not None:
|
|
781
|
+
try:
|
|
782
|
+
server.close()
|
|
783
|
+
await server.wait_closed()
|
|
784
|
+
except Exception:
|
|
785
|
+
logger.debug("[BROKER] Failed to close asyncio server", exc_info=True)
|
|
786
|
+
finally:
|
|
787
|
+
self._server = None
|
|
788
|
+
|
|
475
789
|
await super().aclose()
|
|
476
790
|
|
|
477
791
|
if self._server is not None:
|
tactus/cli/app.py
CHANGED
|
@@ -722,6 +722,7 @@ def run(
|
|
|
722
722
|
# Handle global flags
|
|
723
723
|
if mock_all:
|
|
724
724
|
mock_manager.enable_mock()
|
|
725
|
+
runtime.mock_all_agents = True
|
|
725
726
|
console.print("[yellow]Mocking enabled for all tools[/yellow]")
|
|
726
727
|
elif real_all:
|
|
727
728
|
mock_manager.disable_mock()
|
|
@@ -807,7 +808,16 @@ def run(
|
|
|
807
808
|
# Display results
|
|
808
809
|
if result.get("result"):
|
|
809
810
|
console.print("\n[green]Result:[/green]")
|
|
810
|
-
|
|
811
|
+
display_result = result["result"]
|
|
812
|
+
try:
|
|
813
|
+
from tactus.protocols.result import TactusResult
|
|
814
|
+
|
|
815
|
+
if isinstance(display_result, TactusResult):
|
|
816
|
+
display_result = display_result.output
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
console.print(f" {display_result}")
|
|
811
821
|
|
|
812
822
|
# Display state
|
|
813
823
|
if result.get("state"):
|