tactus 0.33.0__py3-none-any.whl → 0.34.1__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/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +382 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/server.py +300 -9
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
tactus/primitives/human.py
CHANGED
|
@@ -4,14 +4,19 @@ Human Primitive - Human-in-the-Loop (HITL) operations.
|
|
|
4
4
|
Provides:
|
|
5
5
|
- Human.approve(opts) - Request yes/no approval (blocking)
|
|
6
6
|
- Human.input(opts) - Request free-form input (blocking)
|
|
7
|
+
- Human.select(opts) - Request selection from options (blocking)
|
|
8
|
+
- Human.multiple(items) - Request multiple inputs in one interaction (blocking)
|
|
7
9
|
- Human.review(opts) - Request review with options (blocking)
|
|
8
10
|
- Human.notify(opts) - Send notification (non-blocking)
|
|
9
11
|
- Human.escalate(opts) - Escalate to human (blocking)
|
|
12
|
+
- Human.custom(opts) - Request custom component interaction (blocking)
|
|
13
|
+
|
|
14
|
+
Deprecated:
|
|
15
|
+
- Human.inputs(items) - Use Human.multiple() instead
|
|
10
16
|
"""
|
|
11
17
|
|
|
12
18
|
import logging
|
|
13
|
-
from typing import Any, Dict, Optional
|
|
14
|
-
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
15
20
|
|
|
16
21
|
logger = logging.getLogger(__name__)
|
|
17
22
|
|
|
@@ -37,14 +42,25 @@ class HumanPrimitive:
|
|
|
37
42
|
logger.debug("HumanPrimitive initialized")
|
|
38
43
|
|
|
39
44
|
def _convert_lua_to_python(self, obj: Any) -> Any:
|
|
40
|
-
"""Recursively convert Lua tables to Python dicts."""
|
|
45
|
+
"""Recursively convert Lua tables to Python dicts or lists."""
|
|
41
46
|
if obj is None:
|
|
42
47
|
return None
|
|
43
48
|
# Check if it's a Lua table (has .items() but not a dict)
|
|
44
49
|
if hasattr(obj, "items") and not isinstance(obj, dict):
|
|
45
|
-
#
|
|
50
|
+
# Get all items from the Lua table
|
|
51
|
+
items = list(obj.items())
|
|
52
|
+
|
|
53
|
+
# Check if this is an array-like table (numeric keys starting from 1)
|
|
54
|
+
if items and all(isinstance(k, int) for k, v in items):
|
|
55
|
+
# Sort by key and extract values to create a Python list
|
|
56
|
+
sorted_items = sorted(items, key=lambda x: x[0])
|
|
57
|
+
# Check if keys are consecutive starting from 1
|
|
58
|
+
if [k for k, v in sorted_items] == list(range(1, len(sorted_items) + 1)):
|
|
59
|
+
return [self._convert_lua_to_python(v) for k, v in sorted_items]
|
|
60
|
+
|
|
61
|
+
# Otherwise, convert to dict (string keys or mixed)
|
|
46
62
|
result = {}
|
|
47
|
-
for key, value in
|
|
63
|
+
for key, value in items:
|
|
48
64
|
result[key] = self._convert_lua_to_python(value)
|
|
49
65
|
return result
|
|
50
66
|
elif isinstance(obj, dict):
|
|
@@ -62,7 +78,7 @@ class HumanPrimitive:
|
|
|
62
78
|
Request yes/no approval from human (BLOCKING).
|
|
63
79
|
|
|
64
80
|
Args:
|
|
65
|
-
options: Dict with
|
|
81
|
+
options: Dict with options OR string message for convenience
|
|
66
82
|
- message: str - Message to show human
|
|
67
83
|
- context: Dict - Additional context
|
|
68
84
|
- timeout: int - Timeout in seconds (None = no timeout)
|
|
@@ -73,6 +89,10 @@ class HumanPrimitive:
|
|
|
73
89
|
bool - True if approved, False if rejected/timeout
|
|
74
90
|
|
|
75
91
|
Example (Lua):
|
|
92
|
+
-- Simple form (just message string)
|
|
93
|
+
local approved = Human.approve("Deploy to production?")
|
|
94
|
+
|
|
95
|
+
-- Full form (with options)
|
|
76
96
|
local approved = Human.approve({
|
|
77
97
|
message = "Deploy to production?",
|
|
78
98
|
context = {environment = "prod"},
|
|
@@ -87,6 +107,10 @@ class HumanPrimitive:
|
|
|
87
107
|
# Convert Lua tables to Python dicts recursively
|
|
88
108
|
opts = self._convert_lua_to_python(options) or {}
|
|
89
109
|
|
|
110
|
+
# Support string message shorthand: Human.approve("message")
|
|
111
|
+
if isinstance(opts, str):
|
|
112
|
+
opts = {"message": opts}
|
|
113
|
+
|
|
90
114
|
# Check for config reference
|
|
91
115
|
config_key = opts.get("config_key")
|
|
92
116
|
if config_key and config_key in self.hitl_config:
|
|
@@ -102,15 +126,22 @@ class HumanPrimitive:
|
|
|
102
126
|
|
|
103
127
|
logger.info(f"Human approval requested: {message[:50]}...")
|
|
104
128
|
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
129
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
130
|
+
# This allows kill/resume to work - procedure can be restarted and will resume from this point
|
|
131
|
+
logger.debug("[CHECKPOINT] Creating checkpoint for Human.approve(), type=hitl_approval")
|
|
132
|
+
|
|
133
|
+
def checkpoint_fn():
|
|
134
|
+
return self.execution_context.wait_for_human(
|
|
135
|
+
request_type="approval",
|
|
136
|
+
message=message,
|
|
137
|
+
timeout_seconds=timeout,
|
|
138
|
+
default_value=default,
|
|
139
|
+
options=None,
|
|
140
|
+
metadata=context,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_approval")
|
|
144
|
+
logger.debug(f"[CHECKPOINT] Human.approve() checkpoint completed, response={response}")
|
|
114
145
|
|
|
115
146
|
return response.value
|
|
116
147
|
|
|
@@ -157,15 +188,18 @@ class HumanPrimitive:
|
|
|
157
188
|
|
|
158
189
|
logger.info(f"Human input requested: {message[:50]}...")
|
|
159
190
|
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
192
|
+
def checkpoint_fn():
|
|
193
|
+
return self.execution_context.wait_for_human(
|
|
194
|
+
request_type="input",
|
|
195
|
+
message=message,
|
|
196
|
+
timeout_seconds=timeout,
|
|
197
|
+
default_value=default,
|
|
198
|
+
options=None,
|
|
199
|
+
metadata={"placeholder": placeholder},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_input")
|
|
169
203
|
|
|
170
204
|
return response.value
|
|
171
205
|
|
|
@@ -231,19 +265,22 @@ class HumanPrimitive:
|
|
|
231
265
|
else:
|
|
232
266
|
formatted_options.append({"label": str(opt).title(), "type": "action"})
|
|
233
267
|
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
268
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
269
|
+
def checkpoint_fn():
|
|
270
|
+
return self.execution_context.wait_for_human(
|
|
271
|
+
request_type="review",
|
|
272
|
+
message=message,
|
|
273
|
+
timeout_seconds=timeout,
|
|
274
|
+
default_value={
|
|
275
|
+
"decision": "reject",
|
|
276
|
+
"edited_artifact": artifact_python,
|
|
277
|
+
"feedback": "",
|
|
278
|
+
},
|
|
279
|
+
options=formatted_options,
|
|
280
|
+
metadata={"artifact": artifact_python, "artifact_type": artifact_type},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_review")
|
|
247
284
|
|
|
248
285
|
return response.value
|
|
249
286
|
|
|
@@ -325,18 +362,553 @@ class HumanPrimitive:
|
|
|
325
362
|
# Prepare metadata with severity and context
|
|
326
363
|
metadata = {"severity": severity, "context": context}
|
|
327
364
|
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
365
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
366
|
+
def checkpoint_fn():
|
|
367
|
+
return self.execution_context.wait_for_human(
|
|
368
|
+
request_type="escalation",
|
|
369
|
+
message=message,
|
|
370
|
+
timeout_seconds=None, # No timeout - wait indefinitely
|
|
371
|
+
default_value=None, # No default - human must resolve
|
|
372
|
+
options=None,
|
|
373
|
+
metadata=metadata,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self.execution_context.checkpoint(checkpoint_fn, "hitl_escalation")
|
|
338
377
|
|
|
339
378
|
logger.info("Human escalation resolved - resuming workflow")
|
|
340
379
|
|
|
380
|
+
def select(self, options: Optional[Dict[str, Any]] = None) -> Any:
|
|
381
|
+
"""
|
|
382
|
+
Request selection from options (BLOCKING).
|
|
383
|
+
|
|
384
|
+
Supports both single-select (radio buttons/dropdown) and multi-select (checkboxes).
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
options: Dict with:
|
|
388
|
+
- message: str - Prompt for human
|
|
389
|
+
- options: List[str] or List[Dict] - Available choices
|
|
390
|
+
- mode: str - "single" (default) or "multiple"
|
|
391
|
+
- style: str - UI hint: "radio", "dropdown", "checkbox" (optional)
|
|
392
|
+
- min: int - Minimum selections required (for multiple mode)
|
|
393
|
+
- max: int - Maximum selections allowed (for multiple mode)
|
|
394
|
+
- default: Any - Default selection(s) if timeout
|
|
395
|
+
- timeout: int - Timeout in seconds
|
|
396
|
+
- config_key: str - Reference to hitl: declaration
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
For single mode: str - Selected option value
|
|
400
|
+
For multiple mode: List[str] - Selected option values
|
|
401
|
+
|
|
402
|
+
Example (Lua):
|
|
403
|
+
-- Single select (radio buttons)
|
|
404
|
+
local target = Human.select({
|
|
405
|
+
message = "Choose deployment target",
|
|
406
|
+
options = {"staging", "production", "development"},
|
|
407
|
+
mode = "single",
|
|
408
|
+
style = "radio"
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
-- Multi-select (checkboxes)
|
|
412
|
+
local features = Human.select({
|
|
413
|
+
message = "Which features to enable?",
|
|
414
|
+
options = {"dark_mode", "notifications", "analytics"},
|
|
415
|
+
mode = "multiple",
|
|
416
|
+
min = 1,
|
|
417
|
+
max = 2
|
|
418
|
+
})
|
|
419
|
+
"""
|
|
420
|
+
# Convert Lua tables to Python dicts recursively
|
|
421
|
+
opts = self._convert_lua_to_python(options) or {}
|
|
422
|
+
|
|
423
|
+
# Check for config reference
|
|
424
|
+
config_key = opts.get("config_key")
|
|
425
|
+
if config_key and config_key in self.hitl_config:
|
|
426
|
+
config_opts = self.hitl_config[config_key].copy()
|
|
427
|
+
config_opts.update(opts)
|
|
428
|
+
opts = config_opts
|
|
429
|
+
|
|
430
|
+
message = opts.get("message", "Selection required")
|
|
431
|
+
options_list = opts.get("options", [])
|
|
432
|
+
mode = opts.get("mode", "single")
|
|
433
|
+
style = opts.get("style") # UI hint: radio, dropdown, checkbox
|
|
434
|
+
min_selections = opts.get("min", 1 if mode == "multiple" else None)
|
|
435
|
+
max_selections = opts.get("max")
|
|
436
|
+
timeout = opts.get("timeout")
|
|
437
|
+
default = opts.get("default", [] if mode == "multiple" else None)
|
|
438
|
+
|
|
439
|
+
logger.info(f"Human selection requested ({mode}): {message[:50]}...")
|
|
440
|
+
|
|
441
|
+
# Convert options list to format expected by protocol: [{label, value}, ...]
|
|
442
|
+
formatted_options = []
|
|
443
|
+
for opt in options_list:
|
|
444
|
+
if isinstance(opt, dict) and "label" in opt:
|
|
445
|
+
# Already formatted: {label: "...", value: "..."}
|
|
446
|
+
formatted_options.append(opt)
|
|
447
|
+
elif isinstance(opt, dict) and "value" in opt:
|
|
448
|
+
# Has value but no label - use value as label
|
|
449
|
+
formatted_options.append({"label": str(opt["value"]), "value": opt["value"]})
|
|
450
|
+
else:
|
|
451
|
+
# Simple string - use as both label and value
|
|
452
|
+
formatted_options.append({"label": str(opt), "value": opt})
|
|
453
|
+
|
|
454
|
+
# Build metadata with select-specific fields
|
|
455
|
+
metadata = {
|
|
456
|
+
"mode": mode,
|
|
457
|
+
"min": min_selections,
|
|
458
|
+
"max": max_selections,
|
|
459
|
+
}
|
|
460
|
+
if style:
|
|
461
|
+
metadata["style"] = style
|
|
462
|
+
|
|
463
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
464
|
+
def checkpoint_fn():
|
|
465
|
+
return self.execution_context.wait_for_human(
|
|
466
|
+
request_type="select",
|
|
467
|
+
message=message,
|
|
468
|
+
timeout_seconds=timeout,
|
|
469
|
+
default_value=default,
|
|
470
|
+
options=formatted_options,
|
|
471
|
+
metadata=metadata,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_select")
|
|
475
|
+
|
|
476
|
+
return response.value
|
|
477
|
+
|
|
478
|
+
def upload(self, options: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
479
|
+
"""
|
|
480
|
+
Request file upload from human (BLOCKING).
|
|
481
|
+
|
|
482
|
+
Files are stored locally on the filesystem. The response contains
|
|
483
|
+
the file path and metadata, not the file contents.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
options: Dict with:
|
|
487
|
+
- message: str - Upload prompt
|
|
488
|
+
- accept: str or List[str] - Accepted file types (e.g., ".pdf,.doc" or ["image/*"])
|
|
489
|
+
- max_size: str or int - Maximum file size (e.g., "10MB" or 10485760)
|
|
490
|
+
- multiple: bool - Allow multiple files (default: False)
|
|
491
|
+
- timeout: int - Timeout in seconds
|
|
492
|
+
- config_key: str - Reference to hitl: declaration
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Dict with file info (or List[Dict] if multiple=True):
|
|
496
|
+
- path: str - Local filesystem path to uploaded file
|
|
497
|
+
- name: str - Original filename
|
|
498
|
+
- size: int - File size in bytes
|
|
499
|
+
- mime_type: str - MIME type of file
|
|
500
|
+
|
|
501
|
+
Returns None if timeout with no default.
|
|
502
|
+
|
|
503
|
+
Example (Lua):
|
|
504
|
+
-- Single file upload
|
|
505
|
+
local file = Human.upload({
|
|
506
|
+
message = "Upload your document",
|
|
507
|
+
accept = ".pdf,.doc,.docx",
|
|
508
|
+
max_size = "10MB"
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
if file then
|
|
512
|
+
print("Uploaded: " .. file.name)
|
|
513
|
+
print("Path: " .. file.path)
|
|
514
|
+
print("Size: " .. file.size .. " bytes")
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
-- Multiple file upload
|
|
518
|
+
local images = Human.upload({
|
|
519
|
+
message = "Upload images",
|
|
520
|
+
accept = "image/*",
|
|
521
|
+
multiple = true,
|
|
522
|
+
max_size = "5MB"
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
for _, img in ipairs(images or {}) do
|
|
526
|
+
print("Image: " .. img.name)
|
|
527
|
+
end
|
|
528
|
+
"""
|
|
529
|
+
# Convert Lua tables to Python dicts recursively
|
|
530
|
+
opts = self._convert_lua_to_python(options) or {}
|
|
531
|
+
|
|
532
|
+
# Check for config reference
|
|
533
|
+
config_key = opts.get("config_key")
|
|
534
|
+
if config_key and config_key in self.hitl_config:
|
|
535
|
+
config_opts = self.hitl_config[config_key].copy()
|
|
536
|
+
config_opts.update(opts)
|
|
537
|
+
opts = config_opts
|
|
538
|
+
|
|
539
|
+
message = opts.get("message", "File upload requested")
|
|
540
|
+
accept = opts.get("accept") # File type filter
|
|
541
|
+
max_size = opts.get("max_size") # Size limit
|
|
542
|
+
multiple = opts.get("multiple", False)
|
|
543
|
+
timeout = opts.get("timeout")
|
|
544
|
+
|
|
545
|
+
logger.info(f"Human file upload requested: {message[:50]}...")
|
|
546
|
+
|
|
547
|
+
# Normalize accept to list
|
|
548
|
+
if isinstance(accept, str):
|
|
549
|
+
accept = [a.strip() for a in accept.split(",")]
|
|
550
|
+
|
|
551
|
+
# Normalize max_size to bytes
|
|
552
|
+
if isinstance(max_size, str):
|
|
553
|
+
max_size = self._parse_size(max_size)
|
|
554
|
+
|
|
555
|
+
# Build metadata with upload-specific fields
|
|
556
|
+
metadata = {
|
|
557
|
+
"accept": accept,
|
|
558
|
+
"max_size": max_size,
|
|
559
|
+
"multiple": multiple,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
563
|
+
def checkpoint_fn():
|
|
564
|
+
return self.execution_context.wait_for_human(
|
|
565
|
+
request_type="upload",
|
|
566
|
+
message=message,
|
|
567
|
+
timeout_seconds=timeout,
|
|
568
|
+
default_value=None,
|
|
569
|
+
options=None,
|
|
570
|
+
metadata=metadata,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_upload")
|
|
574
|
+
|
|
575
|
+
return response.value
|
|
576
|
+
|
|
577
|
+
def _parse_size(self, size_str: str) -> int:
|
|
578
|
+
"""Parse human-readable size string to bytes."""
|
|
579
|
+
size_str = size_str.strip().upper()
|
|
580
|
+
multipliers = {
|
|
581
|
+
"B": 1,
|
|
582
|
+
"KB": 1024,
|
|
583
|
+
"MB": 1024 * 1024,
|
|
584
|
+
"GB": 1024 * 1024 * 1024,
|
|
585
|
+
}
|
|
586
|
+
for suffix, multiplier in multipliers.items():
|
|
587
|
+
if size_str.endswith(suffix):
|
|
588
|
+
try:
|
|
589
|
+
return int(float(size_str[: -len(suffix)].strip()) * multiplier)
|
|
590
|
+
except ValueError:
|
|
591
|
+
pass
|
|
592
|
+
# Try parsing as raw number
|
|
593
|
+
try:
|
|
594
|
+
return int(size_str)
|
|
595
|
+
except ValueError:
|
|
596
|
+
logger.warning(f"Could not parse size '{size_str}', using default")
|
|
597
|
+
return 10 * 1024 * 1024 # Default 10MB
|
|
598
|
+
|
|
599
|
+
def inputs(self, items: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
600
|
+
"""
|
|
601
|
+
Request multiple inputs from human in a single interaction (BLOCKING).
|
|
602
|
+
|
|
603
|
+
DEPRECATED: Use Human.multiple() instead for clearer naming.
|
|
604
|
+
This method will be removed in a future version.
|
|
605
|
+
|
|
606
|
+
Presents inputs as tabs in the UI, allowing the human to fill them all
|
|
607
|
+
before submitting a single response.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
items: List of input items, each with:
|
|
611
|
+
- id: str - Unique ID for this item (required)
|
|
612
|
+
- label: str - Short label for tabs (required)
|
|
613
|
+
- type: str - Request type: "approval", "input", "select", etc. (required)
|
|
614
|
+
- message: str - Prompt for this input (required)
|
|
615
|
+
- options: List - Options for select/review types
|
|
616
|
+
- required: bool - Whether this input is required (default: True)
|
|
617
|
+
- metadata: Dict - Type-specific metadata
|
|
618
|
+
- timeout: int - Timeout in seconds
|
|
619
|
+
- default: Any - Default value
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Dict keyed by item ID with response values:
|
|
623
|
+
{
|
|
624
|
+
"target": "production",
|
|
625
|
+
"confirm": True,
|
|
626
|
+
"notes": "Deploy notes..."
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
Example (Lua):
|
|
630
|
+
local responses = Human.inputs({
|
|
631
|
+
{
|
|
632
|
+
id = "target",
|
|
633
|
+
label = "Target",
|
|
634
|
+
type = "select",
|
|
635
|
+
message = "Which environment?",
|
|
636
|
+
options = {"staging", "production"}
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id = "confirm",
|
|
640
|
+
label = "Confirm",
|
|
641
|
+
type = "approval",
|
|
642
|
+
message = "Are you sure?"
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
id = "notes",
|
|
646
|
+
label = "Notes",
|
|
647
|
+
type = "input",
|
|
648
|
+
message = "Any notes?",
|
|
649
|
+
required = false
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
if responses.confirm then
|
|
654
|
+
deploy(responses.target, responses.notes)
|
|
655
|
+
end
|
|
656
|
+
"""
|
|
657
|
+
# Deprecation warning
|
|
658
|
+
logger.warning(
|
|
659
|
+
"Human.inputs() is deprecated. Use Human.multiple() instead for clearer naming. "
|
|
660
|
+
"This method will be removed in a future version."
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Convert Lua tables to Python dicts recursively
|
|
664
|
+
logger.debug(f"Human.inputs() called with items type: {type(items)}")
|
|
665
|
+
items_list = self._convert_lua_to_python(items) or []
|
|
666
|
+
logger.debug(
|
|
667
|
+
f"Converted to items_list, length: {len(items_list)}, type: {type(items_list)}"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if not items_list:
|
|
671
|
+
raise ValueError("Human.inputs() requires at least one item")
|
|
672
|
+
|
|
673
|
+
# Validate items
|
|
674
|
+
seen_ids = set()
|
|
675
|
+
for idx, item in enumerate(items_list):
|
|
676
|
+
logger.debug(
|
|
677
|
+
f"Validating item {idx}: type={type(item)}, keys={list(item.keys()) if isinstance(item, dict) else 'NOT A DICT'}"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# Ensure item is a dict
|
|
681
|
+
if not isinstance(item, dict):
|
|
682
|
+
raise ValueError(
|
|
683
|
+
f"Item {idx} is not a dictionary (got {type(item).__name__}): {item}"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Validate required fields
|
|
687
|
+
if "id" not in item:
|
|
688
|
+
raise ValueError("Each item must have an 'id' field")
|
|
689
|
+
if "label" not in item:
|
|
690
|
+
raise ValueError("Each item must have a 'label' field")
|
|
691
|
+
if "type" not in item:
|
|
692
|
+
raise ValueError("Each item must have a 'type' field")
|
|
693
|
+
if "message" not in item:
|
|
694
|
+
raise ValueError("Each item must have a 'message' field")
|
|
695
|
+
|
|
696
|
+
# Check for duplicate IDs
|
|
697
|
+
item_id = item["id"]
|
|
698
|
+
if item_id in seen_ids:
|
|
699
|
+
raise ValueError(f"Duplicate item ID: {item_id}")
|
|
700
|
+
seen_ids.add(item_id)
|
|
701
|
+
|
|
702
|
+
logger.info(f"Human inputs requested: {len(items_list)} items")
|
|
703
|
+
|
|
704
|
+
# Build ControlRequestItem list
|
|
705
|
+
from tactus.protocols.control import ControlRequestItem
|
|
706
|
+
|
|
707
|
+
request_items = []
|
|
708
|
+
for item in items_list:
|
|
709
|
+
# Convert options if present
|
|
710
|
+
options_list = item.get("options", [])
|
|
711
|
+
formatted_options = []
|
|
712
|
+
for opt in options_list:
|
|
713
|
+
if isinstance(opt, dict) and "label" in opt:
|
|
714
|
+
formatted_options.append(opt)
|
|
715
|
+
elif isinstance(opt, dict) and "value" in opt:
|
|
716
|
+
formatted_options.append({"label": str(opt["value"]), "value": opt["value"]})
|
|
717
|
+
else:
|
|
718
|
+
formatted_options.append({"label": str(opt), "value": opt})
|
|
719
|
+
|
|
720
|
+
# Build metadata
|
|
721
|
+
metadata = item.get("metadata", {})
|
|
722
|
+
|
|
723
|
+
# Create ControlRequestItem
|
|
724
|
+
request_item = ControlRequestItem(
|
|
725
|
+
item_id=item["id"],
|
|
726
|
+
label=item["label"],
|
|
727
|
+
request_type=item["type"],
|
|
728
|
+
message=item["message"],
|
|
729
|
+
options=formatted_options,
|
|
730
|
+
default_value=item.get("default"),
|
|
731
|
+
required=item.get("required", True),
|
|
732
|
+
metadata=metadata,
|
|
733
|
+
)
|
|
734
|
+
request_items.append(request_item)
|
|
735
|
+
|
|
736
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
737
|
+
def checkpoint_fn():
|
|
738
|
+
return self.execution_context.wait_for_human(
|
|
739
|
+
request_type="inputs",
|
|
740
|
+
message=f"Multiple inputs requested ({len(items_list)} items)",
|
|
741
|
+
timeout_seconds=None, # Individual items can have timeouts
|
|
742
|
+
default_value={},
|
|
743
|
+
options=None,
|
|
744
|
+
metadata={"items": [item.model_dump() for item in request_items]},
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_inputs")
|
|
748
|
+
|
|
749
|
+
# Response value should be a dict keyed by item ID
|
|
750
|
+
result = response.value if isinstance(response.value, dict) else {}
|
|
751
|
+
|
|
752
|
+
# Convert Python lists to Lua tables for nested values
|
|
753
|
+
# This is needed when frontend returns arrays (e.g., multi-select results)
|
|
754
|
+
lua_runtime = self.execution_context.lua_sandbox.lua
|
|
755
|
+
converted_result = {}
|
|
756
|
+
for key, value in result.items():
|
|
757
|
+
if isinstance(value, list):
|
|
758
|
+
# Convert Python list to Lua table
|
|
759
|
+
converted_result[key] = lua_runtime.table_from(value)
|
|
760
|
+
else:
|
|
761
|
+
converted_result[key] = value
|
|
762
|
+
|
|
763
|
+
return lua_runtime.table_from(converted_result)
|
|
764
|
+
|
|
765
|
+
def multiple(self, items: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
766
|
+
"""
|
|
767
|
+
Request multiple inputs from human in a single interaction (BLOCKING).
|
|
768
|
+
|
|
769
|
+
This is the preferred method name for collecting multiple inputs.
|
|
770
|
+
Use this instead of inputs() for clearer intent.
|
|
771
|
+
|
|
772
|
+
Presents inputs in a unified UI (inline or modal), allowing the human to fill
|
|
773
|
+
them all before submitting a single response.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
items: List of input items, each with:
|
|
777
|
+
- id: str - Unique ID for this item (required)
|
|
778
|
+
- label: str - Short label for tabs (required)
|
|
779
|
+
- type: str - Request type: "approval", "input", "select", etc. (required)
|
|
780
|
+
- message: str - Prompt for this input (required)
|
|
781
|
+
- options: List - Options for select/review types
|
|
782
|
+
- required: bool - Whether this input is required (default: True)
|
|
783
|
+
- metadata: Dict - Type-specific metadata
|
|
784
|
+
- timeout: int - Timeout in seconds
|
|
785
|
+
- default: Any - Default value
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
Dict keyed by item ID with response values:
|
|
789
|
+
{
|
|
790
|
+
"target": "production",
|
|
791
|
+
"confirm": True,
|
|
792
|
+
"notes": "Deploy notes..."
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
Example (Lua):
|
|
796
|
+
local responses = Human.multiple({
|
|
797
|
+
{
|
|
798
|
+
id = "target",
|
|
799
|
+
label = "Target",
|
|
800
|
+
type = "select",
|
|
801
|
+
message = "Which environment?",
|
|
802
|
+
options = {"staging", "production"}
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
id = "confirm",
|
|
806
|
+
label = "Confirm",
|
|
807
|
+
type = "approval",
|
|
808
|
+
message = "Are you sure?"
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
id = "notes",
|
|
812
|
+
label = "Notes",
|
|
813
|
+
type = "input",
|
|
814
|
+
message = "Any notes?",
|
|
815
|
+
required = false
|
|
816
|
+
}
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
if responses.confirm then
|
|
820
|
+
deploy(responses.target, responses.notes)
|
|
821
|
+
end
|
|
822
|
+
"""
|
|
823
|
+
return self.inputs(items)
|
|
824
|
+
|
|
825
|
+
def custom(self, options: Optional[Dict[str, Any]] = None) -> Any:
|
|
826
|
+
"""
|
|
827
|
+
Request custom component interaction from human (BLOCKING).
|
|
828
|
+
|
|
829
|
+
Renders a custom UI component specified by metadata.component_type.
|
|
830
|
+
The component receives all metadata and can return arbitrary values.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
options: Dict with:
|
|
834
|
+
- component_type: str - Which custom component to render (required)
|
|
835
|
+
- message: str - Message to display (required)
|
|
836
|
+
- data: Dict - Component-specific data (images, options, etc.)
|
|
837
|
+
- actions: List[Dict] - Optional action buttons
|
|
838
|
+
- timeout: int - Timeout in seconds
|
|
839
|
+
- default: Any - Default value if timeout
|
|
840
|
+
- config_key: str - Reference to hitl: declaration
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Any - Value returned by the custom component
|
|
844
|
+
Could be a simple value (string, dict) or complex object
|
|
845
|
+
depending on the component implementation
|
|
846
|
+
|
|
847
|
+
Example (Lua):
|
|
848
|
+
local result = Human.custom({
|
|
849
|
+
component_type = "image-selector",
|
|
850
|
+
message = "Select your favorite image",
|
|
851
|
+
data = {
|
|
852
|
+
images = {
|
|
853
|
+
{url = "https://...", label = "Option 1"},
|
|
854
|
+
{url = "https://...", label = "Option 2"}
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
actions = {
|
|
858
|
+
{id = "regenerate", label = "Regenerate", style = "secondary"}
|
|
859
|
+
}
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
if result.action == "regenerate" then
|
|
863
|
+
-- User clicked regenerate
|
|
864
|
+
return {regenerate = true}
|
|
865
|
+
else
|
|
866
|
+
-- User selected an image
|
|
867
|
+
return {selected_url = result}
|
|
868
|
+
end
|
|
869
|
+
"""
|
|
870
|
+
if not isinstance(options, dict):
|
|
871
|
+
raise TypeError("custom() requires a dict argument with component_type and message")
|
|
872
|
+
|
|
873
|
+
component_type = options.get("component_type")
|
|
874
|
+
if not component_type:
|
|
875
|
+
raise ValueError("custom() requires 'component_type' field in options")
|
|
876
|
+
|
|
877
|
+
message = options.get("message")
|
|
878
|
+
if not message:
|
|
879
|
+
raise ValueError("custom() requires 'message' field in options")
|
|
880
|
+
|
|
881
|
+
# Extract parameters
|
|
882
|
+
data = options.get("data", {})
|
|
883
|
+
actions = options.get("actions", [])
|
|
884
|
+
timeout = options.get("timeout")
|
|
885
|
+
default = options.get("default")
|
|
886
|
+
config_key = options.get("config_key")
|
|
887
|
+
|
|
888
|
+
# Build metadata with custom component info
|
|
889
|
+
metadata = {
|
|
890
|
+
"component_type": component_type,
|
|
891
|
+
"data": data,
|
|
892
|
+
"actions": actions,
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
logger.info(f"Human custom component requested: {component_type}")
|
|
896
|
+
|
|
897
|
+
# CRITICAL: Wrap HITL call in checkpoint for transparent durability
|
|
898
|
+
def checkpoint_fn():
|
|
899
|
+
return self.execution_context.wait_for_human(
|
|
900
|
+
request_type="custom",
|
|
901
|
+
message=message,
|
|
902
|
+
timeout_seconds=timeout,
|
|
903
|
+
default_value=default,
|
|
904
|
+
options=None,
|
|
905
|
+
metadata=metadata,
|
|
906
|
+
config_key=config_key,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
response = self.execution_context.checkpoint(checkpoint_fn, "hitl_custom")
|
|
910
|
+
|
|
911
|
+
return response.value
|
|
912
|
+
|
|
341
913
|
def __repr__(self) -> str:
|
|
342
914
|
return f"HumanPrimitive(config_keys={list(self.hitl_config.keys())})"
|