tactus 0.32.2__py3-none-any.whl → 0.34.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 (100) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +382 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
tactus/dspy/config.py CHANGED
@@ -9,7 +9,6 @@ from typing import Optional, Any
9
9
 
10
10
  import dspy
11
11
 
12
-
13
12
  # Global reference to the current LM configuration
14
13
  _current_lm: Optional[dspy.BaseLM] = None
15
14
 
@@ -98,8 +97,20 @@ def configure_lm(
98
97
  # Create and configure the standard DSPy LM (LiteLLM-backed)
99
98
  lm = dspy.LM(model, **lm_kwargs)
100
99
 
101
- # Set as global default
102
- dspy.configure(lm=lm)
100
+ # Create adapter with native function calling enabled
101
+ from dspy.adapters.chat_adapter import ChatAdapter
102
+ import logging
103
+
104
+ logger = logging.getLogger(__name__)
105
+
106
+ adapter = ChatAdapter(use_native_function_calling=True)
107
+ logger.info(
108
+ f"[ADAPTER] Created ChatAdapter with use_native_function_calling={adapter.use_native_function_calling}"
109
+ )
110
+
111
+ # Set as global default with adapter
112
+ dspy.configure(lm=lm, adapter=adapter)
113
+ logger.info(f"[ADAPTER] Configured DSPy with adapter: {adapter}")
103
114
  _current_lm = lm
104
115
 
105
116
  return lm
tactus/dspy/history.py CHANGED
@@ -79,7 +79,8 @@ class TactusHistory:
79
79
  raise ValueError("Message must include 'content' key")
80
80
 
81
81
  # Validate role
82
- valid_roles = ["system", "user", "assistant"]
82
+ # Note: "tool" role is required for OpenAI function calling responses
83
+ valid_roles = ["system", "user", "assistant", "tool"]
83
84
  if message["role"] not in valid_roles:
84
85
  raise ValueError(f"Invalid role. Must be one of {valid_roles}")
85
86
 
tactus/dspy/module.py CHANGED
@@ -64,7 +64,13 @@ class RawModule(dspy.Module):
64
64
  return [field.strip() for field in output_part.split(",")]
65
65
 
66
66
  def forward(
67
- self, system_prompt: str, history, user_message: str, available_tools: str = "", **kwargs
67
+ self,
68
+ system_prompt: str,
69
+ history,
70
+ user_message: str,
71
+ available_tools: str = "",
72
+ tools=None,
73
+ **kwargs,
68
74
  ):
69
75
  """
70
76
  Forward pass with direct LM call (no formatting delimiters).
@@ -73,7 +79,8 @@ class RawModule(dspy.Module):
73
79
  system_prompt: System prompt (overrides init if provided)
74
80
  history: Conversation history (dspy.History, TactusHistory, or string)
75
81
  user_message: Current user message
76
- available_tools: Optional tools description (for agents with tools)
82
+ available_tools: Optional tools description (for agents with tools) - legacy, prefer tools param
83
+ tools: Optional list of dspy.Tool objects for native function calling
77
84
  **kwargs: Additional args passed to LM
78
85
 
79
86
  Returns:
@@ -92,8 +99,52 @@ class RawModule(dspy.Module):
92
99
  # Add history messages
93
100
  if history:
94
101
  if hasattr(history, "messages"):
95
- # It's a History object - use messages directly
96
- messages.extend(history.messages)
102
+ # It's a History object - sanitize messages to ensure JSON serializability
103
+ for msg in history.messages:
104
+ logger.debug(f"[RAWMODULE] Sanitizing history message: role={msg.get('role')}")
105
+ sanitized_msg = {"role": msg.get("role"), "content": msg.get("content")}
106
+
107
+ # If message is a tool result, preserve tool_call_id and name
108
+ if msg.get("role") == "tool":
109
+ if "tool_call_id" in msg:
110
+ sanitized_msg["tool_call_id"] = msg["tool_call_id"]
111
+ logger.debug("[RAWMODULE] Preserved tool_call_id for tool message")
112
+ if "name" in msg:
113
+ sanitized_msg["name"] = msg["name"]
114
+
115
+ # If message has tool_calls, ensure they're plain dicts
116
+ if "tool_calls" in msg:
117
+ tool_calls = msg["tool_calls"]
118
+ # Convert any non-dict tool calls to dicts
119
+ if tool_calls and not isinstance(tool_calls, list):
120
+ tool_calls = [tool_calls]
121
+ if tool_calls:
122
+ sanitized_tool_calls = []
123
+ for tc in tool_calls:
124
+ if isinstance(tc, dict):
125
+ sanitized_tool_calls.append(tc)
126
+ else:
127
+ # It's a typed object - convert to dict
128
+ tc_dict = {
129
+ "id": getattr(tc, "id", ""),
130
+ "type": getattr(tc, "type", "function"),
131
+ "function": {
132
+ "name": (
133
+ getattr(tc.function, "name", "")
134
+ if hasattr(tc, "function")
135
+ else ""
136
+ ),
137
+ "arguments": (
138
+ getattr(tc.function, "arguments", "{}")
139
+ if hasattr(tc, "function")
140
+ else "{}"
141
+ ),
142
+ },
143
+ }
144
+ logger.debug("[RAWMODULE] Converted typed tool call to dict")
145
+ sanitized_tool_calls.append(tc_dict)
146
+ sanitized_msg["tool_calls"] = sanitized_tool_calls
147
+ messages.append(sanitized_msg)
97
148
  elif isinstance(history, str) and history.strip():
98
149
  # It's a formatted string - parse it
99
150
  for line in history.strip().split("\n"):
@@ -104,7 +155,7 @@ class RawModule(dspy.Module):
104
155
 
105
156
  # Add current user message
106
157
  if user_message:
107
- # If tools are available, include them in the user message
158
+ # If tools are available (legacy string format), include them in the user message
108
159
  if available_tools and "available_tools" in self.signature:
109
160
  user_content = f"{user_message}\n\nAvailable tools:\n{available_tools}"
110
161
  messages.append({"role": "user", "content": user_content})
@@ -116,20 +167,94 @@ class RawModule(dspy.Module):
116
167
  if lm is None:
117
168
  raise RuntimeError("No LM configured. Call dspy.configure(lm=...) first.")
118
169
 
170
+ # Convert DSPy Tool objects to LiteLLM format for native function calling
171
+ if tools and isinstance(tools, list) and len(tools) > 0:
172
+ litellm_tools = []
173
+ for tool in tools:
174
+ if hasattr(tool, "format_as_litellm_function_call"):
175
+ litellm_tools.append(tool.format_as_litellm_function_call())
176
+ if litellm_tools:
177
+ kwargs["tools"] = litellm_tools
178
+ # Ensure tool_choice is passed if set on the LM
179
+ if (
180
+ hasattr(lm, "kwargs")
181
+ and "tool_choice" in lm.kwargs
182
+ and "tool_choice" not in kwargs
183
+ ):
184
+ kwargs["tool_choice"] = lm.kwargs["tool_choice"]
185
+ logger.debug(
186
+ f"[RAWMODULE] Passing {len(litellm_tools)} tools to LM with tool_choice={kwargs.get('tool_choice')}"
187
+ )
188
+
189
+ # Log summary of messages being sent
190
+ logger.debug(f"[RAWMODULE] Sending {len(messages)} messages to LM")
191
+
119
192
  # Call LM directly - streamify() will intercept this call if streaming is enabled
120
193
  response = lm(messages=messages, **kwargs)
121
194
 
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)
195
+ # Extract response text and tool calls from LM result
196
+ # LM returns either:
197
+ # - list of strings (when no tool calls): ["response text"]
198
+ # - list of dicts (when tool calls present): [{"text": "...", "tool_calls": [...]}]
199
+ response_text = ""
200
+ tool_calls_from_lm = None
201
+
202
+ if isinstance(response, list) and len(response) > 0:
203
+ first_output = response[0]
204
+ if isinstance(first_output, dict):
205
+ # Response is a dict with text and possibly tool_calls
206
+ response_text = first_output.get("text", "")
207
+ tool_calls_from_lm = first_output.get("tool_calls")
208
+ logger.debug(
209
+ f"[RAWMODULE] Extracted response with {len(tool_calls_from_lm) if tool_calls_from_lm else 0} tool calls"
210
+ )
211
+ else:
212
+ # Response is a plain string
213
+ response_text = str(first_output)
214
+ else:
215
+ response_text = str(response)
125
216
 
126
217
  # Build prediction result based on signature
127
218
  prediction_kwargs = {"response": response_text}
128
219
 
129
- # If signature includes tool_calls, add a placeholder
130
- # (Real tool call parsing would happen here in a full implementation)
220
+ # If signature includes tool_calls, use the tool_calls we extracted from the LM response
131
221
  if "tool_calls" in self.output_fields:
132
- prediction_kwargs["tool_calls"] = "No tools were used."
222
+ if tool_calls_from_lm:
223
+ # Convert to DSPy ToolCalls format
224
+ # tool_calls_from_lm is a list of ChatCompletionMessageToolCall objects from LiteLLM
225
+ from dspy.adapters.types.tool import ToolCalls
226
+ import json
227
+
228
+ tool_calls_list = []
229
+ for tc in tool_calls_from_lm:
230
+ # Handle both dict and object access patterns
231
+ func_name = (
232
+ tc.get("function", {}).get("name")
233
+ if isinstance(tc, dict)
234
+ else tc.function.name
235
+ )
236
+ func_args = (
237
+ tc.get("function", {}).get("arguments")
238
+ if isinstance(tc, dict)
239
+ else tc.function.arguments
240
+ )
241
+ tool_calls_list.append(
242
+ {
243
+ "name": func_name,
244
+ "args": (
245
+ json.loads(func_args) if isinstance(func_args, str) else func_args
246
+ ),
247
+ }
248
+ )
249
+ prediction_kwargs["tool_calls"] = ToolCalls.from_dict_list(tool_calls_list)
250
+ logger.debug(
251
+ f"[RAWMODULE] Converted {len(tool_calls_list)} tool calls to DSPy format"
252
+ )
253
+ else:
254
+ # No tool calls in response
255
+ from dspy.adapters.types.tool import ToolCalls
256
+
257
+ prediction_kwargs["tool_calls"] = ToolCalls.from_dict_list([])
133
258
 
134
259
  # Return as Prediction for DSPy compatibility
135
260
  return dspy.Prediction(**prediction_kwargs)
tactus/dspy/signature.py CHANGED
@@ -9,7 +9,6 @@ from typing import Dict, Any, Optional, Union
9
9
 
10
10
  import dspy
11
11
 
12
-
13
12
  # Map Tactus types to Python types for DSPy fields
14
13
  TYPE_MAP = {
15
14
  "string": str,
tactus/ide/server.py CHANGED
@@ -770,11 +770,34 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
770
770
  tool_paths = merged_config.get("tool_paths")
771
771
  mcp_servers = merged_config.get("mcp_servers", {})
772
772
 
773
+ # Create HITL handler with SSE channel for IDE integration
774
+ from tactus.adapters.control_loop import (
775
+ ControlLoopHandler,
776
+ ControlLoopHITLAdapter,
777
+ )
778
+ from tactus.adapters.channels import load_default_channels
779
+
780
+ # Load default channels (CLI + IPC) and add SSE channel
781
+ channels = load_default_channels(procedure_id=procedure_id)
782
+ sse_channel = get_sse_channel()
783
+
784
+ # Add SSE channel to the list
785
+ channels.append(sse_channel)
786
+
787
+ # Create control loop handler with all channels
788
+ control_handler = ControlLoopHandler(
789
+ channels=channels,
790
+ storage=storage_backend,
791
+ )
792
+
793
+ # Wrap in adapter for backward compatibility
794
+ hitl_handler = ControlLoopHITLAdapter(control_handler)
795
+
773
796
  # Create runtime with log handler, run_id, and loaded config
774
797
  runtime = TactusRuntime(
775
798
  procedure_id=procedure_id,
776
799
  storage_backend=storage_backend,
777
- hitl_handler=None, # No HITL in IDE streaming mode
800
+ hitl_handler=hitl_handler, # Now includes SSE channel!
778
801
  log_handler=log_handler,
779
802
  run_id=run_id,
780
803
  source_file_path=str(path),
@@ -830,6 +853,78 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
830
853
  # Capture inputs in closure scope for the thread
831
854
  procedure_inputs = inputs
832
855
 
856
+ async def handle_container_control_request(request_data: dict) -> dict:
857
+ """
858
+ Bridge container HITL requests to host's SSE channel.
859
+
860
+ This handler is called by the broker when the container sends
861
+ a control.request. It forwards the request to the SSE channel,
862
+ waits for the user response in the IDE, and returns the response
863
+ data back to the container.
864
+ """
865
+ import threading
866
+ from tactus.protocols.control import ControlRequest
867
+
868
+ # Parse the request
869
+ request = ControlRequest.model_validate(request_data)
870
+ logger.info(
871
+ f"[HITL] Container control request {request.request_id} "
872
+ f"for procedure {request.procedure_id}"
873
+ )
874
+
875
+ # Get SSE channel
876
+ sse_channel = get_sse_channel()
877
+
878
+ # Create a threading event to wait for response
879
+ response_event = threading.Event()
880
+ response_data = {}
881
+
882
+ # Register pending request
883
+ _pending_hitl_requests[request.request_id] = {
884
+ "event": response_event,
885
+ "response": response_data,
886
+ }
887
+
888
+ try:
889
+ # Send to SSE channel (delivers to IDE UI)
890
+ delivery = await sse_channel.send(request)
891
+ if not delivery.success:
892
+ raise RuntimeError(
893
+ f"Failed to deliver HITL request to IDE: {delivery.error_message}"
894
+ )
895
+
896
+ logger.info(
897
+ f"[HITL] Request {request.request_id} delivered to IDE, waiting for response..."
898
+ )
899
+
900
+ # Wait for response (with timeout) - run blocking wait in thread pool
901
+ timeout_seconds = request.timeout_seconds or 300 # 5 min default
902
+ logger.info(
903
+ f"[HITL] Starting wait for response (timeout={timeout_seconds}s)..."
904
+ )
905
+ result = await asyncio.to_thread(
906
+ response_event.wait, timeout=timeout_seconds
907
+ )
908
+ logger.info(f"[HITL] Wait completed, result={result}")
909
+
910
+ if result:
911
+ logger.info(
912
+ f"[HITL] Received response for {request.request_id}: "
913
+ f"{response_data.get('value')}"
914
+ )
915
+ return response_data
916
+ else:
917
+ # Timeout
918
+ logger.warning(f"[HITL] Timeout for {request.request_id}")
919
+ return {
920
+ "value": request.default_value,
921
+ "timed_out": True,
922
+ "channel_id": "sse",
923
+ }
924
+ finally:
925
+ # Clean up pending request
926
+ _pending_hitl_requests.pop(request.request_id, None)
927
+
833
928
  def run_procedure():
834
929
  try:
835
930
  # Create new event loop for this thread
@@ -839,6 +934,13 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
839
934
  if use_sandbox:
840
935
  # Use sandbox execution (events streamed via broker over UDS)
841
936
  runner = ContainerRunner(sandbox_config)
937
+
938
+ # Pass async control handler directly (broker calls it in async context)
939
+ # Build LLM backend config (provider-agnostic)
940
+ llm_backend_config = {}
941
+ if openai_api_key:
942
+ llm_backend_config["openai_api_key"] = openai_api_key
943
+
842
944
  exec_result = loop.run_until_complete(
843
945
  runner.run(
844
946
  source=source,
@@ -848,6 +950,9 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
848
950
  event_handler=(
849
951
  sandbox_event_queue.put if sandbox_event_queue else None
850
952
  ),
953
+ run_id=run_id,
954
+ control_handler=handle_container_control_request,
955
+ llm_backend_config=llm_backend_config,
851
956
  )
852
957
  )
853
958
 
@@ -888,19 +993,31 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
888
993
  yield f"data: {json.dumps(container_running_event)}\n\n"
889
994
 
890
995
  # Stream events based on execution mode
996
+ # Poll aggressively to stream events in real-time
891
997
  while not result_container["done"]:
998
+ events_sent = False
999
+
892
1000
  if use_sandbox and sandbox_event_queue:
893
1001
  # Stream from sandbox callback queue
894
1002
  try:
895
- event_dict = sandbox_event_queue.get(timeout=0.1)
1003
+ event_dict = sandbox_event_queue.get(timeout=0.01)
896
1004
  all_events.append(event_dict)
897
1005
  yield f"data: {json.dumps(event_dict)}\n\n"
1006
+ events_sent = True
898
1007
  except queue.Empty:
899
1008
  pass
1009
+
1010
+ # Also check for HITL events from SSE channel (container HITL)
1011
+ hitl_event = sse_channel.get_next_event(timeout=0.001)
1012
+ if hitl_event:
1013
+ all_events.append(hitl_event)
1014
+ yield f"data: {json.dumps(hitl_event)}\n\n"
1015
+ events_sent = True
900
1016
  else:
901
1017
  # Stream from IDELogHandler (direct execution)
902
- events = log_handler.get_events(timeout=0.1)
903
- for event in events:
1018
+ # Get one event at a time to stream immediately
1019
+ try:
1020
+ event = log_handler.events.get(timeout=0.001)
904
1021
  try:
905
1022
  # Serialize with ISO format for datetime
906
1023
  event_dict = event.model_dump(mode="json")
@@ -915,22 +1032,43 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
915
1032
  event_dict["timestamp"] = iso_string
916
1033
  all_events.append(event_dict)
917
1034
  yield f"data: {json.dumps(event_dict)}\n\n"
1035
+ events_sent = True
918
1036
  except Exception as e:
919
1037
  logger.error(f"Error serializing event: {e}", exc_info=True)
920
1038
  logger.error(f"Event type: {type(event)}, Event: {event}")
1039
+ except queue.Empty:
1040
+ pass
921
1041
 
922
- time.sleep(0.05)
1042
+ # Also check for HITL events from SSE channel
1043
+ hitl_event = sse_channel.get_next_event(timeout=0.001)
1044
+ if hitl_event:
1045
+ all_events.append(hitl_event)
1046
+ yield f"data: {json.dumps(hitl_event)}\n\n"
1047
+ events_sent = True
1048
+
1049
+ # Only sleep if no events were sent to maintain responsiveness
1050
+ if not events_sent:
1051
+ time.sleep(0.01)
923
1052
 
924
1053
  # Get any remaining events
925
1054
  if use_sandbox and sandbox_event_queue:
926
- # Drain sandbox event queue
927
- while True:
1055
+ # Drain sandbox event queue with retries to catch late-arriving events
1056
+ # Agent streaming events and ExecutionSummaryEvent may still be in flight
1057
+ max_wait = 2.0 # Wait up to 2 seconds for final events
1058
+ poll_interval = 0.05 # Poll every 50ms
1059
+ elapsed = 0.0
1060
+ consecutive_empty = 0
1061
+ max_consecutive_empty = 4 # Stop after 4 empty polls (200ms of no events)
1062
+
1063
+ while elapsed < max_wait and consecutive_empty < max_consecutive_empty:
928
1064
  try:
929
- event_dict = sandbox_event_queue.get_nowait()
1065
+ event_dict = sandbox_event_queue.get(timeout=poll_interval)
930
1066
  all_events.append(event_dict)
931
1067
  yield f"data: {json.dumps(event_dict)}\n\n"
1068
+ consecutive_empty = 0 # Reset counter when we get an event
932
1069
  except queue.Empty:
933
- break
1070
+ consecutive_empty += 1
1071
+ elapsed += poll_interval
934
1072
 
935
1073
  # Emit container stopped event
936
1074
  container_stopped_event = {
@@ -1863,6 +2001,34 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1863
2001
  logger.error(f"Error getting checkpoint {run_id}@{position}: {e}", exc_info=True)
1864
2002
  return jsonify({"error": str(e)}), 500
1865
2003
 
2004
+ @app.route("/api/procedures/<procedure_id>/checkpoints", methods=["DELETE"])
2005
+ def clear_checkpoints(procedure_id: str):
2006
+ """Clear all checkpoints for a procedure to force fresh execution."""
2007
+ try:
2008
+ from pathlib import Path as PathLib
2009
+ import os
2010
+
2011
+ # Build the checkpoint file path
2012
+ storage_dir = (
2013
+ PathLib(WORKSPACE_ROOT) / ".tac" / "storage"
2014
+ if WORKSPACE_ROOT
2015
+ else PathLib.home() / ".tactus" / "storage"
2016
+ )
2017
+ checkpoint_file = storage_dir / f"{procedure_id}.json"
2018
+
2019
+ if checkpoint_file.exists():
2020
+ os.remove(checkpoint_file)
2021
+ logger.info(f"Cleared checkpoints for procedure: {procedure_id}")
2022
+ return jsonify(
2023
+ {"success": True, "message": f"Checkpoints cleared for {procedure_id}"}
2024
+ )
2025
+ else:
2026
+ return jsonify({"success": True, "message": "No checkpoints found"}), 200
2027
+
2028
+ except Exception as e:
2029
+ logger.error(f"Error clearing checkpoints for {procedure_id}: {e}", exc_info=True)
2030
+ return jsonify({"error": str(e)}), 500
2031
+
1866
2032
  @app.route("/api/traces/runs/<run_id>/statistics", methods=["GET"])
1867
2033
  def get_run_statistics(run_id: str):
1868
2034
  """Get statistics for a run by filtering checkpoints by run_id."""
@@ -2196,6 +2362,131 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
2196
2362
  logger.warning(f"Could not register config routes: {e}")
2197
2363
 
2198
2364
  # Serve frontend if dist directory is provided
2365
+ # =========================================================================
2366
+ # HITL (Human-in-the-Loop) Control Channel Endpoints
2367
+ # =========================================================================
2368
+
2369
+ # Global SSE channel instance (shared across requests)
2370
+ _sse_channel = None
2371
+ # Pending HITL requests (for container control handler)
2372
+ _pending_hitl_requests: dict[str, dict] = {}
2373
+
2374
+ def get_sse_channel():
2375
+ """Get or create the global SSE channel instance."""
2376
+ nonlocal _sse_channel
2377
+ if _sse_channel is None:
2378
+ from tactus.adapters.channels.sse import SSEControlChannel
2379
+
2380
+ _sse_channel = SSEControlChannel()
2381
+ return _sse_channel
2382
+
2383
+ @app.route("/api/hitl/response/<request_id>", methods=["POST"])
2384
+ def hitl_response(request_id: str):
2385
+ """
2386
+ Handle HITL response from IDE.
2387
+
2388
+ Called when user responds to a HITL request in the IDE UI.
2389
+ Pushes response to SSEControlChannel which forwards to control loop.
2390
+
2391
+ Request body:
2392
+ - value: The response value (boolean, string, dict, etc.)
2393
+ """
2394
+ try:
2395
+ data = request.json or {}
2396
+ value = data.get("value")
2397
+
2398
+ logger.info(f"Received HITL response for {request_id}: {value}")
2399
+
2400
+ # Check if this is a container HITL request (pending in our dict)
2401
+ if request_id in _pending_hitl_requests:
2402
+ pending = _pending_hitl_requests[request_id]
2403
+ pending["response"]["value"] = value
2404
+ pending["response"]["timed_out"] = False
2405
+ pending["response"]["channel_id"] = "sse"
2406
+ pending["event"].set() # Signal the waiting thread
2407
+ logger.info(f"[HITL] Signaled container handler for {request_id}")
2408
+ else:
2409
+ # Push to SSE channel's response queue (for non-container HITL)
2410
+ channel = get_sse_channel()
2411
+ channel.handle_ide_response(request_id, value)
2412
+
2413
+ return jsonify({"status": "ok", "request_id": request_id})
2414
+
2415
+ except Exception as e:
2416
+ logger.exception(f"Error handling HITL response for {request_id}")
2417
+ return jsonify({"status": "error", "message": str(e)}), 400
2418
+
2419
+ @app.route("/api/hitl/stream", methods=["GET"])
2420
+ def hitl_stream():
2421
+ """
2422
+ SSE stream for HITL requests.
2423
+
2424
+ Clients connect to this endpoint to receive hitl.request events
2425
+ in real-time. Events include:
2426
+ - hitl.request: New HITL request with full context
2427
+ - hitl.cancel: Request cancelled (another channel responded)
2428
+ """
2429
+ logger.info("[HITL-SSE] Client connected to /api/hitl/stream")
2430
+
2431
+ def generate():
2432
+ """Generator that yields SSE events from the channel."""
2433
+ import asyncio
2434
+ import json
2435
+
2436
+ channel = get_sse_channel()
2437
+
2438
+ # Create event loop for this thread
2439
+ loop = asyncio.new_event_loop()
2440
+ asyncio.set_event_loop(loop)
2441
+
2442
+ try:
2443
+ # Send initial connection event
2444
+ connection_event = {
2445
+ "type": "connection",
2446
+ "status": "connected",
2447
+ "timestamp": datetime.utcnow().isoformat() + "Z",
2448
+ }
2449
+ logger.info("[HITL-SSE] Sending connection event to client")
2450
+ yield f"data: {json.dumps(connection_event)}\n\n"
2451
+
2452
+ # Stream events from channel
2453
+ while True:
2454
+ # Get next event from channel (non-blocking with timeout)
2455
+ event = loop.run_until_complete(channel.get_next_event())
2456
+
2457
+ if event:
2458
+ logger.info(
2459
+ f"[HITL-SSE] Sending event to client: {event.get('type', 'unknown')}"
2460
+ )
2461
+ yield f"data: {json.dumps(event)}\n\n"
2462
+ else:
2463
+ # Send keepalive comment every second if no events
2464
+ yield ": keepalive\n\n"
2465
+ import time
2466
+
2467
+ time.sleep(1)
2468
+
2469
+ except GeneratorExit:
2470
+ logger.info("HITL SSE client disconnected")
2471
+ except Exception as e:
2472
+ logger.error(f"Error in HITL SSE stream: {e}", exc_info=True)
2473
+ finally:
2474
+ loop.close()
2475
+
2476
+ return Response(
2477
+ stream_with_context(generate()),
2478
+ mimetype="text/event-stream",
2479
+ headers={
2480
+ "Cache-Control": "no-cache",
2481
+ "X-Accel-Buffering": "no",
2482
+ "Connection": "keep-alive",
2483
+ },
2484
+ )
2485
+
2486
+ # =========================================================================
2487
+ # Frontend Serving (if enabled)
2488
+ # =========================================================================
2489
+
2199
2490
  if frontend_dist_dir:
2200
2491
 
2201
2492
  @app.route("/")