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
@@ -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
- # Convert Lua table to dict
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 obj.items():
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
- # Delegate to execution context's wait_for_human
106
- response = self.execution_context.wait_for_human(
107
- request_type="approval",
108
- message=message,
109
- timeout_seconds=timeout,
110
- default_value=default,
111
- options=None,
112
- metadata=context,
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
- # Delegate to execution context
161
- response = self.execution_context.wait_for_human(
162
- request_type="input",
163
- message=message,
164
- timeout_seconds=timeout,
165
- default_value=default,
166
- options=None,
167
- metadata={"placeholder": placeholder},
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
- # Delegate to execution context
235
- response = self.execution_context.wait_for_human(
236
- request_type="review",
237
- message=message,
238
- timeout_seconds=timeout,
239
- default_value={
240
- "decision": "reject",
241
- "edited_artifact": artifact_python,
242
- "feedback": "",
243
- },
244
- options=formatted_options,
245
- metadata={"artifact": artifact_python, "artifact_type": artifact_type},
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
- # Delegate to execution context
329
- # No timeout, no default - blocks until human resolves
330
- self.execution_context.wait_for_human(
331
- request_type="escalation",
332
- message=message,
333
- timeout_seconds=None, # No timeout - wait indefinitely
334
- default_value=None, # No default - human must resolve
335
- options=None,
336
- metadata=metadata,
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())})"