tactus 0.34.0__py3-none-any.whl → 0.35.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/adapters/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +15 -6
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
tactus/adapters/control_loop.py
CHANGED
|
@@ -11,7 +11,7 @@ import asyncio
|
|
|
11
11
|
import logging
|
|
12
12
|
import uuid
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
|
-
from typing import
|
|
14
|
+
from typing import Any, Optional
|
|
15
15
|
|
|
16
16
|
from tactus.core.exceptions import ProcedureWaitingForHuman
|
|
17
17
|
from tactus.protocols.control import (
|
|
@@ -62,7 +62,7 @@ class ControlLoopHandler:
|
|
|
62
62
|
|
|
63
63
|
def __init__(
|
|
64
64
|
self,
|
|
65
|
-
channels:
|
|
65
|
+
channels: list[ControlChannel],
|
|
66
66
|
storage: Optional[StorageBackend] = None,
|
|
67
67
|
immediate_response_timeout: float = 0.5,
|
|
68
68
|
execution_context=None,
|
|
@@ -83,7 +83,11 @@ class ControlLoopHandler:
|
|
|
83
83
|
self._channels_initialized = False
|
|
84
84
|
|
|
85
85
|
channel_ids = [c.channel_id for c in channels]
|
|
86
|
-
logger.info(
|
|
86
|
+
logger.info(
|
|
87
|
+
"ControlLoopHandler initialized with %s channels: %s",
|
|
88
|
+
len(channels),
|
|
89
|
+
channel_ids,
|
|
90
|
+
)
|
|
87
91
|
|
|
88
92
|
async def initialize_channels(self) -> None:
|
|
89
93
|
"""
|
|
@@ -98,8 +102,8 @@ class ControlLoopHandler:
|
|
|
98
102
|
if not self.channels:
|
|
99
103
|
return
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
await asyncio.gather(*
|
|
105
|
+
initialize_tasks = [channel.initialize() for channel in self.channels]
|
|
106
|
+
await asyncio.gather(*initialize_tasks, return_exceptions=True)
|
|
103
107
|
self._channels_initialized = True
|
|
104
108
|
|
|
105
109
|
async def shutdown_channels(self) -> None:
|
|
@@ -111,30 +115,30 @@ class ControlLoopHandler:
|
|
|
111
115
|
if not self.channels:
|
|
112
116
|
return
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
await asyncio.gather(*
|
|
118
|
+
shutdown_tasks = [channel.shutdown() for channel in self.channels]
|
|
119
|
+
await asyncio.gather(*shutdown_tasks, return_exceptions=True)
|
|
116
120
|
|
|
117
121
|
def request_interaction(
|
|
118
122
|
self,
|
|
119
123
|
procedure_id: str,
|
|
120
124
|
request_type: str,
|
|
121
125
|
message: str,
|
|
122
|
-
options: Optional[
|
|
126
|
+
options: Optional[list[dict[str, Any]]] = None,
|
|
123
127
|
timeout_seconds: Optional[int] = None,
|
|
124
128
|
default_value: Any = None,
|
|
125
|
-
metadata: Optional[
|
|
129
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
126
130
|
# Rich context
|
|
127
131
|
procedure_name: str = "Unknown Procedure",
|
|
128
132
|
invocation_id: Optional[str] = None,
|
|
129
133
|
namespace: str = "",
|
|
130
134
|
subject: Optional[str] = None,
|
|
131
135
|
started_at: Optional[datetime] = None,
|
|
132
|
-
input_summary: Optional[
|
|
133
|
-
conversation: Optional[
|
|
134
|
-
prior_interactions: Optional[
|
|
136
|
+
input_summary: Optional[dict[str, Any]] = None,
|
|
137
|
+
conversation: Optional[list[dict[str, Any]]] = None,
|
|
138
|
+
prior_interactions: Optional[list[dict[str, Any]]] = None,
|
|
135
139
|
# New context architecture
|
|
136
|
-
runtime_context: Optional[
|
|
137
|
-
application_context: Optional[
|
|
140
|
+
runtime_context: Optional[dict[str, Any]] = None,
|
|
141
|
+
application_context: Optional[list[dict[str, Any]]] = None,
|
|
138
142
|
) -> ControlResponse:
|
|
139
143
|
"""
|
|
140
144
|
Request controller interaction by sending to all channels.
|
|
@@ -190,25 +194,44 @@ class ControlLoopHandler:
|
|
|
190
194
|
)
|
|
191
195
|
|
|
192
196
|
logger.info(
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
"Control request %s for procedure %s: %s - %s...",
|
|
198
|
+
request.request_id,
|
|
199
|
+
procedure_id,
|
|
200
|
+
request_type,
|
|
201
|
+
message[:50],
|
|
195
202
|
)
|
|
196
203
|
|
|
197
204
|
# Run the async request flow
|
|
198
205
|
# Check if we're already in an async context
|
|
199
206
|
try:
|
|
200
|
-
|
|
207
|
+
event_loop = asyncio.get_running_loop()
|
|
208
|
+
if event_loop.is_closed():
|
|
209
|
+
raise RuntimeError("Running event loop is closed")
|
|
210
|
+
|
|
201
211
|
# Already in async context - create task and run it
|
|
202
212
|
# This shouldn't normally happen since request_interaction is sync
|
|
203
213
|
import nest_asyncio
|
|
204
214
|
|
|
205
215
|
nest_asyncio.apply()
|
|
206
|
-
return
|
|
216
|
+
return event_loop.run_until_complete(self._request_interaction_async(request))
|
|
207
217
|
except RuntimeError:
|
|
208
|
-
# Not in async context - create
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
218
|
+
# Not in async context - create a temporary event loop.
|
|
219
|
+
previous_event_loop: asyncio.AbstractEventLoop | None = None
|
|
220
|
+
try:
|
|
221
|
+
previous_event_loop = asyncio.get_event_loop()
|
|
222
|
+
except RuntimeError:
|
|
223
|
+
previous_event_loop = None
|
|
224
|
+
else:
|
|
225
|
+
if getattr(previous_event_loop, "is_closed", lambda: False)():
|
|
226
|
+
previous_event_loop = None
|
|
227
|
+
|
|
228
|
+
event_loop = asyncio.new_event_loop()
|
|
229
|
+
try:
|
|
230
|
+
asyncio.set_event_loop(event_loop)
|
|
231
|
+
return event_loop.run_until_complete(self._request_interaction_async(request))
|
|
232
|
+
finally:
|
|
233
|
+
event_loop.close()
|
|
234
|
+
asyncio.set_event_loop(previous_event_loop)
|
|
212
235
|
|
|
213
236
|
async def _request_interaction_async(self, request: ControlRequest) -> ControlResponse:
|
|
214
237
|
"""
|
|
@@ -220,7 +243,7 @@ class ControlLoopHandler:
|
|
|
220
243
|
if self.storage:
|
|
221
244
|
cached_response = self.check_pending_response(request.procedure_id, request.request_id)
|
|
222
245
|
if cached_response:
|
|
223
|
-
logger.info(
|
|
246
|
+
logger.info("RESUME: Using cached response for %s", request.request_id)
|
|
224
247
|
return cached_response
|
|
225
248
|
|
|
226
249
|
# Initialize channels on first use
|
|
@@ -241,14 +264,20 @@ class ControlLoopHandler:
|
|
|
241
264
|
deliveries = await self._fanout(request, eligible_channels)
|
|
242
265
|
|
|
243
266
|
# Log delivery results
|
|
244
|
-
successful = [
|
|
245
|
-
failed = [
|
|
267
|
+
successful = [delivery for delivery in deliveries if delivery.success]
|
|
268
|
+
failed = [delivery for delivery in deliveries if not delivery.success]
|
|
246
269
|
logger.info(
|
|
247
|
-
|
|
248
|
-
|
|
270
|
+
"Control request %s: %s successful deliveries, %s failed",
|
|
271
|
+
request.request_id,
|
|
272
|
+
len(successful),
|
|
273
|
+
len(failed),
|
|
249
274
|
)
|
|
250
|
-
for
|
|
251
|
-
logger.warning(
|
|
275
|
+
for delivery in failed:
|
|
276
|
+
logger.warning(
|
|
277
|
+
" Failed delivery to %s: %s",
|
|
278
|
+
delivery.channel_id,
|
|
279
|
+
delivery.error_message,
|
|
280
|
+
)
|
|
252
281
|
|
|
253
282
|
if not successful:
|
|
254
283
|
raise RuntimeError("All channel deliveries failed")
|
|
@@ -264,7 +293,10 @@ class ControlLoopHandler:
|
|
|
264
293
|
# Store response for future resume
|
|
265
294
|
if self.storage:
|
|
266
295
|
self._store_response(request, response)
|
|
267
|
-
logger.info(
|
|
296
|
+
logger.info(
|
|
297
|
+
"Stored response for %s (enables resume)",
|
|
298
|
+
request.request_id,
|
|
299
|
+
)
|
|
268
300
|
|
|
269
301
|
# Cancel all other channels
|
|
270
302
|
await self._cancel_other_channels(
|
|
@@ -283,8 +315,8 @@ class ControlLoopHandler:
|
|
|
283
315
|
async def _fanout(
|
|
284
316
|
self,
|
|
285
317
|
request: ControlRequest,
|
|
286
|
-
channels:
|
|
287
|
-
) ->
|
|
318
|
+
channels: list[ControlChannel],
|
|
319
|
+
) -> list[DeliveryResult]:
|
|
288
320
|
"""
|
|
289
321
|
Send request to all channels concurrently.
|
|
290
322
|
|
|
@@ -295,8 +327,8 @@ class ControlLoopHandler:
|
|
|
295
327
|
Returns:
|
|
296
328
|
List of delivery results
|
|
297
329
|
"""
|
|
298
|
-
|
|
299
|
-
results = await asyncio.gather(*
|
|
330
|
+
send_tasks = [self._send_with_error_handling(channel, request) for channel in channels]
|
|
331
|
+
results = await asyncio.gather(*send_tasks)
|
|
300
332
|
return list(results)
|
|
301
333
|
|
|
302
334
|
async def _send_with_error_handling(
|
|
@@ -309,21 +341,21 @@ class ControlLoopHandler:
|
|
|
309
341
|
"""
|
|
310
342
|
try:
|
|
311
343
|
return await channel.send(request)
|
|
312
|
-
except Exception as
|
|
313
|
-
logger.exception(
|
|
344
|
+
except Exception as error:
|
|
345
|
+
logger.exception("Failed to send to %s", channel.channel_id)
|
|
314
346
|
return DeliveryResult(
|
|
315
347
|
channel_id=channel.channel_id,
|
|
316
348
|
external_message_id="",
|
|
317
349
|
delivered_at=datetime.now(timezone.utc),
|
|
318
350
|
success=False,
|
|
319
|
-
error_message=str(
|
|
351
|
+
error_message=str(error),
|
|
320
352
|
)
|
|
321
353
|
|
|
322
354
|
async def _wait_for_first_response(
|
|
323
355
|
self,
|
|
324
356
|
request: ControlRequest,
|
|
325
|
-
channels:
|
|
326
|
-
deliveries:
|
|
357
|
+
channels: list[ControlChannel],
|
|
358
|
+
deliveries: list[DeliveryResult],
|
|
327
359
|
) -> Optional[ControlResponse]:
|
|
328
360
|
"""
|
|
329
361
|
Wait for first response from any channel.
|
|
@@ -346,7 +378,7 @@ class ControlLoopHandler:
|
|
|
346
378
|
timeout = self.immediate_response_timeout if not has_sync_channel else 30.0
|
|
347
379
|
|
|
348
380
|
# Create tasks for each channel's receive iterator
|
|
349
|
-
receive_tasks = []
|
|
381
|
+
receive_tasks: list[tuple[ControlChannel, asyncio.Task[Optional[ControlResponse]]]] = []
|
|
350
382
|
for channel in channels:
|
|
351
383
|
task = asyncio.create_task(
|
|
352
384
|
self._get_first_from_channel(channel),
|
|
@@ -378,8 +410,8 @@ class ControlLoopHandler:
|
|
|
378
410
|
return response
|
|
379
411
|
except asyncio.CancelledError:
|
|
380
412
|
pass
|
|
381
|
-
except Exception as
|
|
382
|
-
logger.debug(
|
|
413
|
+
except Exception as error:
|
|
414
|
+
logger.debug("Task exception: %s", error)
|
|
383
415
|
|
|
384
416
|
return None
|
|
385
417
|
|
|
@@ -393,19 +425,23 @@ class ControlLoopHandler:
|
|
|
393
425
|
"""Get first response from a channel's receive iterator."""
|
|
394
426
|
try:
|
|
395
427
|
async for response in channel.receive():
|
|
396
|
-
logger.info(
|
|
428
|
+
logger.info(
|
|
429
|
+
"Received response from %s: %s",
|
|
430
|
+
channel.channel_id,
|
|
431
|
+
response.request_id,
|
|
432
|
+
)
|
|
397
433
|
return response
|
|
398
434
|
except asyncio.CancelledError:
|
|
399
435
|
raise
|
|
400
|
-
except Exception as
|
|
401
|
-
logger.debug(
|
|
436
|
+
except Exception as error:
|
|
437
|
+
logger.debug("Error receiving from %s: %s", channel.channel_id, error)
|
|
402
438
|
return None
|
|
403
439
|
return None
|
|
404
440
|
|
|
405
441
|
async def _cancel_other_channels(
|
|
406
442
|
self,
|
|
407
443
|
request: ControlRequest,
|
|
408
|
-
deliveries:
|
|
444
|
+
deliveries: list[DeliveryResult],
|
|
409
445
|
winning_channel: Optional[str],
|
|
410
446
|
) -> None:
|
|
411
447
|
"""
|
|
@@ -444,11 +480,11 @@ class ControlLoopHandler:
|
|
|
444
480
|
try:
|
|
445
481
|
await channel.cancel(external_message_id, reason)
|
|
446
482
|
except Exception:
|
|
447
|
-
logger.exception(
|
|
483
|
+
logger.exception("Failed to cancel on %s", channel.channel_id)
|
|
448
484
|
|
|
449
|
-
def _get_eligible_channels(self, request: ControlRequest) ->
|
|
485
|
+
def _get_eligible_channels(self, request: ControlRequest) -> list[ControlChannel]:
|
|
450
486
|
"""Get channels that support the given request type."""
|
|
451
|
-
eligible = []
|
|
487
|
+
eligible: list[ControlChannel] = []
|
|
452
488
|
for channel in self.channels:
|
|
453
489
|
if self._channel_supports_request(channel, request):
|
|
454
490
|
eligible.append(channel)
|
|
@@ -486,20 +522,20 @@ class ControlLoopHandler:
|
|
|
486
522
|
procedure_id: str,
|
|
487
523
|
request_type: str,
|
|
488
524
|
message: str,
|
|
489
|
-
options: Optional[
|
|
525
|
+
options: Optional[list[dict[str, Any]]] = None,
|
|
490
526
|
timeout_seconds: Optional[int] = None,
|
|
491
527
|
default_value: Any = None,
|
|
492
|
-
metadata: Optional[
|
|
528
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
493
529
|
procedure_name: str = "Unknown Procedure",
|
|
494
530
|
invocation_id: Optional[str] = None,
|
|
495
531
|
namespace: str = "",
|
|
496
532
|
subject: Optional[str] = None,
|
|
497
533
|
started_at: Optional[datetime] = None,
|
|
498
|
-
input_summary: Optional[
|
|
499
|
-
conversation: Optional[
|
|
500
|
-
prior_interactions: Optional[
|
|
501
|
-
runtime_context: Optional[
|
|
502
|
-
application_context: Optional[
|
|
534
|
+
input_summary: Optional[dict[str, Any]] = None,
|
|
535
|
+
conversation: Optional[list[dict[str, Any]]] = None,
|
|
536
|
+
prior_interactions: Optional[list[dict[str, Any]]] = None,
|
|
537
|
+
runtime_context: Optional[dict[str, Any]] = None,
|
|
538
|
+
application_context: Optional[list[dict[str, Any]]] = None,
|
|
503
539
|
) -> ControlRequest:
|
|
504
540
|
"""Build a ControlRequest from the provided parameters."""
|
|
505
541
|
# CRITICAL: Generate deterministic request_id based on checkpoint position AND run_id
|
|
@@ -510,21 +546,21 @@ class ControlLoopHandler:
|
|
|
510
546
|
checkpoint_position = self.execution_context.next_position()
|
|
511
547
|
|
|
512
548
|
# Get run_id from execution context to ensure cache isolation between runs
|
|
513
|
-
|
|
549
|
+
run_id_prefix = "unknown"
|
|
514
550
|
if self.execution_context and hasattr(self.execution_context, "current_run_id"):
|
|
515
551
|
if self.execution_context.current_run_id:
|
|
516
552
|
# Use first 8 chars of run_id for brevity
|
|
517
|
-
|
|
553
|
+
run_id_prefix = self.execution_context.current_run_id[:8]
|
|
518
554
|
|
|
519
555
|
if checkpoint_position is not None:
|
|
520
556
|
# Deterministic ID: procedure_id:run_id:position
|
|
521
|
-
request_id = f"{procedure_id}:{
|
|
557
|
+
request_id = f"{procedure_id}:{run_id_prefix}:pos{checkpoint_position}"
|
|
522
558
|
else:
|
|
523
559
|
# Fallback to random ID (backward compatibility for contexts without position tracking)
|
|
524
|
-
request_id = f"{procedure_id}:{
|
|
560
|
+
request_id = f"{procedure_id}:{run_id_prefix}:{uuid.uuid4().hex[:12]}"
|
|
525
561
|
|
|
526
562
|
# Convert options to ControlOption objects
|
|
527
|
-
control_options = []
|
|
563
|
+
control_options: list[ControlOption] = []
|
|
528
564
|
if options:
|
|
529
565
|
for opt in options:
|
|
530
566
|
control_options.append(
|
|
@@ -540,14 +576,16 @@ class ControlLoopHandler:
|
|
|
540
576
|
items = []
|
|
541
577
|
if request_type == "inputs":
|
|
542
578
|
logger.debug(
|
|
543
|
-
|
|
579
|
+
"Processing inputs request, metadata type: %s, value: %s",
|
|
580
|
+
type(metadata),
|
|
581
|
+
metadata,
|
|
544
582
|
)
|
|
545
583
|
if metadata and isinstance(metadata, dict) and "items" in metadata:
|
|
546
584
|
from tactus.protocols.control import ControlRequestItem
|
|
547
585
|
|
|
548
|
-
|
|
549
|
-
logger.debug(
|
|
550
|
-
for item_dict in
|
|
586
|
+
item_entries = metadata.get("items", [])
|
|
587
|
+
logger.debug("Found %s items in metadata", len(item_entries))
|
|
588
|
+
for item_dict in item_entries:
|
|
551
589
|
# Convert dict to ControlRequestItem
|
|
552
590
|
items.append(ControlRequestItem(**item_dict))
|
|
553
591
|
|
|
@@ -589,7 +627,7 @@ class ControlLoopHandler:
|
|
|
589
627
|
)
|
|
590
628
|
|
|
591
629
|
# Convert application_context dicts to ContextLink objects
|
|
592
|
-
app_ctx_objs = []
|
|
630
|
+
app_ctx_objs: list[ContextLink] = []
|
|
593
631
|
if application_context:
|
|
594
632
|
for link in application_context:
|
|
595
633
|
app_ctx_objs.append(
|
|
@@ -631,7 +669,7 @@ class ControlLoopHandler:
|
|
|
631
669
|
metadata=metadata or {},
|
|
632
670
|
)
|
|
633
671
|
|
|
634
|
-
def _store_pending(self, request: ControlRequest, deliveries:
|
|
672
|
+
def _store_pending(self, request: ControlRequest, deliveries: list[DeliveryResult]) -> None:
|
|
635
673
|
"""Store pending request in storage backend."""
|
|
636
674
|
if not self.storage:
|
|
637
675
|
return
|
|
@@ -670,7 +708,7 @@ class ControlLoopHandler:
|
|
|
670
708
|
def check_pending_response(
|
|
671
709
|
self,
|
|
672
710
|
procedure_id: str,
|
|
673
|
-
|
|
711
|
+
request_id: str,
|
|
674
712
|
) -> Optional[ControlResponse]:
|
|
675
713
|
"""
|
|
676
714
|
Check if there's a response to a pending control request.
|
|
@@ -679,7 +717,7 @@ class ControlLoopHandler:
|
|
|
679
717
|
|
|
680
718
|
Args:
|
|
681
719
|
procedure_id: Unique procedure identifier
|
|
682
|
-
|
|
720
|
+
request_id: Request ID (message_id in this context)
|
|
683
721
|
|
|
684
722
|
Returns:
|
|
685
723
|
ControlResponse if response exists, None otherwise
|
|
@@ -687,35 +725,35 @@ class ControlLoopHandler:
|
|
|
687
725
|
if not self.storage:
|
|
688
726
|
return None
|
|
689
727
|
|
|
690
|
-
key = f"{self.PENDING_KEY_PREFIX}{
|
|
728
|
+
key = f"{self.PENDING_KEY_PREFIX}{request_id}"
|
|
691
729
|
state = self.storage.get_state(procedure_id) or {}
|
|
692
730
|
|
|
693
731
|
if key in state:
|
|
694
732
|
pending = state[key]
|
|
695
733
|
if pending.get("response"):
|
|
696
|
-
logger.info(
|
|
734
|
+
logger.info("Found response for request %s", request_id)
|
|
697
735
|
return ControlResponse.model_validate(pending["response"])
|
|
698
736
|
|
|
699
737
|
return None
|
|
700
738
|
|
|
701
|
-
def cancel_pending_request(self, procedure_id: str,
|
|
739
|
+
def cancel_pending_request(self, procedure_id: str, request_id: str) -> None:
|
|
702
740
|
"""
|
|
703
741
|
Cancel a pending control request.
|
|
704
742
|
|
|
705
743
|
Args:
|
|
706
744
|
procedure_id: Unique procedure identifier
|
|
707
|
-
|
|
745
|
+
request_id: Request ID to cancel
|
|
708
746
|
"""
|
|
709
747
|
if not self.storage:
|
|
710
748
|
return
|
|
711
749
|
|
|
712
|
-
key = f"{self.PENDING_KEY_PREFIX}{
|
|
750
|
+
key = f"{self.PENDING_KEY_PREFIX}{request_id}"
|
|
713
751
|
state = self.storage.get_state(procedure_id) or {}
|
|
714
752
|
|
|
715
753
|
if key in state:
|
|
716
754
|
del state[key]
|
|
717
755
|
self.storage.set_state(procedure_id, state)
|
|
718
|
-
logger.info(
|
|
756
|
+
logger.info("Cancelled control request %s", request_id)
|
|
719
757
|
|
|
720
758
|
|
|
721
759
|
class ControlLoopHITLAdapter:
|
|
@@ -776,13 +814,13 @@ class ControlLoopHITLAdapter:
|
|
|
776
814
|
metadata = request.get("metadata", {})
|
|
777
815
|
|
|
778
816
|
# Use provided execution_context or fall back to instance one
|
|
779
|
-
|
|
817
|
+
execution_context_to_use = execution_context or self.execution_context
|
|
780
818
|
|
|
781
819
|
# CRITICAL: Pass execution_context to control_handler for deterministic request IDs
|
|
782
820
|
# This allows _build_request to use next_position() for stable request_id generation
|
|
783
|
-
|
|
784
|
-
if
|
|
785
|
-
self.control_handler.execution_context =
|
|
821
|
+
previous_execution_context = self.control_handler.execution_context
|
|
822
|
+
if execution_context_to_use:
|
|
823
|
+
self.control_handler.execution_context = execution_context_to_use
|
|
786
824
|
|
|
787
825
|
# Gather rich context from execution context if available
|
|
788
826
|
procedure_name = "Unknown Procedure"
|
|
@@ -793,26 +831,26 @@ class ControlLoopHITLAdapter:
|
|
|
793
831
|
conversation = None
|
|
794
832
|
prior_interactions = None
|
|
795
833
|
|
|
796
|
-
if
|
|
797
|
-
procedure_name = getattr(
|
|
798
|
-
invocation_id = getattr(
|
|
834
|
+
if execution_context_to_use:
|
|
835
|
+
procedure_name = getattr(execution_context_to_use, "procedure_name", procedure_name)
|
|
836
|
+
invocation_id = getattr(execution_context_to_use, "invocation_id", invocation_id)
|
|
799
837
|
|
|
800
838
|
# Try to get additional context if methods exist
|
|
801
|
-
if hasattr(
|
|
802
|
-
subject =
|
|
803
|
-
if hasattr(
|
|
804
|
-
started_at =
|
|
805
|
-
if hasattr(
|
|
806
|
-
input_summary =
|
|
807
|
-
if hasattr(
|
|
808
|
-
conversation =
|
|
809
|
-
if hasattr(
|
|
810
|
-
prior_interactions =
|
|
839
|
+
if hasattr(execution_context_to_use, "get_subject"):
|
|
840
|
+
subject = execution_context_to_use.get_subject()
|
|
841
|
+
if hasattr(execution_context_to_use, "get_started_at"):
|
|
842
|
+
started_at = execution_context_to_use.get_started_at()
|
|
843
|
+
if hasattr(execution_context_to_use, "get_input_summary"):
|
|
844
|
+
input_summary = execution_context_to_use.get_input_summary()
|
|
845
|
+
if hasattr(execution_context_to_use, "get_conversation_history"):
|
|
846
|
+
conversation = execution_context_to_use.get_conversation_history()
|
|
847
|
+
if hasattr(execution_context_to_use, "get_prior_control_interactions"):
|
|
848
|
+
prior_interactions = execution_context_to_use.get_prior_control_interactions()
|
|
811
849
|
|
|
812
850
|
# Get runtime context for HITL display (new context architecture)
|
|
813
851
|
runtime_context = None
|
|
814
|
-
if
|
|
815
|
-
runtime_context =
|
|
852
|
+
if execution_context_to_use and hasattr(execution_context_to_use, "get_runtime_context"):
|
|
853
|
+
runtime_context = execution_context_to_use.get_runtime_context()
|
|
816
854
|
|
|
817
855
|
# Application context would be passed from the host application
|
|
818
856
|
# For now, we don't have a way to pass it through, but the protocol supports it
|
|
@@ -852,9 +890,10 @@ class ControlLoopHITLAdapter:
|
|
|
852
890
|
)
|
|
853
891
|
finally:
|
|
854
892
|
# Restore original execution context
|
|
855
|
-
|
|
893
|
+
if execution_context_to_use:
|
|
894
|
+
self.control_handler.execution_context = previous_execution_context
|
|
856
895
|
|
|
857
|
-
def check_pending_response(self, procedure_id: str,
|
|
896
|
+
def check_pending_response(self, procedure_id: str, request_id: str):
|
|
858
897
|
"""
|
|
859
898
|
Check for pending response.
|
|
860
899
|
|
|
@@ -862,7 +901,7 @@ class ControlLoopHITLAdapter:
|
|
|
862
901
|
"""
|
|
863
902
|
from tactus.protocols.models import HITLResponse
|
|
864
903
|
|
|
865
|
-
control_response = self.control_handler.check_pending_response(procedure_id,
|
|
904
|
+
control_response = self.control_handler.check_pending_response(procedure_id, request_id)
|
|
866
905
|
if control_response is None:
|
|
867
906
|
return None
|
|
868
907
|
|
|
@@ -874,6 +913,6 @@ class ControlLoopHITLAdapter:
|
|
|
874
913
|
channel=control_response.channel_id,
|
|
875
914
|
)
|
|
876
915
|
|
|
877
|
-
def cancel_pending_request(self, procedure_id: str,
|
|
916
|
+
def cancel_pending_request(self, procedure_id: str, request_id: str) -> None:
|
|
878
917
|
"""Cancel pending request - delegates to ControlLoopHandler."""
|
|
879
|
-
self.control_handler.cancel_pending_request(procedure_id,
|
|
918
|
+
self.control_handler.cancel_pending_request(procedure_id, request_id)
|
|
@@ -7,9 +7,8 @@ without enabling streaming UI behavior.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import logging
|
|
11
10
|
import json
|
|
12
|
-
|
|
11
|
+
import logging
|
|
13
12
|
|
|
14
13
|
from tactus.protocols.models import CostEvent, LogEvent
|
|
15
14
|
|
|
@@ -29,7 +28,7 @@ class CostCollectorLogHandler:
|
|
|
29
28
|
supports_streaming = False
|
|
30
29
|
|
|
31
30
|
def __init__(self):
|
|
32
|
-
self.cost_events:
|
|
31
|
+
self.cost_events: list[CostEvent] = []
|
|
33
32
|
logger.debug("CostCollectorLogHandler initialized")
|
|
34
33
|
|
|
35
34
|
def log(self, event: LogEvent) -> None:
|
|
@@ -41,16 +40,17 @@ class CostCollectorLogHandler:
|
|
|
41
40
|
if isinstance(event, LogEvent):
|
|
42
41
|
event_logger = logging.getLogger(event.logger_name or "procedure")
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
message_text = event.message
|
|
45
44
|
if event.context:
|
|
46
|
-
|
|
45
|
+
context_json = json.dumps(event.context, indent=2, default=str)
|
|
46
|
+
message_text = f"{message_text}\nContext: {context_json}"
|
|
47
47
|
|
|
48
48
|
level = (event.level or "INFO").upper()
|
|
49
49
|
if level == "DEBUG":
|
|
50
|
-
event_logger.debug(
|
|
50
|
+
event_logger.debug(message_text)
|
|
51
51
|
elif level in ("WARN", "WARNING"):
|
|
52
|
-
event_logger.warning(
|
|
52
|
+
event_logger.warning(message_text)
|
|
53
53
|
elif level == "ERROR":
|
|
54
|
-
event_logger.error(
|
|
54
|
+
event_logger.error(message_text)
|
|
55
55
|
else:
|
|
56
|
-
event_logger.info(
|
|
56
|
+
event_logger.info(message_text)
|