tactus 0.22.0__py3-none-any.whl → 0.23.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.22.0"
8
+ __version__ = "0.23.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
tactus/dspy/agent.py CHANGED
@@ -82,6 +82,7 @@ class DSPyAgentHandle:
82
82
  module: DSPy module type to use (default: "Predict"). Options:
83
83
  - "Predict": Simple pass-through prediction (no reasoning traces)
84
84
  - "ChainOfThought": Adds step-by-step reasoning before response
85
+ - "Raw": Minimal formatting, direct LM calls (lowest token overhead)
85
86
  initial_message: Initial message to send on first turn if no inject
86
87
  registry: Optional Registry instance for accessing mocks
87
88
  mock_manager: Optional MockManager instance for checking mocks
@@ -288,6 +289,7 @@ class DSPyAgentHandle:
288
289
  mapping = {
289
290
  "Predict": "predict",
290
291
  "ChainOfThought": "chain_of_thought",
292
+ "Raw": "raw",
291
293
  # Future modules can be added here:
292
294
  # "ReAct": "react",
293
295
  # "ProgramOfThought": "program_of_thought",
tactus/dspy/config.py CHANGED
@@ -131,3 +131,69 @@ def reset_lm_configuration() -> None:
131
131
  _current_lm = None
132
132
  # Also reset DSPy's global configuration
133
133
  dspy.configure(lm=None)
134
+
135
+
136
+ def create_lm(
137
+ model: str,
138
+ api_key: Optional[str] = None,
139
+ api_base: Optional[str] = None,
140
+ temperature: float = 0.7,
141
+ max_tokens: Optional[int] = None,
142
+ model_type: Optional[str] = None,
143
+ **kwargs: Any,
144
+ ) -> dspy.LM:
145
+ """
146
+ Create a Language Model instance WITHOUT setting it as global default.
147
+
148
+ This is useful for creating LMs in async contexts where dspy.configure()
149
+ cannot be called (e.g., in different event loops or async tasks).
150
+
151
+ Use with dspy.context(lm=...) to set the LM for a specific scope:
152
+ lm = create_lm("openai/gpt-4o")
153
+ with dspy.context(lm=lm):
154
+ # Use DSPy operations here
155
+
156
+ Args:
157
+ model: Model identifier in LiteLLM format (e.g., "openai/gpt-4o")
158
+ api_key: API key (optional, can use environment variables)
159
+ api_base: Custom API base URL (optional)
160
+ temperature: Sampling temperature (default: 0.7)
161
+ max_tokens: Maximum tokens in response (optional)
162
+ model_type: Model type (e.g., "chat", "responses" for reasoning models)
163
+ **kwargs: Additional LiteLLM parameters
164
+
165
+ Returns:
166
+ dspy.LM instance (not configured globally)
167
+ """
168
+ # Validate model parameter
169
+ if model is None or not model:
170
+ raise ValueError("model is required for LM configuration")
171
+
172
+ if not isinstance(model, str) or not model.startswith(
173
+ ("openai/", "anthropic/", "bedrock/", "gemini/", "ollama/")
174
+ ):
175
+ # Check if it's at least formatted correctly
176
+ if "/" not in model:
177
+ raise ValueError(
178
+ f"Invalid model format: {model}. Expected format like 'provider/model-name'"
179
+ )
180
+
181
+ # Build configuration
182
+ lm_kwargs = {
183
+ "temperature": temperature,
184
+ # IMPORTANT: Disable caching to enable streaming
185
+ "cache": False,
186
+ **kwargs,
187
+ }
188
+
189
+ if api_key:
190
+ lm_kwargs["api_key"] = api_key
191
+ if api_base:
192
+ lm_kwargs["api_base"] = api_base
193
+ if max_tokens:
194
+ lm_kwargs["max_tokens"] = max_tokens
195
+ if model_type:
196
+ lm_kwargs["model_type"] = model_type
197
+
198
+ # Create LM without setting as global default
199
+ return dspy.LM(model, **lm_kwargs)
tactus/dspy/module.py CHANGED
@@ -5,12 +5,135 @@ This module provides the Module primitive that maps to DSPy modules,
5
5
  supporting various prediction strategies like Predict, ChainOfThought, etc.
6
6
  """
7
7
 
8
+ import logging
8
9
  from typing import Any, Dict, Optional, Union
9
10
 
10
11
  import dspy
11
12
 
12
13
  from tactus.dspy.signature import create_signature
13
14
 
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RawModule(dspy.Module):
19
+ """
20
+ Minimal DSPy module for raw LM calls without formatting delimiters.
21
+
22
+ This module calls the LM directly without using dspy.Predict, eliminating
23
+ the formatting delimiters like [[ ## response ## ]] that Predict adds.
24
+
25
+ Key features:
26
+ - No DSPy formatting delimiters in output
27
+ - Works correctly with dspy.streamify() for streaming
28
+ - Maintains DSPy's optimization and tracing capabilities
29
+ - Supports dynamic signatures (with or without tool_calls)
30
+
31
+ Usage:
32
+ # Without tools
33
+ raw = RawModule(signature="system_prompt, history, user_message -> response")
34
+ result = raw(user_message="Hello", history="")
35
+
36
+ # With tools
37
+ raw = RawModule(signature="system_prompt, history, user_message, available_tools -> response, tool_calls")
38
+ result = raw(user_message="Hello", history="", available_tools="...")
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ signature: str = "system_prompt, history, user_message -> response",
44
+ system_prompt: str = "",
45
+ ):
46
+ """
47
+ Initialize raw module.
48
+
49
+ Args:
50
+ signature: DSPy signature string defining inputs and outputs
51
+ system_prompt: System prompt to prepend to all conversations
52
+ """
53
+ super().__init__()
54
+ self.system_prompt = system_prompt
55
+ self.signature = signature
56
+ # Parse signature to determine output fields
57
+ self.output_fields = self._parse_output_fields(signature)
58
+
59
+ def _parse_output_fields(self, signature: str) -> list:
60
+ """Extract output field names from signature string."""
61
+ if "->" not in signature:
62
+ return ["response"]
63
+ output_part = signature.split("->")[1].strip()
64
+ return [field.strip() for field in output_part.split(",")]
65
+
66
+ def forward(
67
+ self, system_prompt: str, history, user_message: str, available_tools: str = "", **kwargs
68
+ ):
69
+ """
70
+ Forward pass with direct LM call (no formatting delimiters).
71
+
72
+ Args:
73
+ system_prompt: System prompt (overrides init if provided)
74
+ history: Conversation history (dspy.History, TactusHistory, or string)
75
+ user_message: Current user message
76
+ available_tools: Optional tools description (for agents with tools)
77
+ **kwargs: Additional args passed to LM
78
+
79
+ Returns:
80
+ dspy.Prediction with response field (and tool_calls if signature includes it)
81
+ """
82
+ # Use provided system_prompt or fall back to init value
83
+ sys_prompt = system_prompt or self.system_prompt
84
+
85
+ # Build messages array for direct LM call
86
+ messages = []
87
+
88
+ # Add system prompt if provided
89
+ if sys_prompt:
90
+ messages.append({"role": "system", "content": sys_prompt})
91
+
92
+ # Add history messages
93
+ if history:
94
+ if hasattr(history, "messages"):
95
+ # It's a History object - use messages directly
96
+ messages.extend(history.messages)
97
+ elif isinstance(history, str) and history.strip():
98
+ # It's a formatted string - parse it
99
+ for line in history.strip().split("\n"):
100
+ if line.startswith("User: "):
101
+ messages.append({"role": "user", "content": line[6:]})
102
+ elif line.startswith("Assistant: "):
103
+ messages.append({"role": "assistant", "content": line[11:]})
104
+
105
+ # Add current user message
106
+ if user_message:
107
+ # If tools are available, include them in the user message
108
+ if available_tools and "available_tools" in self.signature:
109
+ user_content = f"{user_message}\n\nAvailable tools:\n{available_tools}"
110
+ messages.append({"role": "user", "content": user_content})
111
+ else:
112
+ messages.append({"role": "user", "content": user_message})
113
+
114
+ # Get the configured LM
115
+ lm = dspy.settings.lm
116
+ if lm is None:
117
+ raise RuntimeError("No LM configured. Call dspy.configure(lm=...) first.")
118
+
119
+ # Call LM directly - streamify() will intercept this call if streaming is enabled
120
+ response = lm(messages=messages, **kwargs)
121
+
122
+ # Extract response text from LM result
123
+ # LM returns a list of strings - take the first one
124
+ response_text = response[0] if isinstance(response, list) else str(response)
125
+
126
+ # Build prediction result based on signature
127
+ prediction_kwargs = {"response": response_text}
128
+
129
+ # If signature includes tool_calls, add a placeholder
130
+ # (Real tool call parsing would happen here in a full implementation)
131
+ if "tool_calls" in self.output_fields:
132
+ prediction_kwargs["tool_calls"] = "No tools were used."
133
+
134
+ # Return as Prediction for DSPy compatibility
135
+ return dspy.Prediction(**prediction_kwargs)
136
+
14
137
 
15
138
  class TactusModule:
16
139
  """
@@ -100,6 +223,18 @@ class TactusModule:
100
223
  return dspy.Predict(self.signature, **self.kwargs)
101
224
  elif self.strategy == "chain_of_thought":
102
225
  return dspy.ChainOfThought(self.signature, **self.kwargs)
226
+ elif self.strategy == "raw":
227
+ # Raw module for minimal formatting
228
+ # Pass the signature so RawModule can support tool_calls when needed
229
+ if isinstance(self.signature, str):
230
+ signature_str = self.signature
231
+ else:
232
+ # It's a DSPy Signature object - reconstruct the signature string
233
+ # from input_fields and output_fields
234
+ input_names = list(self.signature.input_fields.keys())
235
+ output_names = list(self.signature.output_fields.keys())
236
+ signature_str = f"{', '.join(input_names)} -> {', '.join(output_names)}"
237
+ return RawModule(signature=signature_str, system_prompt="")
103
238
  elif self.strategy == "react":
104
239
  # ReAct requires tools - will be implemented in Step 5.1
105
240
  raise NotImplementedError("ReAct strategy not yet implemented. Coming in Step 5.1.")
@@ -110,7 +245,7 @@ class TactusModule:
110
245
  )
111
246
  else:
112
247
  raise ValueError(
113
- f"Unknown strategy '{self.strategy}'. Supported: predict, chain_of_thought"
248
+ f"Unknown strategy '{self.strategy}'. Supported: predict, chain_of_thought, raw"
114
249
  )
115
250
 
116
251
  def __call__(self, **kwargs: Any) -> dspy.Prediction:
tactus/ide/server.py CHANGED
@@ -2070,73 +2070,85 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
2070
2070
  @app.route("/api/chat/stream", methods=["POST"])
2071
2071
  def chat_stream():
2072
2072
  """
2073
- Stream chat responses with SSE.
2073
+ Stream chat responses with SSE using our working implementation.
2074
2074
 
2075
2075
  Request body:
2076
+ - workspace_root: Workspace path
2076
2077
  - message: User's message
2078
+ - config: Optional config with provider, model, etc.
2077
2079
  """
2078
- data = request.json or {}
2079
- message = data.get("message")
2080
+ try:
2081
+ import sys
2082
+ import os
2083
+ import uuid
2084
+ import asyncio
2085
+
2086
+ # Add backend directory to path so we can import our modules
2087
+ backend_dir = os.path.join(
2088
+ os.path.dirname(__file__), "..", "..", "tactus-ide", "backend"
2089
+ )
2090
+ if backend_dir not in sys.path:
2091
+ sys.path.insert(0, backend_dir)
2080
2092
 
2081
- if not message:
2082
- return jsonify({"error": "Missing 'message' parameter"}), 400
2093
+ from assistant_service import AssistantService
2083
2094
 
2084
- if not WORKSPACE_ROOT:
2085
- return jsonify({"error": "No workspace folder selected"}), 400
2095
+ data = request.json or {}
2096
+ workspace_root = data.get("workspace_root") or WORKSPACE_ROOT
2097
+ user_message = data.get("message")
2098
+ config = data.get(
2099
+ "config",
2100
+ {"provider": "openai", "model": "gpt-4o", "temperature": 0.7, "max_tokens": 4000},
2101
+ )
2086
2102
 
2087
- def generate_events():
2088
- """Generator function that yields SSE chat events."""
2089
- try:
2090
- import json
2091
- from datetime import datetime
2103
+ if not workspace_root or not user_message:
2104
+ return jsonify({"error": "workspace_root and message required"}), 400
2092
2105
 
2093
- # Get or create assistant
2094
- assistant = get_or_create_assistant()
2106
+ # Create service instance
2107
+ conversation_id = str(uuid.uuid4())
2108
+ service = AssistantService(workspace_root, config)
2095
2109
 
2096
- # Send start event
2097
- start_event = {
2098
- "event_type": "chat_start",
2099
- "timestamp": datetime.utcnow().isoformat() + "Z",
2100
- }
2101
- yield f"data: {json.dumps(start_event)}\n\n"
2110
+ def generate():
2111
+ """Generator function that yields SSE events."""
2112
+ loop = asyncio.new_event_loop()
2113
+ asyncio.set_event_loop(loop)
2102
2114
 
2103
- # Process message
2104
- result = assistant.process_message(message)
2115
+ try:
2116
+ # Start conversation (configures DSPy LM internally)
2117
+ loop.run_until_complete(service.start_conversation(conversation_id))
2105
2118
 
2106
- # Send response event
2107
- response_event = {
2108
- "event_type": "chat_response",
2109
- "timestamp": datetime.utcnow().isoformat() + "Z",
2110
- "response": result["response"],
2111
- "tool_calls": result.get("tool_calls", []),
2112
- }
2113
- yield f"data: {json.dumps(response_event)}\n\n"
2119
+ # Send immediate thinking indicator
2120
+ yield f"data: {json.dumps({'type': 'thinking', 'content': 'Processing your request...'})}\n\n"
2114
2121
 
2115
- # Send completion event
2116
- complete_event = {
2117
- "event_type": "chat_complete",
2118
- "timestamp": datetime.utcnow().isoformat() + "Z",
2119
- }
2120
- yield f"data: {json.dumps(complete_event)}\n\n"
2122
+ # Create async generator
2123
+ async_gen = service.send_message(user_message)
2121
2124
 
2122
- except Exception as e:
2123
- logger.error(f"Error in chat streaming: {e}", exc_info=True)
2124
- error_event = {
2125
- "event_type": "chat_error",
2126
- "timestamp": datetime.utcnow().isoformat() + "Z",
2127
- "error": str(e),
2128
- }
2129
- yield f"data: {json.dumps(error_event)}\n\n"
2130
-
2131
- return Response(
2132
- stream_with_context(generate_events()),
2133
- mimetype="text/event-stream",
2134
- headers={
2135
- "Cache-Control": "no-cache",
2136
- "X-Accel-Buffering": "no",
2137
- "Connection": "keep-alive",
2138
- },
2139
- )
2125
+ # Consume events one at a time and yield immediately
2126
+ while True:
2127
+ try:
2128
+ event = loop.run_until_complete(async_gen.__anext__())
2129
+ yield f"data: {json.dumps(event)}\n\n"
2130
+ except StopAsyncIteration:
2131
+ break
2132
+
2133
+ except Exception as e:
2134
+ logger.error(f"Error streaming message: {e}", exc_info=True)
2135
+ yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
2136
+ finally:
2137
+ loop.close()
2138
+
2139
+ return Response(
2140
+ stream_with_context(generate()),
2141
+ mimetype="text/event-stream",
2142
+ headers={
2143
+ "Cache-Control": "no-cache",
2144
+ "X-Accel-Buffering": "no",
2145
+ "Connection": "keep-alive",
2146
+ },
2147
+ )
2148
+
2149
+ except Exception as e:
2150
+ logger.error(f"Error in stream endpoint: {e}", exc_info=True)
2151
+ return jsonify({"error": str(e)}), 500
2140
2152
 
2141
2153
  @app.route("/api/chat/reset", methods=["POST"])
2142
2154
  def chat_reset():
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional
17
17
 
18
18
  from .config import SandboxConfig
19
+ from .docker_manager import DockerManager, calculate_source_hash
19
20
  from .protocol import (
20
21
  ExecutionRequest,
21
22
  ExecutionResult,
@@ -80,6 +81,76 @@ class ContainerRunner:
80
81
  """
81
82
  self.config = config
82
83
 
84
+ # Parse image name and tag from config.image (e.g., "tactus-sandbox:local")
85
+ image_parts = config.image.split(":")
86
+ image_name = image_parts[0] if len(image_parts) > 0 else "tactus-sandbox"
87
+ image_tag = image_parts[1] if len(image_parts) > 1 else "local"
88
+
89
+ self.docker_manager = DockerManager(
90
+ image_name=image_name,
91
+ image_tag=image_tag,
92
+ )
93
+
94
+ def _ensure_sandbox_up_to_date(self, skip_for_ide: bool = False) -> None:
95
+ """
96
+ Automatically rebuild sandbox if code has changed.
97
+
98
+ This enables fast, automatic rebuilds during development without
99
+ requiring manual `tactus sandbox rebuild` commands. Uses source
100
+ hash for change detection with Docker layer caching for speed.
101
+
102
+ Can be disabled by setting TACTUS_AUTO_REBUILD_SANDBOX=false or
103
+ when running from IDE (to avoid blocking UI).
104
+
105
+ Args:
106
+ skip_for_ide: If True, skip rebuild (used when called from IDE)
107
+
108
+ Raises:
109
+ RuntimeError: If rebuild is needed but fails.
110
+ """
111
+ # Skip auto-rebuild in IDE to avoid blocking UI
112
+ if skip_for_ide:
113
+ logger.debug("Auto-rebuild skipped for IDE execution")
114
+ return
115
+
116
+ # Check if auto-rebuild is disabled
117
+ auto_rebuild = os.environ.get("TACTUS_AUTO_REBUILD_SANDBOX", "true").lower()
118
+ if auto_rebuild not in ("true", "1", "yes"):
119
+ logger.debug("Auto-rebuild disabled via TACTUS_AUTO_REBUILD_SANDBOX")
120
+ return
121
+
122
+ # Get current version and source hash
123
+ from tactus import __version__
124
+
125
+ # Calculate tactus root from this file's location
126
+ # container_runner.py is in tactus/sandbox/, so root is 2 levels up
127
+ tactus_root = Path(__file__).parent.parent.parent
128
+
129
+ current_hash = calculate_source_hash(tactus_root)
130
+
131
+ # Check if rebuild is needed
132
+ if self.docker_manager.needs_rebuild(__version__, current_hash):
133
+ logger.info("Code changes detected, rebuilding sandbox...")
134
+
135
+ # Get paths
136
+ dockerfile_path = tactus_root / "tactus" / "docker" / "Dockerfile"
137
+
138
+ # Build with source hash
139
+ success, msg = self.docker_manager.build_image(
140
+ dockerfile_path=dockerfile_path,
141
+ context_path=tactus_root,
142
+ version=__version__,
143
+ source_hash=current_hash,
144
+ verbose=False,
145
+ )
146
+
147
+ if not success:
148
+ raise RuntimeError(f"Failed to rebuild sandbox: {msg}")
149
+
150
+ logger.info("Sandbox rebuilt successfully")
151
+ else:
152
+ logger.debug("Sandbox is up to date")
153
+
83
154
  def _build_docker_command(
84
155
  self,
85
156
  working_dir: Path,
@@ -203,6 +274,11 @@ class ContainerRunner:
203
274
  Returns:
204
275
  ExecutionResult with status, result/error, and metadata.
205
276
  """
277
+ # Ensure sandbox is up to date (auto-rebuild if code changed)
278
+ # Skip for IDE to avoid blocking UI - IDE has its own rebuild mechanism
279
+ skip_rebuild_for_ide = callback_url is not None
280
+ self._ensure_sandbox_up_to_date(skip_for_ide=skip_rebuild_for_ide)
281
+
206
282
  execution_id = str(uuid.uuid4())[:8]
207
283
  start_time = time.time()
208
284
 
@@ -4,6 +4,7 @@ Docker management utilities for sandbox execution.
4
4
  Handles Docker availability detection, image building, and version management.
5
5
  """
6
6
 
7
+ import hashlib
7
8
  import logging
8
9
  import shutil
9
10
  import subprocess
@@ -17,6 +18,49 @@ DEFAULT_IMAGE_NAME = "tactus-sandbox"
17
18
  DEFAULT_IMAGE_TAG = "local"
18
19
 
19
20
 
21
+ def calculate_source_hash(tactus_root: Path) -> str:
22
+ """
23
+ Calculate hash of Tactus source files for change detection.
24
+
25
+ This enables fast, automatic rebuilds when code changes without
26
+ requiring manual version bumps or rebuild commands.
27
+
28
+ Args:
29
+ tactus_root: Root directory of the Tactus package
30
+
31
+ Returns:
32
+ Short hash (16 chars) representing the current state of source code
33
+ """
34
+ # Key paths that affect sandbox behavior
35
+ paths_to_hash = [
36
+ tactus_root / "tactus" / "dspy",
37
+ tactus_root / "tactus" / "core",
38
+ tactus_root / "tactus" / "primitives",
39
+ tactus_root / "tactus" / "sandbox",
40
+ tactus_root / "pyproject.toml", # Dependencies affect sandbox
41
+ ]
42
+
43
+ hasher = hashlib.sha256()
44
+
45
+ for path in sorted(paths_to_hash):
46
+ if not path.exists():
47
+ continue
48
+
49
+ if path.is_file():
50
+ # Hash file contents
51
+ hasher.update(path.read_bytes())
52
+ elif path.is_dir():
53
+ # Hash all Python files in directory (recursively)
54
+ for py_file in sorted(path.rglob("*.py")):
55
+ # Hash relative path + contents for reproducibility
56
+ rel_path = str(py_file.relative_to(tactus_root))
57
+ hasher.update(rel_path.encode())
58
+ hasher.update(py_file.read_bytes())
59
+
60
+ # Return short hash (16 chars is plenty for collision avoidance)
61
+ return hasher.hexdigest()[:16]
62
+
63
+
20
64
  def is_docker_available() -> Tuple[bool, str]:
21
65
  """
22
66
  Check if Docker is available and running.
@@ -123,12 +167,45 @@ class DockerManager:
123
167
  except Exception:
124
168
  return None
125
169
 
126
- def needs_rebuild(self, current_version: str) -> bool:
170
+ def get_image_source_hash(self) -> Optional[str]:
171
+ """
172
+ Get the source hash label from the existing image.
173
+
174
+ Returns:
175
+ Source hash string if found, None otherwise.
176
+ """
177
+ try:
178
+ result = subprocess.run(
179
+ [
180
+ "docker",
181
+ "image",
182
+ "inspect",
183
+ "--format",
184
+ '{{index .Config.Labels "tactus.source_hash"}}',
185
+ self.full_image_name,
186
+ ],
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=10,
190
+ )
191
+ if result.returncode == 0 and result.stdout.strip():
192
+ return result.stdout.strip()
193
+ return None
194
+ except Exception:
195
+ return None
196
+
197
+ def needs_rebuild(self, current_version: str, current_hash: Optional[str] = None) -> bool:
127
198
  """
128
199
  Check if the image needs to be rebuilt.
129
200
 
201
+ Checks both version and source hash (if provided) to determine
202
+ if a rebuild is necessary. This enables automatic rebuilds when
203
+ code changes without requiring manual version bumps.
204
+
130
205
  Args:
131
206
  current_version: Current Tactus version.
207
+ current_hash: Optional source hash of current code. If provided,
208
+ will trigger rebuild when hash doesn't match.
132
209
 
133
210
  Returns:
134
211
  True if image should be rebuilt.
@@ -136,17 +213,34 @@ class DockerManager:
136
213
  if not self.image_exists():
137
214
  return True
138
215
 
216
+ # Check version mismatch
139
217
  image_version = self.get_image_version()
140
218
  if image_version is None:
141
219
  return True
142
220
 
143
- return image_version != current_version
221
+ if image_version != current_version:
222
+ return True
223
+
224
+ # Check source hash mismatch (if hash checking is enabled)
225
+ if current_hash is not None:
226
+ image_hash = self.get_image_source_hash()
227
+ if image_hash is None:
228
+ # Old image without hash label - rebuild to add it
229
+ logger.debug("Image missing source hash label, rebuild needed")
230
+ return True
231
+
232
+ if image_hash != current_hash:
233
+ logger.debug(f"Source hash mismatch: {image_hash} != {current_hash}")
234
+ return True
235
+
236
+ return False
144
237
 
145
238
  def build_image(
146
239
  self,
147
240
  dockerfile_path: Path,
148
241
  context_path: Path,
149
242
  version: str,
243
+ source_hash: Optional[str] = None,
150
244
  verbose: bool = False,
151
245
  ) -> Tuple[bool, str]:
152
246
  """
@@ -156,6 +250,7 @@ class DockerManager:
156
250
  dockerfile_path: Path to the Dockerfile
157
251
  context_path: Build context path (usually the Tactus package root)
158
252
  version: Tactus version to label the image with
253
+ source_hash: Optional source hash to label the image with for change detection
159
254
  verbose: If True, stream build output
160
255
 
161
256
  Returns:
@@ -178,9 +273,14 @@ class DockerManager:
178
273
  str(dockerfile_path),
179
274
  "--label",
180
275
  f"tactus.version={version}",
181
- str(context_path),
182
276
  ]
183
277
 
278
+ # Add source hash label if provided
279
+ if source_hash:
280
+ cmd.extend(["--label", f"tactus.source_hash={source_hash}"])
281
+
282
+ cmd.append(str(context_path))
283
+
184
284
  try:
185
285
  if verbose:
186
286
  # Stream output in real-time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tactus
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Tactus: Lua-based DSL for agentic workflows
5
5
  Project-URL: Homepage, https://github.com/AnthusAI/Tactus
6
6
  Project-URL: Documentation, https://github.com/AnthusAI/Tactus/tree/main/docs
@@ -1,4 +1,4 @@
1
- tactus/__init__.py,sha256=JgkDSchEs5m2RE9qP_hqbjCJG2E5vaV5f2TCj-Z9nrw,1245
1
+ tactus/__init__.py,sha256=bYU1q6ssy4qyaU4ywptrmHurU9Y5gaCei8LiigteA2I,1245
2
2
  tactus/adapters/__init__.py,sha256=lU8uUxuryFRIpVrn_KeVK7aUhsvOT1tYsuE3FOOIFpI,289
3
3
  tactus/adapters/cli_hitl.py,sha256=l8jKU3y99g8z2vS11td0JXLVG77SF01nO-Ss4pRFXO0,6962
4
4
  tactus/adapters/cli_log.py,sha256=JKD693goi_wT_Kei4mTc2KJ-0QfgFZTpV3Prb8zfNZo,9779
@@ -34,15 +34,15 @@ tactus/core/dependencies/registry.py,sha256=bgRdqJJTUrnQlu0wvjv2In1EPq7prZq-b9eB
34
34
  tactus/docker/Dockerfile,sha256=Lo1erljpHSw9O-uOj0mn47gtg6abqzBvhbChAb-GQhI,1720
35
35
  tactus/docker/entrypoint.sh,sha256=-qYq5RQIGS6KirJ6NO0XiHZf_oAdfmk5HzHOBsiqxRM,1870
36
36
  tactus/dspy/__init__.py,sha256=beUkvMUFdPvZE9-bEOfRo2TH-FoCvPT_L9_dpJPW324,1226
37
- tactus/dspy/agent.py,sha256=0NmggxO25wArROKPSx0GKFsMTqCi3sIRTcP_Pvm_2-c,37560
38
- tactus/dspy/config.py,sha256=r6XfEkXg1NZWTXDuWCT1DG7BRv4mvty1T-VqgjkKXLk,3833
37
+ tactus/dspy/agent.py,sha256=lWb71_EyM_VrgFE4XQ3LPW3Sl5rIir0ndAZHZoh23U4,37671
38
+ tactus/dspy/config.py,sha256=oXwYzfVCTBHRnbH_vvm591FhE0zKep7wgmujLSXKf_c,6013
39
39
  tactus/dspy/history.py,sha256=0yGi3P5ruRUPoRyaCWsUDeuEYYsfproc_7pMVZuhmUo,5980
40
- tactus/dspy/module.py,sha256=73lD10t6StsIGLDidaMraIygicrSV50UuaeOfMHBz2k,9734
40
+ tactus/dspy/module.py,sha256=sJdFS-5A4SpuiMLjbwiZJCvg3pTtEx8x8MRVaqjCQ2I,15423
41
41
  tactus/dspy/prediction.py,sha256=AFtkmKQafOcA4Pzdty0dvZ62lmfS3wgIj4Bc3Awhb2c,7228
42
42
  tactus/dspy/signature.py,sha256=jdLHBa5BOEBwXTfmLui6fjViEDQDhdUzQm2__STHquU,6053
43
43
  tactus/ide/__init__.py,sha256=1fSC0xWP-Lq5wl4FgDq7SMnkvZ0DxXupreTl3ZRX1zw,143
44
44
  tactus/ide/coding_assistant.py,sha256=GgmspWIn9IPgBK0ZYapeISIOrcDfRyK7yyPDPV85r8g,12184
45
- tactus/ide/server.py,sha256=uguiIXsR8bzYTj9Ek257uXF2akmeR5pUmct6-RfgBwo,96846
45
+ tactus/ide/server.py,sha256=Wp2-lpbYp8u-xPDUjGVNX08Rvgx-9umwxrVlWfAce0c,97575
46
46
  tactus/primitives/__init__.py,sha256=2NHEGBCuasVJlD5bHE6OSYivZ3WEp90DJDTvoKehVzg,1712
47
47
  tactus/primitives/control.py,sha256=PjRt_Pegcj2L1Uy-IUBQKTYFRMXy7b9q1z2kzJNH8qw,4683
48
48
  tactus/primitives/file.py,sha256=-kz0RCst_i_3V860-LtGntYpE0Mm371U_KGHqELbMx0,7186
@@ -79,8 +79,8 @@ tactus/providers/google.py,sha256=wgZ3eiQif1rq1T8BK5V2kL_QVCmqBQZuWLz37y9cxOQ,31
79
79
  tactus/providers/openai.py,sha256=3qSXfdELTHdU7vuRSxQrtnfNctt0HhrePOLFj3YlViA,2692
80
80
  tactus/sandbox/__init__.py,sha256=UCBvPD63szvSdwSzpznLW-cnJOgGkVHiKcmJtsAmnuA,1424
81
81
  tactus/sandbox/config.py,sha256=zbv2ewzwN381HuJRaPnDgzdbZlxNIUYIWAbj9udYf0A,3713
82
- tactus/sandbox/container_runner.py,sha256=WW4zEQHMipVFVY9TwGbkS7wrGhadBcmhdjXPJRK5NIY,13122
83
- tactus/sandbox/docker_manager.py,sha256=B7ZA7UrPZe-C4fkwHyQwnT-WtEecdhZ-eYLLqGRw5-o,9972
82
+ tactus/sandbox/container_runner.py,sha256=GUY460O8oTREzpc7NWUHcCqSrLapovDT0yWpA0hRV9Q,16161
83
+ tactus/sandbox/docker_manager.py,sha256=fqSlLpanL4zdgj0NopCc0Du-Ac1TlbgswQJ5N3CNfrs,13535
84
84
  tactus/sandbox/entrypoint.py,sha256=wqfHP3EKVEJJBJuAR859xQu9UvP4oU8qENCMxwV0yi8,5122
85
85
  tactus/sandbox/protocol.py,sha256=hk8yYj6hDmGA32bet4AJ3d3CKiFLMBGoJnc1OrD3nLc,6397
86
86
  tactus/stdlib/__init__.py,sha256=26AUJ2IGp6OuhWaUeGSRwTa1r7W4e6-fhoLa4u7RjLQ,288
@@ -142,8 +142,8 @@ tactus/validation/generated/LuaParserVisitor.py,sha256=ageKSmHPxnO3jBS2fBtkmYBOd
142
142
  tactus/validation/generated/__init__.py,sha256=5gWlwRI0UvmHw2fnBpj_IG6N8oZeabr5tbj1AODDvjc,196
143
143
  tactus/validation/grammar/LuaLexer.g4,sha256=t2MXiTCr127RWAyQGvamkcU_m4veqPzSuHUtAKwalw4,2771
144
144
  tactus/validation/grammar/LuaParser.g4,sha256=ceZenb90BdiZmVdOxMGj9qJk3QbbWVZe5HUqPgoePfY,3202
145
- tactus-0.22.0.dist-info/METADATA,sha256=Ag0B4BYOcgBP9AxI_w4uOz7zT-Sn-FycdbuhWdtkw3E,55250
146
- tactus-0.22.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
147
- tactus-0.22.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
148
- tactus-0.22.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
149
- tactus-0.22.0.dist-info/RECORD,,
145
+ tactus-0.23.0.dist-info/METADATA,sha256=Kvj_FpO-1s2v2VwKCLr8OKvaKO51OO4SRGe5KWxhLUI,55250
146
+ tactus-0.23.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
147
+ tactus-0.23.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
148
+ tactus-0.23.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
149
+ tactus-0.23.0.dist-info/RECORD,,