tactus 0.33.0__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.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
tactus/cli/app.py CHANGED
@@ -31,7 +31,6 @@ from tactus.validation import TactusValidator, ValidationMode
31
31
  from tactus.formatting import TactusFormatter, FormattingError
32
32
  from tactus.adapters.memory import MemoryStorage
33
33
  from tactus.adapters.file_storage import FileStorage
34
- from tactus.adapters.cli_hitl import CLIHITLHandler
35
34
 
36
35
  # Setup rich console for pretty output
37
36
  console = Console()
@@ -594,8 +593,8 @@ def run(
594
593
  console.print(f"[red]Error:[/red] Unknown storage backend: {storage}")
595
594
  raise typer.Exit(1)
596
595
 
597
- # Setup HITL handler
598
- hitl_handler = CLIHITLHandler(console=console)
596
+ # HITL handler will be set up later after procedure_id is known
597
+ hitl_handler = None
599
598
 
600
599
  # Load configuration cascade
601
600
  from tactus.core.config_manager import ConfigManager
@@ -627,6 +626,10 @@ def run(
627
626
  if sandbox is not None:
628
627
  # CLI flag overrides config
629
628
  sandbox_config_dict["enabled"] = sandbox
629
+ else:
630
+ # CLI default: require sandbox unless explicitly disabled
631
+ if sandbox_config_dict.get("enabled") is None:
632
+ sandbox_config_dict["enabled"] = True
630
633
  if sandbox_network is not None:
631
634
  sandbox_config_dict["network"] = sandbox_network
632
635
  if sandbox_broker_host is not None:
@@ -698,6 +701,23 @@ def run(
698
701
 
699
702
  # Create runtime
700
703
  procedure_id = f"cli-{workflow_file.stem}"
704
+
705
+ # Setup HITL handler - use new ControlLoopHandler with default channels
706
+ from tactus.adapters.channels import load_default_channels
707
+ from tactus.adapters.channels.cli import CLIControlChannel
708
+ from tactus.adapters.control_loop import ControlLoopHandler, ControlLoopHITLAdapter
709
+
710
+ # Load default channels (CLI if tty, IPC always)
711
+ # Then add CLI with custom console if not already present
712
+ channels = load_default_channels(procedure_id=procedure_id)
713
+
714
+ # If CLI channel not already loaded (because not tty), add it with custom console
715
+ if not any(c.channel_id == "cli" for c in channels):
716
+ channels.insert(0, CLIControlChannel(console=console))
717
+
718
+ control_handler = ControlLoopHandler(channels=channels, storage=storage_backend)
719
+ hitl_handler = ControlLoopHITLAdapter(control_handler)
720
+
701
721
  runtime = TactusRuntime(
702
722
  procedure_id=procedure_id,
703
723
  storage_backend=storage_backend,
@@ -800,7 +820,48 @@ def run(
800
820
  console.print(f"[dim]{sandbox_result.traceback}[/dim]")
801
821
  else:
802
822
  # Execute directly (non-sandboxed)
803
- result = asyncio.run(runtime.execute(source_content, context, format=file_format))
823
+ try:
824
+ result = asyncio.run(runtime.execute(source_content, context, format=file_format))
825
+ except Exception as e:
826
+ from tactus.core.exceptions import ProcedureWaitingForHuman
827
+
828
+ # Check both the exception itself and its __cause__
829
+ console.print(f"[dim]DEBUG: Caught exception type: {type(e).__name__}[/dim]")
830
+ console.print(
831
+ f"[dim]DEBUG: Exception __cause__ type: {type(e.__cause__).__name__ if e.__cause__ else 'None'}[/dim]"
832
+ )
833
+ console.print(
834
+ f"[dim]DEBUG: Is ProcedureWaitingForHuman: {isinstance(e, ProcedureWaitingForHuman)}[/dim]"
835
+ )
836
+ console.print(
837
+ f"[dim]DEBUG: __cause__ is ProcedureWaitingForHuman: {isinstance(e.__cause__, ProcedureWaitingForHuman) if e.__cause__ else False}[/dim]"
838
+ )
839
+
840
+ if isinstance(e, ProcedureWaitingForHuman):
841
+ # Direct exception
842
+ console.print(
843
+ "\n[yellow]⏸ Procedure paused - waiting for human response[/yellow]"
844
+ )
845
+ console.print(f"[dim]Message ID: {e.pending_message_id}[/dim]")
846
+ console.print("\n[cyan]The procedure has been paused and is waiting for input.")
847
+ console.print(
848
+ "To resume, run the procedure again or provide a response via another channel.[/cyan]\n"
849
+ )
850
+ return
851
+ elif e.__cause__ and isinstance(e.__cause__, ProcedureWaitingForHuman):
852
+ # Wrapped exception
853
+ console.print(
854
+ "\n[yellow]⏸ Procedure paused - waiting for human response[/yellow]"
855
+ )
856
+ console.print(f"[dim]Message ID: {e.__cause__.pending_message_id}[/dim]")
857
+ console.print("\n[cyan]The procedure has been paused and is waiting for input.")
858
+ console.print(
859
+ "To resume, run the procedure again or provide a response via another channel.[/cyan]\n"
860
+ )
861
+ return
862
+ else:
863
+ # Re-raise other exceptions
864
+ raise
804
865
 
805
866
  if result["success"]:
806
867
  console.print("\n[green]✓ Procedure completed successfully[/green]\n")
@@ -899,6 +960,7 @@ def sandbox_rebuild(
899
960
  """
900
961
  from pathlib import Path
901
962
  from tactus.sandbox import is_docker_available, DockerManager
963
+ from tactus.sandbox.docker_manager import resolve_dockerfile_path
902
964
  import tactus
903
965
 
904
966
  # Check Docker availability
@@ -909,7 +971,7 @@ def sandbox_rebuild(
909
971
 
910
972
  # Get Tactus package path for build context
911
973
  tactus_path = Path(tactus.__file__).parent.parent
912
- dockerfile_path = tactus_path / "tactus" / "docker" / "Dockerfile"
974
+ dockerfile_path, build_mode = resolve_dockerfile_path(tactus_path)
913
975
 
914
976
  if not dockerfile_path.exists():
915
977
  console.print(f"[red]Error:[/red] Dockerfile not found: {dockerfile_path}")
@@ -933,6 +995,10 @@ def sandbox_rebuild(
933
995
  console.print(f"[blue]Building sandbox image:[/blue] {manager.full_image_name}")
934
996
  console.print(f"[dim]Version: {version}[/dim]")
935
997
  console.print(f"[dim]Context: {tactus_path}[/dim]\n")
998
+ if build_mode == "pypi":
999
+ console.print(
1000
+ "[yellow]No source tree detected; building image by installing tactus from PyPI.[/yellow]"
1001
+ )
936
1002
 
937
1003
  success, message = manager.build_image(
938
1004
  dockerfile_path=dockerfile_path,
@@ -1423,7 +1489,10 @@ def test(
1423
1489
  evaluator = TactusEvaluationRunner(
1424
1490
  procedure_file, mock_tools=mock_tools, params=test_params
1425
1491
  )
1426
- evaluator.setup(result.registry.gherkin_specifications)
1492
+ evaluator.setup(
1493
+ result.registry.gherkin_specifications,
1494
+ custom_steps_dict=result.registry.custom_steps,
1495
+ )
1427
1496
 
1428
1497
  if scenario:
1429
1498
  eval_results = [evaluator.evaluate_scenario(scenario, runs, parallel)]
@@ -1436,7 +1505,10 @@ def test(
1436
1505
  else:
1437
1506
  # Run standard test
1438
1507
  runner = TactusTestRunner(procedure_file, mock_tools=mock_tools, params=test_params)
1439
- runner.setup(result.registry.gherkin_specifications)
1508
+ runner.setup(
1509
+ result.registry.gherkin_specifications,
1510
+ custom_steps_dict=result.registry.custom_steps,
1511
+ )
1440
1512
 
1441
1513
  test_result = runner.run_tests(parallel=parallel, scenario_filter=scenario)
1442
1514
 
@@ -2211,6 +2283,195 @@ def trace_export(
2211
2283
  raise typer.Exit(1)
2212
2284
 
2213
2285
 
2286
+ # =============================================================================
2287
+ # Stdlib Commands
2288
+ # =============================================================================
2289
+
2290
+ stdlib_app = typer.Typer(help="Manage Tactus standard library")
2291
+ app.add_typer(stdlib_app, name="stdlib")
2292
+
2293
+
2294
+ @stdlib_app.command("test")
2295
+ def stdlib_test(
2296
+ module: Optional[str] = typer.Argument(
2297
+ None, help="Specific module to test (e.g., 'classify', 'extract')"
2298
+ ),
2299
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
2300
+ parallel: bool = typer.Option(True, "--parallel/--no-parallel", help="Run in parallel"),
2301
+ ):
2302
+ """
2303
+ Run BDD tests for standard library modules.
2304
+
2305
+ Examples:
2306
+ tactus stdlib test # Run all stdlib tests
2307
+ tactus stdlib test classify # Run only classify tests
2308
+ tactus stdlib test extract # Run only extract tests
2309
+ """
2310
+ import tactus
2311
+ from tactus.validation import TactusValidator
2312
+ from tactus.testing.test_runner import TactusTestRunner
2313
+
2314
+ # Find stdlib spec files
2315
+ package_root = Path(tactus.__file__).parent
2316
+ stdlib_tac_path = package_root / "stdlib" / "tac" / "tactus"
2317
+
2318
+ # Find all .spec.tac files
2319
+ if module:
2320
+ # Test specific module
2321
+ spec_file = stdlib_tac_path / f"{module}.spec.tac"
2322
+ if not spec_file.exists():
2323
+ console.print(f"[red]Module spec not found: {spec_file}[/red]")
2324
+ raise typer.Exit(1)
2325
+ spec_files = [spec_file]
2326
+ else:
2327
+ # Test all modules
2328
+ spec_files = list(stdlib_tac_path.glob("*.spec.tac"))
2329
+
2330
+ if not spec_files:
2331
+ console.print("[yellow]No spec files found in stdlib[/yellow]")
2332
+ raise typer.Exit(0)
2333
+
2334
+ console.print(f"[cyan]Found {len(spec_files)} spec file(s) to test[/cyan]")
2335
+
2336
+ total_passed = 0
2337
+ total_failed = 0
2338
+ failed_modules = []
2339
+
2340
+ validator = TactusValidator()
2341
+
2342
+ for spec_file in sorted(spec_files):
2343
+ module_name = spec_file.stem.replace(".spec", "")
2344
+ console.print(f"\n[bold]Testing: {module_name}[/bold]")
2345
+
2346
+ # Validate and load specs
2347
+ result = validator.validate_file(str(spec_file))
2348
+
2349
+ if not result.valid:
2350
+ console.print(" [red]✗ Validation failed[/red]")
2351
+ for error in result.errors:
2352
+ console.print(f" {error.message}")
2353
+ total_failed += 1
2354
+ failed_modules.append(module_name)
2355
+ continue
2356
+
2357
+ if not result.registry or not result.registry.gherkin_specifications:
2358
+ console.print(" [yellow]⚠ No specifications found[/yellow]")
2359
+ continue
2360
+
2361
+ # Run tests
2362
+ try:
2363
+ runner = TactusTestRunner(spec_file, mock_tools={}, params={})
2364
+ runner.setup(
2365
+ result.registry.gherkin_specifications,
2366
+ custom_steps_dict=result.registry.custom_steps,
2367
+ )
2368
+
2369
+ test_result = runner.run_tests(parallel=parallel, scenario_filter=None)
2370
+
2371
+ # Display results
2372
+ passed = test_result.passed_scenarios
2373
+ failed = test_result.failed_scenarios
2374
+ total_passed += passed
2375
+ total_failed += failed
2376
+
2377
+ if failed > 0:
2378
+ console.print(f" [red]✗ {passed} passed, {failed} failed[/red]")
2379
+ for feature in test_result.features:
2380
+ for scenario in feature.scenarios:
2381
+ if scenario.status != "failed":
2382
+ continue
2383
+ console.print(f" [red]Scenario failed:[/red] {scenario.name}")
2384
+ for step in scenario.steps:
2385
+ if step.status != "failed":
2386
+ continue
2387
+ error_detail = step.error_message or "Unknown failure"
2388
+ console.print(
2389
+ f" [red]{step.keyword} {step.message}[/red]: {error_detail}"
2390
+ )
2391
+ failed_modules.append(module_name)
2392
+ else:
2393
+ console.print(f" [green]✓ {passed} scenarios passed[/green]")
2394
+
2395
+ runner.cleanup()
2396
+
2397
+ except Exception as e:
2398
+ console.print(f" [red]✗ Error: {e}[/red]")
2399
+ if verbose:
2400
+ console.print_exception()
2401
+ total_failed += 1
2402
+ failed_modules.append(module_name)
2403
+
2404
+ # Summary
2405
+ console.print("\n" + "=" * 50)
2406
+ console.print("[bold]Stdlib Test Summary[/bold]")
2407
+ console.print(f" Passed: [green]{total_passed}[/green]")
2408
+ console.print(f" Failed: [red]{total_failed}[/red]")
2409
+
2410
+ if failed_modules:
2411
+ console.print(f"\n[red]Failed modules: {', '.join(failed_modules)}[/red]")
2412
+ raise typer.Exit(1)
2413
+
2414
+ console.print("\n[green]All stdlib tests passed![/green]")
2415
+
2416
+
2417
+ # =============================================================================
2418
+ # Control Command
2419
+ # =============================================================================
2420
+
2421
+
2422
+ @app.command()
2423
+ def control(
2424
+ socket_path: Optional[str] = typer.Option(
2425
+ None,
2426
+ "--socket",
2427
+ "-s",
2428
+ help="Path to runtime's Unix socket (default: auto-detect from /tmp/tactus-control-*.sock)",
2429
+ ),
2430
+ auto_respond: Optional[str] = typer.Option(
2431
+ None, "--respond", "-r", help="Auto-respond with this value (for testing)"
2432
+ ),
2433
+ ):
2434
+ """
2435
+ Connect to running procedure and respond to control requests.
2436
+
2437
+ Opens an interactive session that connects to a running Tactus procedure
2438
+ via Unix socket IPC. Control requests from Human.approve() and similar
2439
+ calls will appear here, and you can respond to them.
2440
+
2441
+ This allows running the procedure in one terminal and responding to
2442
+ control requests from another terminal.
2443
+ """
2444
+ from tactus.cli.control import main as control_main
2445
+ import glob
2446
+
2447
+ # Auto-detect socket path if not provided
2448
+ if socket_path is None:
2449
+ # Look for sockets in /tmp/tactus-control-*.sock
2450
+ socket_files = glob.glob("/tmp/tactus-control-*.sock")
2451
+ if not socket_files:
2452
+ console.print("[red]✗ No Tactus runtime sockets found[/red]")
2453
+ console.print("\n[yellow]Make sure a Tactus procedure is running:[/yellow]")
2454
+ console.print(" [dim]tactus run examples/90-hitl-simple.tac[/dim]")
2455
+ raise typer.Exit(1)
2456
+ elif len(socket_files) == 1:
2457
+ socket_path = socket_files[0]
2458
+ console.print(f"[dim]Auto-detected socket: {socket_path}[/dim]")
2459
+ else:
2460
+ console.print("[yellow]Multiple runtime sockets found:[/yellow]")
2461
+ for i, path in enumerate(socket_files, 1):
2462
+ console.print(f" [{i}] {path}")
2463
+ console.print()
2464
+ selection = Prompt.ask(
2465
+ "Select socket",
2466
+ choices=[str(i) for i in range(1, len(socket_files) + 1)],
2467
+ default="1",
2468
+ )
2469
+ socket_path = socket_files[int(selection) - 1]
2470
+
2471
+ # Run control CLI
2472
+ asyncio.run(control_main(socket_path, auto_respond))
2473
+
2474
+
2214
2475
  def main():
2215
2476
  """Main entry point for the CLI."""
2216
2477
  # Load configuration before processing any commands
@@ -2228,6 +2489,8 @@ def main():
2228
2489
  "eval",
2229
2490
  "version",
2230
2491
  "ide",
2492
+ "stdlib",
2493
+ "control",
2231
2494
  "trace-list",
2232
2495
  "trace-show",
2233
2496
  "trace-export",