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 +1 -1
- tactus/dspy/agent.py +2 -0
- tactus/dspy/config.py +66 -0
- tactus/dspy/module.py +136 -1
- tactus/ide/server.py +66 -54
- tactus/sandbox/container_runner.py +76 -0
- tactus/sandbox/docker_manager.py +103 -3
- {tactus-0.22.0.dist-info → tactus-0.23.0.dist-info}/METADATA +1 -1
- {tactus-0.22.0.dist-info → tactus-0.23.0.dist-info}/RECORD +12 -12
- {tactus-0.22.0.dist-info → tactus-0.23.0.dist-info}/WHEEL +0 -0
- {tactus-0.22.0.dist-info → tactus-0.23.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.22.0.dist-info → tactus-0.23.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
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
|
-
|
|
2079
|
-
|
|
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
|
-
|
|
2082
|
-
return jsonify({"error": "Missing 'message' parameter"}), 400
|
|
2093
|
+
from assistant_service import AssistantService
|
|
2083
2094
|
|
|
2084
|
-
|
|
2085
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
2094
|
-
|
|
2106
|
+
# Create service instance
|
|
2107
|
+
conversation_id = str(uuid.uuid4())
|
|
2108
|
+
service = AssistantService(workspace_root, config)
|
|
2095
2109
|
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
2104
|
-
|
|
2115
|
+
try:
|
|
2116
|
+
# Start conversation (configures DSPy LM internally)
|
|
2117
|
+
loop.run_until_complete(service.start_conversation(conversation_id))
|
|
2105
2118
|
|
|
2106
|
-
|
|
2107
|
-
|
|
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
|
-
|
|
2116
|
-
|
|
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
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
|
tactus/sandbox/docker_manager.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
tactus/__init__.py,sha256=
|
|
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=
|
|
38
|
-
tactus/dspy/config.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
83
|
-
tactus/sandbox/docker_manager.py,sha256=
|
|
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.
|
|
146
|
-
tactus-0.
|
|
147
|
-
tactus-0.
|
|
148
|
-
tactus-0.
|
|
149
|
-
tactus-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|