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 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.30.0"
8
+ __version__ = "0.31.1"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
@@ -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
- for param_name, param_spec in input_schema.items():
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
 
@@ -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
- try:
66
- logger.info(f"Connecting to MCP server '{name}'...")
67
-
68
- # Substitute environment variables in config
69
- config = substitute_env_vars(config)
70
-
71
- # Create base server
72
- server = MCPServerStdio(
73
- command=config["command"],
74
- args=config.get("args", []),
75
- env=config.get("env"),
76
- process_tool_call=self._create_trace_callback(name), # Tracking hook
77
- )
78
-
79
- # Wrap with prefix to namespace tools
80
- prefixed_server = server.prefixed(name)
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
- else:
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
- # Don't raise - allow procedure to continue without this MCP server
100
- logger.info(f"Continuing without MCP server '{name}'")
101
- continue
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
- console.print(f" {result['result']}")
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"):