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.
Files changed (40) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +55 -0
  3. tactus/adapters/cli_log.py +0 -25
  4. tactus/broker/__init__.py +12 -0
  5. tactus/broker/client.py +260 -0
  6. tactus/broker/server.py +505 -0
  7. tactus/broker/stdio.py +12 -0
  8. tactus/cli/app.py +38 -2
  9. tactus/core/dsl_stubs.py +2 -1
  10. tactus/core/output_validator.py +6 -3
  11. tactus/core/registry.py +8 -1
  12. tactus/core/runtime.py +15 -1
  13. tactus/core/yaml_parser.py +1 -11
  14. tactus/dspy/agent.py +190 -102
  15. tactus/dspy/broker_lm.py +181 -0
  16. tactus/dspy/config.py +21 -8
  17. tactus/dspy/prediction.py +71 -5
  18. tactus/ide/server.py +37 -142
  19. tactus/primitives/__init__.py +2 -0
  20. tactus/primitives/handles.py +34 -7
  21. tactus/primitives/host.py +94 -0
  22. tactus/primitives/log.py +4 -0
  23. tactus/primitives/model.py +20 -2
  24. tactus/primitives/procedure.py +106 -51
  25. tactus/primitives/tool.py +0 -2
  26. tactus/protocols/__init__.py +0 -7
  27. tactus/protocols/log_handler.py +2 -2
  28. tactus/protocols/models.py +1 -1
  29. tactus/sandbox/config.py +33 -5
  30. tactus/sandbox/container_runner.py +498 -60
  31. tactus/sandbox/entrypoint.py +30 -17
  32. tactus/sandbox/protocol.py +0 -9
  33. tactus/testing/README.md +0 -4
  34. tactus/testing/mock_agent.py +80 -23
  35. tactus/testing/test_runner.py +0 -18
  36. {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/METADATA +1 -1
  37. {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/RECORD +40 -33
  38. {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/WHEEL +0 -0
  39. {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/entry_points.txt +0 -0
  40. {tactus-0.26.0.dist-info → tactus-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -149,12 +149,38 @@ class AgentHandle:
149
149
  logger.debug(
150
150
  f"[CHECKPOINT] Creating checkpoint for agent '{self.name}', type=agent_turn, source_info={source_info}"
151
151
  )
152
- return self._execution_context.checkpoint(
153
- agent_call, checkpoint_type="agent_turn", source_info=source_info
154
- )
155
- else:
156
- # No execution context - call directly without checkpointing
157
- return self._primitive(converted_inputs)
152
+ result = self._execution_context.checkpoint(
153
+ agent_call, checkpoint_type="agent_turn", source_info=source_info
154
+ )
155
+ else:
156
+ # No execution context - call directly without checkpointing
157
+ result = self._primitive(converted_inputs)
158
+
159
+ # Convenience: expose the last agent output on the handle as `.output`
160
+ # for Lua patterns like `agent(); return agent.output`.
161
+ output_text = None
162
+ if result is not None:
163
+ for attr in ("response", "message"):
164
+ try:
165
+ value = getattr(result, attr, None)
166
+ except Exception:
167
+ value = None
168
+ if isinstance(value, str):
169
+ output_text = value
170
+ break
171
+
172
+ if output_text is None and isinstance(result, dict):
173
+ for key in ("response", "message"):
174
+ value = result.get(key)
175
+ if isinstance(value, str):
176
+ output_text = value
177
+ break
178
+
179
+ if output_text is None:
180
+ output_text = str(result)
181
+
182
+ self.output = output_text
183
+ return result
158
184
 
159
185
  def _set_primitive(
160
186
  self, primitive: "DSPyAgentHandle", execution_context: Optional[Any] = None
@@ -216,7 +242,8 @@ class ModelHandle:
216
242
  f"Model '{self.name}' initialization failed.\n"
217
243
  f"This should not happen - please report this as a bug."
218
244
  )
219
- return self._primitive.predict(data)
245
+ converted_data = _convert_lua_table(data) if data is not None else None
246
+ return self._primitive.predict(converted_data)
220
247
 
221
248
  def __call__(self, data: Any = None) -> Any:
222
249
  """
@@ -0,0 +1,94 @@
1
+ """
2
+ Host Primitive - brokered host capabilities for the runtime container.
3
+
4
+ This primitive is intended to be used inside the sandboxed runtime container.
5
+ It delegates allowlisted operations to the trusted host-side broker via the
6
+ `TACTUS_BROKER_SOCKET` transport.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any, Dict, Optional
13
+
14
+ from tactus.broker.client import BrokerClient
15
+
16
+
17
+ class HostPrimitive:
18
+ """Provides access to allowlisted host-side tools via the broker."""
19
+
20
+ def __init__(self, client: Optional[BrokerClient] = None):
21
+ self._client = client or BrokerClient.from_environment()
22
+ self._registry = None
23
+ if self._client is None:
24
+ # Allow Host.call() to work in non-sandboxed runs (and in deterministic tests)
25
+ # without requiring a broker transport, while still staying deny-by-default.
26
+ from tactus.broker.server import HostToolRegistry
27
+
28
+ self._registry = HostToolRegistry.default()
29
+
30
+ def _run_coro(self, coro):
31
+ """
32
+ Run an async coroutine from Lua's synchronous context.
33
+
34
+ Mirrors the approach used by `ToolHandle` for async tool handlers.
35
+ """
36
+ try:
37
+ asyncio.get_running_loop()
38
+
39
+ import threading
40
+
41
+ result_container = {"value": None, "exception": None}
42
+
43
+ def run_in_thread():
44
+ try:
45
+ result_container["value"] = asyncio.run(coro)
46
+ except Exception as e:
47
+ result_container["exception"] = e
48
+
49
+ thread = threading.Thread(target=run_in_thread)
50
+ thread.start()
51
+ thread.join()
52
+
53
+ if result_container["exception"]:
54
+ raise result_container["exception"]
55
+ return result_container["value"]
56
+
57
+ except RuntimeError:
58
+ return asyncio.run(coro)
59
+
60
+ def _lua_to_python(self, obj: Any) -> Any:
61
+ if obj is None:
62
+ return None
63
+ if hasattr(obj, "items") and not isinstance(obj, dict):
64
+ return {k: self._lua_to_python(v) for k, v in obj.items()}
65
+ if isinstance(obj, dict):
66
+ return {k: self._lua_to_python(v) for k, v in obj.items()}
67
+ if isinstance(obj, (list, tuple)):
68
+ return [self._lua_to_python(v) for v in obj]
69
+ return obj
70
+
71
+ def call(self, name: str, args: Optional[Dict[str, Any]] = None) -> Any:
72
+ """
73
+ Call an allowlisted host tool via the broker.
74
+
75
+ Example (Lua):
76
+ local result = Host.call("host.ping", {value = 1})
77
+ """
78
+ if not isinstance(name, str) or not name:
79
+ raise ValueError("Host.call requires a non-empty tool name string")
80
+
81
+ args_dict = self._lua_to_python(args) or {}
82
+ if not isinstance(args_dict, dict):
83
+ raise ValueError("Host.call args must be an object/table")
84
+
85
+ if self._client is not None:
86
+ return self._run_coro(self._client.call_tool(name=name, args=args_dict))
87
+
88
+ if self._registry is not None:
89
+ try:
90
+ return self._registry.call(name, args_dict)
91
+ except KeyError as e:
92
+ raise RuntimeError(f"Tool not allowlisted: {name}") from e
93
+
94
+ raise RuntimeError("Host.call requires TACTUS_BROKER_SOCKET to be set")
tactus/primitives/log.py CHANGED
@@ -150,6 +150,10 @@ class LogPrimitive:
150
150
  formatted = self._format_message(message, context)
151
151
  self.logger.warning(formatted)
152
152
 
153
+ def warning(self, message: str, context: Optional[Dict[str, Any]] = None) -> None:
154
+ """Alias for warn(), matching common logging APIs."""
155
+ self.warn(message, context)
156
+
153
157
  def error(self, message: str, context: Optional[Dict[str, Any]] = None) -> None:
154
158
  """
155
159
  Log error message.
@@ -3,7 +3,7 @@ Model primitive for ML inference with automatic checkpointing.
3
3
  """
4
4
 
5
5
  import logging
6
- from typing import Any
6
+ from typing import Any, Optional
7
7
 
8
8
  from tactus.core.execution_context import ExecutionContext
9
9
 
@@ -23,7 +23,13 @@ class ModelPrimitive:
23
23
  Each .predict() call is automatically checkpointed for durability.
24
24
  """
25
25
 
26
- def __init__(self, model_name: str, config: dict, context: ExecutionContext | None = None):
26
+ def __init__(
27
+ self,
28
+ model_name: str,
29
+ config: dict,
30
+ context: ExecutionContext | None = None,
31
+ mock_manager: Optional[Any] = None,
32
+ ):
27
33
  """
28
34
  Initialize model primitive.
29
35
 
@@ -39,6 +45,7 @@ class ModelPrimitive:
39
45
  self.model_name = model_name
40
46
  self.config = config
41
47
  self.context = context
48
+ self.mock_manager = mock_manager
42
49
 
43
50
  # Extract optional input/output schemas
44
51
  self.input_schema = config.get("input", {})
@@ -124,6 +131,17 @@ class ModelPrimitive:
124
131
  Returns:
125
132
  Model prediction result
126
133
  """
134
+ if self.mock_manager is not None:
135
+ args = input_data if isinstance(input_data, dict) else {"input": input_data}
136
+ mock_result = self.mock_manager.get_mock_response(self.model_name, args)
137
+ if mock_result is not None:
138
+ # Ensure temporal mocks advance and calls are available for assertions.
139
+ try:
140
+ self.mock_manager.record_call(self.model_name, args, mock_result)
141
+ except Exception:
142
+ pass
143
+ return mock_result
144
+
127
145
  return self.backend.predict_sync(input_data)
128
146
 
129
147
  def __call__(self, input_data: Any) -> Any:
@@ -75,6 +75,7 @@ class ProcedurePrimitive:
75
75
  self,
76
76
  execution_context: Any,
77
77
  runtime_factory: Callable[[str, Dict[str, Any]], Any],
78
+ lua_sandbox: Any = None,
78
79
  max_depth: int = 5,
79
80
  current_depth: int = 0,
80
81
  ):
@@ -84,11 +85,13 @@ class ProcedurePrimitive:
84
85
  Args:
85
86
  execution_context: Execution context for state management
86
87
  runtime_factory: Factory function to create TactusRuntime instances
88
+ lua_sandbox: LuaSandbox instance for in-file procedure lookup
87
89
  max_depth: Maximum recursion depth
88
90
  current_depth: Current recursion depth
89
91
  """
90
92
  self.execution_context = execution_context
91
93
  self.runtime_factory = runtime_factory
94
+ self.lua_sandbox = lua_sandbox
92
95
  self.max_depth = max_depth
93
96
  self.current_depth = current_depth
94
97
  self.handles: Dict[str, ProcedureHandle] = {}
@@ -96,6 +99,28 @@ class ProcedurePrimitive:
96
99
 
97
100
  logger.info(f"ProcedurePrimitive initialized (depth {current_depth}/{max_depth})")
98
101
 
102
+ def __call__(self, name: str) -> Any:
103
+ """
104
+ Look up an in-file named procedure by name.
105
+
106
+ Enables Lua syntax:
107
+ local res = Procedure("my_proc")({ ... })
108
+
109
+ Named procedures are injected into Lua globals by the runtime during initialization.
110
+ """
111
+ if not self.lua_sandbox or not hasattr(self.lua_sandbox, "lua"):
112
+ raise ProcedureExecutionError("Procedure lookup is not available (lua_sandbox missing)")
113
+
114
+ try:
115
+ proc = self.lua_sandbox.lua.globals()[name]
116
+ except Exception:
117
+ proc = None
118
+
119
+ if proc is None:
120
+ raise ProcedureExecutionError(f"Named procedure '{name}' not found")
121
+
122
+ return proc
123
+
99
124
  def run(self, name: str, params: Optional[Dict[str, Any]] = None) -> Any:
100
125
  """
101
126
  Synchronous procedure invocation with auto-checkpointing.
@@ -122,6 +147,12 @@ class ProcedurePrimitive:
122
147
 
123
148
  # Normalize params
124
149
  params = params or {}
150
+ if hasattr(params, "items"):
151
+ from tactus.core.dsl_stubs import lua_table_to_dict
152
+
153
+ params = lua_table_to_dict(params)
154
+ if isinstance(params, list) and len(params) == 0:
155
+ params = {}
125
156
 
126
157
  # Wrap execution in checkpoint for durability
127
158
  def execute_procedure():
@@ -134,26 +165,37 @@ class ProcedurePrimitive:
134
165
 
135
166
  # Execute synchronously (runtime.execute is async, so we need to run it)
136
167
  import asyncio
168
+ import threading
169
+
170
+ async def run_subprocedure():
171
+ return await runtime.execute(source=source, context=params, format="lua")
137
172
 
138
173
  try:
139
- loop = asyncio.get_running_loop()
140
- # We're already in an async context, use run_until_complete would fail
141
- # Instead, we need to await it, but we're in a sync function
142
- # Solution: Create a task and wait for it
143
- result = asyncio.create_task(
144
- runtime.execute(source=source, context=params, format="lua")
145
- )
146
- # This won't work in sync context - we need to handle this differently
147
- # For now, use run_until_complete in a new loop
148
- raise RuntimeError("Cannot run nested async in sync context")
174
+ asyncio.get_running_loop()
175
+ has_running_loop = True
149
176
  except RuntimeError:
150
- # No running loop or nested loop issue - create new one
151
- loop = asyncio.new_event_loop()
152
- asyncio.set_event_loop(loop)
153
- result = loop.run_until_complete(
154
- runtime.execute(source=source, context=params, format="lua")
155
- )
156
- loop.close()
177
+ has_running_loop = False
178
+
179
+ if has_running_loop:
180
+ result_holder = {}
181
+ error_holder = {}
182
+
183
+ def run_in_thread():
184
+ try:
185
+ result_holder["result"] = asyncio.run(run_subprocedure())
186
+ except Exception as e:
187
+ error_holder["error"] = e
188
+
189
+ t = threading.Thread(target=run_in_thread, daemon=True)
190
+ t.start()
191
+ t.join()
192
+
193
+ if "error" in error_holder:
194
+ raise error_holder["error"]
195
+
196
+ result = result_holder.get("result")
197
+ else:
198
+ result = asyncio.run(run_subprocedure())
157
199
 
158
200
  # Extract result from execution response
159
201
  if result.get("success"):
@@ -469,41 +511,54 @@ class ProcedurePrimitive:
469
511
  Raises:
470
512
  FileNotFoundError: If procedure file not found
471
513
  """
472
- import os
473
514
  from pathlib import Path
474
515
 
475
- # Build search paths
476
- search_paths = [
477
- name, # Exact path
478
- f"{name}.tac", # Add extension
479
- ]
480
-
481
- # Add paths relative to the current procedure file's directory
482
- if (
483
- hasattr(self.execution_context, "current_tac_file")
484
- and self.execution_context.current_tac_file
485
- ):
486
- current_file = Path(self.execution_context.current_tac_file)
487
- current_dir = current_file.parent
488
- search_paths.extend(
489
- [
490
- str(current_dir / name), # Relative to current file
491
- str(current_dir / f"{name}.tac"), # Relative with extension
492
- ]
493
- )
494
-
495
- # Add examples directory as fallback
496
- search_paths.extend(
497
- [
498
- f"examples/{name}", # Examples directory
499
- f"examples/{name}.tac", # Examples with extension
500
- ]
501
- )
516
+ search_paths: list[Path] = []
517
+ seen: set[Path] = set()
518
+
519
+ def add_path(path: Path) -> None:
520
+ normalized = path.resolve() if path.is_absolute() else path
521
+ if normalized in seen:
522
+ return
523
+ seen.add(normalized)
524
+ search_paths.append(path)
525
+
526
+ name_path = Path(name)
527
+
528
+ def add_candidates(base: Path | None, rel: Path) -> None:
529
+ candidate = (base / rel) if base is not None else rel
530
+ add_path(candidate)
531
+ if candidate.suffix != ".tac":
532
+ add_path(Path(str(candidate) + ".tac"))
533
+
534
+ # Absolute path: try as-is.
535
+ if name_path.is_absolute():
536
+ add_candidates(None, name_path)
537
+ else:
538
+ # Relative to current working directory (CLI usage).
539
+ add_candidates(None, name_path)
540
+
541
+ # Relative to the current .tac file directory and its parents (BDD/temp cwd usage).
542
+ current_tac_file = getattr(self.execution_context, "current_tac_file", None)
543
+ if current_tac_file:
544
+ current_dir = Path(current_tac_file).parent
545
+ add_candidates(current_dir, name_path)
546
+
547
+ # Also try resolving from parent directories (helps when callers pass paths
548
+ # relative to project root, but cwd is not the project root).
549
+ for parent in list(current_dir.parents)[:5]:
550
+ add_candidates(parent, name_path)
551
+
552
+ # Fallback: examples directory relative to repo root in common layouts.
553
+ add_candidates(None, Path("examples") / name_path)
502
554
 
503
555
  for path in search_paths:
504
- if os.path.exists(path):
505
- logger.debug(f"Loading procedure from: {path}")
506
- with open(path, "r") as f:
507
- return f.read()
508
-
509
- raise FileNotFoundError(f"Procedure '{name}' not found. Searched: {search_paths}")
556
+ try:
557
+ if path.exists() and path.is_file():
558
+ logger.debug(f"Loading procedure from: {path}")
559
+ return path.read_text()
560
+ except Exception:
561
+ continue
562
+
563
+ searched = [str(p) for p in search_paths]
564
+ raise FileNotFoundError(f"Procedure '{name}' not found. Searched: {searched}")
tactus/primitives/tool.py CHANGED
@@ -22,8 +22,6 @@ class ToolCall:
22
22
 
23
23
  def __init__(self, name: str, args: Dict[str, Any], result: Any):
24
24
  self.name = name
25
- # Backward/compatibility alias used by some callers
26
- self.tool_name = name
27
25
  self.args = args
28
26
  self.result = result
29
27
  self.timestamp = None # Could add timestamp tracking
@@ -13,10 +13,6 @@ from tactus.protocols.models import (
13
13
  ChatMessage,
14
14
  )
15
15
 
16
- # Shared usage/cost + standard result
17
- from tactus.protocols.cost import UsageStats, CostStats
18
- from tactus.protocols.result import TactusResult
19
-
20
16
  # Protocols
21
17
  from tactus.protocols.storage import StorageBackend
22
18
  from tactus.protocols.hitl import HITLHandler
@@ -32,9 +28,6 @@ __all__ = [
32
28
  "HITLRequest",
33
29
  "HITLResponse",
34
30
  "ChatMessage",
35
- "UsageStats",
36
- "CostStats",
37
- "TactusResult",
38
31
  # Protocols
39
32
  "StorageBackend",
40
33
  "HITLHandler",
@@ -6,7 +6,7 @@ Implementations can render logs differently (CLI with Rich, IDE with React, etc.
6
6
  """
7
7
 
8
8
  from typing import Protocol, Union
9
- from tactus.protocols.models import ExecutionSummaryEvent, LogEvent, SystemAlertEvent
9
+ from tactus.protocols.models import LogEvent, ExecutionSummaryEvent
10
10
 
11
11
 
12
12
  class LogHandler(Protocol):
@@ -17,7 +17,7 @@ class LogHandler(Protocol):
17
17
  appropriately for different environments (CLI, IDE, API, etc.).
18
18
  """
19
19
 
20
- def log(self, event: Union[LogEvent, ExecutionSummaryEvent, SystemAlertEvent]) -> None:
20
+ def log(self, event: Union[LogEvent, ExecutionSummaryEvent]) -> None:
21
21
  """
22
22
  Handle a log or summary event.
23
23
 
@@ -203,7 +203,7 @@ class CostEvent(BaseModel):
203
203
 
204
204
  # Response data (new field)
205
205
  response_data: Optional[Dict[str, Any]] = Field(
206
- None, description="Agent's response data (extracted from result.value)"
206
+ None, description="Agent's response data (extracted from result.data)"
207
207
  )
208
208
 
209
209
  model_config = {"arbitrary_types_allowed": True}
tactus/sandbox/config.py CHANGED
@@ -26,12 +26,12 @@ class SandboxConfig(BaseModel):
26
26
 
27
27
  # Core settings
28
28
  # Security model:
29
- # - enabled=None (default): Sandbox REQUIRED, error if Docker unavailable
29
+ # - enabled=None (default): Sandbox AUTO (use if available; otherwise run without isolation)
30
30
  # - enabled=True: Sandbox REQUIRED, error if Docker unavailable
31
31
  # - enabled=False: Sandbox explicitly disabled (security risk acknowledged)
32
32
  enabled: Optional[bool] = Field(
33
33
  default=None,
34
- description="Enable sandbox mode. None/True=required (error if unavailable), False=disabled",
34
+ description="Enable sandbox mode. None=auto, True=required, False=disabled",
35
35
  )
36
36
 
37
37
  # Docker image settings
@@ -60,10 +60,38 @@ class SandboxConfig(BaseModel):
60
60
 
61
61
  # Network mode
62
62
  network: str = Field(
63
- default="bridge",
63
+ default="none",
64
64
  description="Docker network mode (bridge allows outbound, none blocks all)",
65
65
  )
66
66
 
67
+ # Broker transport (how the secretless runtime reaches the host broker)
68
+ # - stdio: local Docker MVP (works on Docker Desktop with --network none)
69
+ # - tcp/tls: remote-mode spike (for K8s/cloud; requires container networking)
70
+ broker_transport: str = Field(
71
+ default="stdio",
72
+ description="Broker transport for the runtime container: stdio, tcp, or tls",
73
+ )
74
+ broker_host: str = Field(
75
+ default="host.docker.internal",
76
+ description="Broker hostname for tcp/tls (as seen from inside the container)",
77
+ )
78
+ broker_bind_host: str = Field(
79
+ default="0.0.0.0",
80
+ description="Bind address for the host-side broker server in tcp/tls modes",
81
+ )
82
+ broker_port: int = Field(
83
+ default=0,
84
+ description="Port for the host-side broker server in tcp/tls modes (0=auto)",
85
+ )
86
+ broker_tls_cert_file: Optional[str] = Field(
87
+ default=None,
88
+ description="TLS certificate file for broker (PEM). Required when broker_transport='tls'",
89
+ )
90
+ broker_tls_key_file: Optional[str] = Field(
91
+ default=None,
92
+ description="TLS private key file for broker (PEM). Required when broker_transport='tls'",
93
+ )
94
+
67
95
  # Resource limits
68
96
  limits: SandboxLimits = Field(
69
97
  default_factory=SandboxLimits,
@@ -109,9 +137,9 @@ class SandboxConfig(BaseModel):
109
137
 
110
138
  Returns:
111
139
  True if Docker unavailability should be a fatal error.
112
- This is True unless the user explicitly disabled sandbox.
140
+ This is True only when the user explicitly requires the sandbox (enabled=True).
113
141
  """
114
- return not self.is_explicitly_disabled()
142
+ return self.enabled is True
115
143
 
116
144
  model_config = {"arbitrary_types_allowed": True}
117
145