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/core/runtime.py CHANGED
@@ -102,7 +102,31 @@ class TactusRuntime:
102
102
  """
103
103
  self.procedure_id = procedure_id
104
104
  self.storage_backend = storage_backend
105
- self.hitl_handler = hitl_handler
105
+
106
+ # Initialize HITL handler - use new ControlLoopHandler by default
107
+ if hitl_handler is None:
108
+ # Auto-configure control loop with channels
109
+ from tactus.adapters.channels import load_default_channels
110
+ from tactus.adapters.control_loop import ControlLoopHandler, ControlLoopHITLAdapter
111
+
112
+ channels = load_default_channels(procedure_id=procedure_id)
113
+ if channels:
114
+ control_handler = ControlLoopHandler(
115
+ channels=channels,
116
+ storage=storage_backend,
117
+ )
118
+ # Wrap in adapter for HITLHandler compatibility
119
+ self.hitl_handler = ControlLoopHITLAdapter(control_handler)
120
+ logger.info(f"Auto-configured ControlLoopHandler with {len(channels)} channel(s)")
121
+ else:
122
+ # No channels available, leave hitl_handler as None
123
+ self.hitl_handler = None
124
+ logger.warning(
125
+ "No control channels available - HITL interactions will use defaults"
126
+ )
127
+ else:
128
+ self.hitl_handler = hitl_handler
129
+
106
130
  self.chat_recorder = chat_recorder
107
131
  self.mcp_server = mcp_server # Keep for backward compatibility
108
132
  self.mcp_servers = mcp_servers or {}
@@ -272,8 +296,7 @@ class TactusRuntime:
272
296
  self.lua_sandbox.inject_primitive("_state_primitive", placeholder_state)
273
297
 
274
298
  # Create State object with special methods and lowercase state proxy with metatable
275
- self.lua_sandbox.lua.execute(
276
- """
299
+ self.lua_sandbox.lua.execute("""
277
300
  State = {
278
301
  increment = function(key, amount)
279
302
  return _state_primitive.increment(key, amount or 1)
@@ -295,8 +318,7 @@ class TactusRuntime:
295
318
  _state_primitive.set(key, value)
296
319
  end
297
320
  })
298
- """
299
- )
321
+ """)
300
322
  self.lua_sandbox.inject_primitive("Tool", placeholder_tool)
301
323
  self.lua_sandbox.inject_primitive("params", placeholder_params)
302
324
  placeholder_system = LuaSystemPrimitive(
@@ -692,6 +714,11 @@ class TactusRuntime:
692
714
  "error": f"Lua execution error: {e}",
693
715
  }
694
716
 
717
+ except ProcedureWaitingForHuman:
718
+ # Re-raise this exception to trigger exit-and-resume pattern
719
+ # Don't treat this as an error - it's expected behavior
720
+ raise
721
+
695
722
  except Exception as e:
696
723
  logger.error(f"Unexpected error: {e}", exc_info=True)
697
724
 
@@ -931,15 +958,66 @@ class TactusRuntime:
931
958
  )
932
959
 
933
960
  # 6. Register DSL-defined toolsets from registry (after individual tools are registered)
961
+ # DEBUG: Write to stderr which should show up in logs
962
+ import sys
963
+
964
+ sys.stderr.write("\n\n=== DSL TOOLSET REGISTRATION START ===\n")
965
+ sys.stderr.write(f"Has registry: {hasattr(self, 'registry')}\n")
966
+ if hasattr(self, "registry") and self.registry:
967
+ sys.stderr.write("Registry is not None: True\n")
968
+ sys.stderr.write(f"Registry has toolsets attr: {hasattr(self.registry, 'toolsets')}\n")
969
+ if hasattr(self.registry, "toolsets"):
970
+ sys.stderr.write(f"Registry toolsets: {list(self.registry.toolsets.keys())}\n")
971
+ sys.stderr.write(f"Registry toolsets count: {len(self.registry.toolsets)}\n")
972
+ else:
973
+ sys.stderr.write("Registry is None or doesn't exist\n")
974
+ sys.stderr.flush()
975
+
976
+ logger.info("=== DSL TOOLSET REGISTRATION START ===")
977
+ logger.info(f"Has registry: {hasattr(self, 'registry')}")
978
+ logger.info(
979
+ f"Registry is not None: {self.registry is not None if hasattr(self, 'registry') else False}"
980
+ )
981
+ if hasattr(self, "registry") and self.registry:
982
+ logger.info(f"Registry has toolsets attr: {hasattr(self.registry, 'toolsets')}")
983
+ if hasattr(self.registry, "toolsets"):
984
+ logger.info(f"Registry toolsets: {list(self.registry.toolsets.keys())}")
985
+ logger.info(f"Registry toolsets count: {len(self.registry.toolsets)}")
986
+
934
987
  if hasattr(self, "registry") and self.registry and hasattr(self.registry, "toolsets"):
988
+ sys.stderr.write(f"Processing {len(self.registry.toolsets)} DSL toolsets\n")
989
+ sys.stderr.flush()
990
+ logger.info(f"Processing {len(self.registry.toolsets)} DSL toolsets")
935
991
  for name, definition in self.registry.toolsets.items():
992
+ sys.stderr.write(
993
+ f"Creating DSL toolset '{name}' with config keys: {list(definition.keys())}\n"
994
+ )
995
+ sys.stderr.flush()
996
+ logger.info(
997
+ f"Creating DSL toolset '{name}' with config keys: {list(definition.keys())}"
998
+ )
936
999
  try:
937
1000
  toolset = await self._create_toolset_from_config(name, definition)
938
1001
  if toolset:
939
1002
  self.toolset_registry[name] = toolset
940
- logger.info(f"Registered DSL-defined toolset '{name}'")
1003
+ sys.stderr.write(f"Registered DSL-defined toolset '{name}'\n")
1004
+ sys.stderr.flush()
1005
+ logger.info(f"✓ Registered DSL-defined toolset '{name}'")
1006
+ else:
1007
+ sys.stderr.write(f"✗ Toolset '{name}' creation returned None\n")
1008
+ sys.stderr.flush()
1009
+ logger.error(f"✗ Toolset '{name}' creation returned None")
941
1010
  except Exception as e:
942
- logger.error(f"Failed to create DSL toolset '{name}': {e}", exc_info=True)
1011
+ sys.stderr.write(f"Failed to create DSL toolset '{name}': {e}\n")
1012
+ sys.stderr.flush()
1013
+ logger.error(f"✗ Failed to create DSL toolset '{name}': {e}", exc_info=True)
1014
+ else:
1015
+ sys.stderr.write("No DSL toolsets to register (registry.toolsets not available)\n")
1016
+ sys.stderr.flush()
1017
+ logger.warning("No DSL toolsets to register (registry.toolsets not available)")
1018
+ sys.stderr.write("=== DSL TOOLSET REGISTRATION END ===\n")
1019
+ sys.stderr.flush()
1020
+ logger.info("=== DSL TOOLSET REGISTRATION END ===")
943
1021
 
944
1022
  logger.info(
945
1023
  f"Toolset registry initialized with {len(self.toolset_registry)} toolset(s): {list(self.toolset_registry.keys())}"
@@ -1439,23 +1517,38 @@ class TactusRuntime:
1439
1517
  # DSL toolsets can have:
1440
1518
  # - "tools" field with list of tool names or inline tool definitions
1441
1519
  # - "use" field to import from a file or other source
1520
+ logger.info(f"[TOOLSET_CREATE] '{name}' has no explicit type, checking for tools/use")
1442
1521
 
1443
1522
  if "tools" in definition:
1444
1523
  # Handle tools list (can be tool names or inline definitions)
1445
1524
  tools_list = definition["tools"]
1525
+ logger.info(
1526
+ f"[TOOLSET_CREATE] '{name}' has tools field with {len(tools_list) if isinstance(tools_list, list) else '?'} items"
1527
+ )
1446
1528
 
1447
1529
  # Check if we have inline tool definitions (dicts with a Lua handler)
1448
1530
  has_inline_tools = False
1449
1531
  if isinstance(tools_list, list):
1450
- for item in tools_list:
1451
- if isinstance(item, dict) and (
1452
- "handler" in item or (1 in item and callable(item.get(1)))
1453
- ):
1454
- has_inline_tools = True
1455
- break
1532
+ for idx, item in enumerate(tools_list):
1533
+ logger.info(
1534
+ f"[TOOLSET_CREATE] Tool {idx}: type={type(item).__name__}, is_dict={isinstance(item, dict)}"
1535
+ )
1536
+ if isinstance(item, dict):
1537
+ logger.info(f"[TOOLSET_CREATE] Tool {idx} keys: {list(item.keys())}")
1538
+ has_handler = "handler" in item
1539
+ has_callable_1 = 1 in item and callable(item.get(1))
1540
+ logger.info(
1541
+ f"[TOOLSET_CREATE] Tool {idx}: has_handler={has_handler}, has_callable_1={has_callable_1}"
1542
+ )
1543
+ if has_handler or has_callable_1:
1544
+ has_inline_tools = True
1545
+ break
1546
+
1547
+ logger.info(f"[TOOLSET_CREATE] '{name}' has_inline_tools={has_inline_tools}")
1456
1548
 
1457
1549
  if has_inline_tools:
1458
1550
  # Create toolset from inline Lua tools
1551
+ logger.info(f"[TOOLSET_CREATE] Creating inline toolset for '{name}'")
1459
1552
  try:
1460
1553
  from tactus.adapters.lua_tools import LuaToolsAdapter
1461
1554
 
@@ -1464,10 +1557,15 @@ class TactusRuntime:
1464
1557
  )
1465
1558
 
1466
1559
  # Create a toolset from inline tool definitions
1467
- return lua_adapter.create_inline_toolset(name, tools_list)
1560
+ toolset = lua_adapter.create_inline_toolset(name, tools_list)
1561
+ logger.info(
1562
+ f"[TOOLSET_CREATE] ✓ Created inline toolset '{name}': {toolset}"
1563
+ )
1564
+ return toolset
1468
1565
  except Exception as e:
1469
1566
  logger.error(
1470
- f"Failed to create inline toolset '{name}': {e}", exc_info=True
1567
+ f"[TOOLSET_CREATE] ✗ Failed to create inline toolset '{name}': {e}",
1568
+ exc_info=True,
1471
1569
  )
1472
1570
  return None
1473
1571
  else:
@@ -1657,6 +1755,8 @@ class TactusRuntime:
1657
1755
  Args:
1658
1756
  context: Procedure context with pre-loaded data
1659
1757
  """
1758
+ import sys # For debug output
1759
+
1660
1760
  logger.info(
1661
1761
  f"_setup_agents called. Toolset registry has {len(self.toolset_registry)} toolsets: {list(self.toolset_registry.keys())}"
1662
1762
  )
@@ -1664,8 +1764,15 @@ class TactusRuntime:
1664
1764
  # Initialize user dependencies first (needed by agents)
1665
1765
  await self._initialize_dependencies()
1666
1766
 
1667
- # Get agent configurations
1668
- agents_config = self.config.get("agents", {})
1767
+ # Get agent configurations from registry (Lua-parsed) if available, otherwise from YAML config
1768
+ if hasattr(self, "registry") and self.registry and hasattr(self.registry, "agents"):
1769
+ agents_config = self.registry.agents
1770
+ logger.info(
1771
+ f"Using {len(agents_config)} agent(s) from registry: {list(agents_config.keys())}"
1772
+ )
1773
+ else:
1774
+ agents_config = self.config.get("agents", {})
1775
+ logger.info(f"Using {len(agents_config)} agent(s) from YAML config")
1669
1776
 
1670
1777
  if not agents_config:
1671
1778
  logger.info("No agents defined in configuration - skipping agent setup")
@@ -1682,7 +1789,18 @@ class TactusRuntime:
1682
1789
  logger.info(f"Default toolsets configured: {default_toolset_names}")
1683
1790
 
1684
1791
  # Setup each agent
1685
- for agent_name, agent_config in agents_config.items():
1792
+ for agent_name, agent_config_raw in agents_config.items():
1793
+ # Convert AgentDeclaration to dict if needed
1794
+ if hasattr(agent_config_raw, "model_dump"):
1795
+ # Pydantic v2
1796
+ agent_config = agent_config_raw.model_dump()
1797
+ elif hasattr(agent_config_raw, "dict"):
1798
+ # Pydantic v1
1799
+ agent_config = agent_config_raw.dict()
1800
+ else:
1801
+ # Already a dict
1802
+ agent_config = agent_config_raw
1803
+
1686
1804
  # Skip if agent was already created during immediate initialization
1687
1805
  if agent_name in self.agents:
1688
1806
  logger.debug(
@@ -1820,8 +1938,19 @@ class TactusRuntime:
1820
1938
  logger.info(f"Agent '{agent_name}' has NO tools (explicitly empty - passing None)")
1821
1939
  else:
1822
1940
  # Parse toolset expressions
1941
+ sys.stderr.write(
1942
+ f"\n[AGENT_SETUP] Agent '{agent_name}' raw tools config: {agent_tools_config}\n"
1943
+ )
1944
+ sys.stderr.write(
1945
+ f"[AGENT_SETUP] toolset_registry has: {list(self.toolset_registry.keys())}\n"
1946
+ )
1947
+ sys.stderr.flush()
1823
1948
  logger.info(f"Agent '{agent_name}' raw tools config: {agent_tools_config}")
1824
1949
  filtered_toolsets = self._parse_toolset_expressions(agent_tools_config)
1950
+ sys.stderr.write(
1951
+ f"[AGENT_SETUP] Agent '{agent_name}' parsed toolsets: {filtered_toolsets}\n"
1952
+ )
1953
+ sys.stderr.flush()
1825
1954
  logger.info(f"Agent '{agent_name}' parsed toolsets: {filtered_toolsets}")
1826
1955
 
1827
1956
  # Append inline tools toolset if present
@@ -1887,6 +2016,9 @@ class TactusRuntime:
1887
2016
  )
1888
2017
 
1889
2018
  # Create DSPy-based agent
2019
+ tool_choice = agent_config.get("tool_choice")
2020
+ logger.info(f"Agent '{agent_name}' config has tool_choice={tool_choice}")
2021
+
1890
2022
  dspy_config = {
1891
2023
  "system_prompt": system_prompt_template,
1892
2024
  "model": model_name,
@@ -1912,14 +2044,19 @@ class TactusRuntime:
1912
2044
  "disable_streaming": agent_config.get("disable_streaming", False),
1913
2045
  "initial_message": initial_message,
1914
2046
  "log_handler": self.log_handler,
2047
+ "tool_choice": tool_choice, # Pass through tool_choice
1915
2048
  }
2049
+ logger.info(
2050
+ f"Agent '{agent_name}' dspy_config has tool_choice={dspy_config.get('tool_choice')}"
2051
+ )
1916
2052
 
1917
- # Create DSPy agent with registry and mock_manager for mock support
2053
+ # Create DSPy agent with registry, mock_manager, and execution_context
1918
2054
  agent_primitive = create_dspy_agent(
1919
2055
  agent_name,
1920
2056
  dspy_config,
1921
2057
  registry=self.registry,
1922
2058
  mock_manager=self.mock_manager,
2059
+ execution_context=self.execution_context,
1923
2060
  )
1924
2061
 
1925
2062
  # Store additional context for compatibility
@@ -1929,6 +2066,15 @@ class TactusRuntime:
1929
2066
 
1930
2067
  self.agents[agent_name] = agent_primitive
1931
2068
 
2069
+ # CRITICAL FIX: Update the Lua global to point to the new agent with toolsets
2070
+ # The agent was created during parsing WITHOUT toolsets, now we update it
2071
+ # to the new agent that HAS toolsets
2072
+ self.lua_sandbox.lua.globals()[agent_name] = agent_primitive
2073
+ sys.stderr.write(
2074
+ f"[AGENT_FIX] Updated Lua global '{agent_name}' to new agent with toolsets\n"
2075
+ )
2076
+ sys.stderr.flush()
2077
+
1932
2078
  logger.info(f"Agent '{agent_name}' configured successfully with model '{model_name}'")
1933
2079
 
1934
2080
  async def _setup_models(self):
@@ -2235,8 +2381,7 @@ class TactusRuntime:
2235
2381
  self.lua_sandbox.inject_primitive("_python_checkpoint", self.step_primitive.checkpoint)
2236
2382
 
2237
2383
  # Create Lua wrapper that captures source location before calling Python
2238
- self.lua_sandbox.lua.execute(
2239
- """
2384
+ self.lua_sandbox.lua.execute("""
2240
2385
  function checkpoint(fn)
2241
2386
  -- Capture caller's source location (2 levels up: this wrapper -> caller)
2242
2387
  local info = debug.getinfo(2, 'Sl')
@@ -2252,8 +2397,7 @@ class TactusRuntime:
2252
2397
  return _python_checkpoint(fn, nil)
2253
2398
  end
2254
2399
  end
2255
- """
2256
- )
2400
+ """)
2257
2401
  logger.debug("Checkpoint wrapper injected with Lua source location tracking")
2258
2402
 
2259
2403
  if self.checkpoint_primitive:
@@ -2367,9 +2511,14 @@ class TactusRuntime:
2367
2511
  # We can't check FieldDefinition type here as it's lost during storage
2368
2512
  input_params[key] = field_def["default"]
2369
2513
  # If required and not in context, it will fail validation in ProcedureCallable
2370
-
2371
2514
  logger.debug(f"Calling main with input_params: {input_params}")
2372
2515
 
2516
+ # Set procedure metadata for HITL context display
2517
+ if hasattr(self, "execution_context") and self.execution_context:
2518
+ self.execution_context.set_procedure_metadata(
2519
+ procedure_name=main_proc.get("name", "main"), input_data=input_params
2520
+ )
2521
+
2373
2522
  # Execute main procedure
2374
2523
  result = main_callable(input_params)
2375
2524
 
@@ -2384,6 +2533,9 @@ class TactusRuntime:
2384
2533
 
2385
2534
  logger.info("Named 'main' procedure execution completed successfully")
2386
2535
  return result
2536
+ except ProcedureWaitingForHuman:
2537
+ # Re-raise without wrapping - this is expected behavior
2538
+ raise
2387
2539
  except Exception as e:
2388
2540
  logger.error(f"Named 'main' procedure execution failed: {e}")
2389
2541
  raise LuaSandboxError(f"Named 'main' procedure execution failed: {e}")
@@ -2436,6 +2588,11 @@ class TactusRuntime:
2436
2588
  if re.search(r"(?m)^\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*=\s*)?Procedure\b", source):
2437
2589
  return source
2438
2590
 
2591
+ # If there are named function definitions (function name()), don't transform.
2592
+ # These are procedure definitions that will be explicitly called.
2593
+ if re.search(r"(?m)^\s*(?:local\s+)?function\s+[A-Za-z_][A-Za-z0-9_]*\s*\(", source):
2594
+ return source
2595
+
2439
2596
  # Detect script mode by top-level input/output declarations OR a top-level `return`.
2440
2597
  # We intentionally treat simple "hello world" scripts as script-mode so agent/tool
2441
2598
  # calls don't execute during the parse/declaration phase.
@@ -2451,6 +2608,7 @@ class TactusRuntime:
2451
2608
  # Once we enter executable code, everything stays in the body.
2452
2609
  in_body = False
2453
2610
  brace_depth = 0
2611
+ function_depth = 0 # Track function...end blocks
2454
2612
  long_string_eq: str | None = None
2455
2613
 
2456
2614
  decl_start = re.compile(
@@ -2467,6 +2625,8 @@ class TactusRuntime:
2467
2625
  r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt"
2468
2626
  r")\b"
2469
2627
  )
2628
+ # Match function definitions: function name() or local function name()
2629
+ function_def = re.compile(r"^\s*(?:local\s+)?function\s+[A-Za-z_][A-Za-z0-9_]*\s*\(")
2470
2630
 
2471
2631
  long_string_open = re.compile(r"\[(=*)\[")
2472
2632
 
@@ -2485,15 +2645,20 @@ class TactusRuntime:
2485
2645
  long_string_eq = None
2486
2646
  continue
2487
2647
 
2488
- # If we're inside a declaration block, keep consuming until braces balance.
2648
+ # If we're inside a declaration block, keep consuming until braces/functions balance.
2489
2649
  added_to_decl = False
2490
- if brace_depth > 0:
2650
+ if brace_depth > 0 or function_depth > 0:
2491
2651
  decl_lines.append(line)
2492
2652
  added_to_decl = True
2493
2653
  elif stripped == "" or stripped.startswith("--"):
2494
2654
  decl_lines.append(line)
2495
2655
  added_to_decl = True
2496
- elif decl_start.match(line) or assignment_decl.match(line) or require_stmt.match(line):
2656
+ elif (
2657
+ decl_start.match(line)
2658
+ or assignment_decl.match(line)
2659
+ or require_stmt.match(line)
2660
+ or function_def.match(line)
2661
+ ):
2497
2662
  decl_lines.append(line)
2498
2663
  added_to_decl = True
2499
2664
  else:
@@ -2516,6 +2681,18 @@ class TactusRuntime:
2516
2681
  if brace_depth < 0:
2517
2682
  brace_depth = 0
2518
2683
 
2684
+ # Track function...end blocks for named function definitions
2685
+ # Count 'function' keywords (both named and anonymous)
2686
+ # Note: This is a simple heuristic that counts keywords in comments/strings too,
2687
+ # but that's acceptable for well-formed DSL code
2688
+ import re as re_module
2689
+
2690
+ function_count = len(re_module.findall(r"\bfunction\b", line))
2691
+ end_count = len(re_module.findall(r"\bend\b", line))
2692
+ function_depth += function_count - end_count
2693
+ if function_depth < 0:
2694
+ function_depth = 0
2695
+
2519
2696
  # If there is no executable code, nothing to wrap.
2520
2697
  if not any(line.strip() for line in body_lines):
2521
2698
  return source
@@ -2720,6 +2897,37 @@ class TactusRuntime:
2720
2897
  except LuaSandboxError as e:
2721
2898
  raise TactusRuntimeError(f"Failed to parse DSL: {e}")
2722
2899
 
2900
+ # Auto-register plain function main() if it exists
2901
+ #
2902
+ # Some .tac files use plain Lua syntax: `function main() ... end`
2903
+ # instead of the Procedure DSL syntax: `Procedure { function(input) ... end }`
2904
+ #
2905
+ # For these files, we need to explicitly check lua.globals() after execution
2906
+ # and register any function named "main" as the main procedure.
2907
+ #
2908
+ # This allows both syntax styles to work:
2909
+ # 1. DSL style (self-registering): Procedure { function(input) ... end }
2910
+ # 2. Plain Lua style (auto-registered): function main() ... end
2911
+ #
2912
+ # The script mode transformation (in _maybe_transform_script_mode_source)
2913
+ # is designed to skip files with named function definitions to avoid wrapping
2914
+ # them incorrectly.
2915
+ lua_globals = sandbox.lua.globals()
2916
+ if "main" in lua_globals:
2917
+ main_func = lua_globals["main"]
2918
+ # Check if it's a function and not already registered
2919
+ if callable(main_func) and "main" not in builder.registry.named_procedures:
2920
+ logger.info(
2921
+ "[AUTO_REGISTER] Found plain function main(), auto-registering as main procedure"
2922
+ )
2923
+ builder.register_named_procedure(
2924
+ name="main",
2925
+ lua_function=main_func,
2926
+ input_schema={},
2927
+ output_schema={},
2928
+ state_schema={},
2929
+ )
2930
+
2723
2931
  # Validate and return registry
2724
2932
  result = builder.validate()
2725
2933
  if not result.valid:
@@ -0,0 +1,49 @@
1
+ # Tactus Sandbox Container (PyPI-based)
2
+ #
3
+ # Builds a sandbox image by installing Tactus from PyPI. This is intended for
4
+ # environments where the Tactus source tree is not available (e.g., pip install tactus).
5
+
6
+ FROM python:3.11-slim
7
+
8
+ # Labels for image management
9
+ ARG TACTUS_VERSION=dev
10
+ LABEL tactus.version="${TACTUS_VERSION}"
11
+ LABEL maintainer="Anthus <info@anthus.ai>"
12
+ LABEL description="Tactus sandbox container for secure procedure execution (PyPI)"
13
+
14
+ # Install system dependencies
15
+ # - Node.js for JavaScript/TypeScript MCP servers
16
+ # - git for any git operations
17
+ # - build-essential for native Python packages
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ curl \
20
+ git \
21
+ build-essential \
22
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
23
+ && apt-get install -y --no-install-recommends nodejs \
24
+ && apt-get clean \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # Create non-root user for security
28
+ RUN useradd -m -s /bin/bash tactus
29
+ WORKDIR /app
30
+
31
+ # Install Tactus from PyPI
32
+ RUN pip install --no-cache-dir "tactus==${TACTUS_VERSION}"
33
+
34
+ # Create workspace and mcp-servers directories
35
+ RUN mkdir -p /workspace /mcp-servers && \
36
+ chown -R tactus:tactus /workspace /mcp-servers
37
+
38
+ # Copy entrypoint script
39
+ COPY tactus/docker/entrypoint.sh /entrypoint.sh
40
+ RUN chmod +x /entrypoint.sh
41
+
42
+ # Switch to non-root user
43
+ USER tactus
44
+
45
+ # Set working directory for procedure execution
46
+ WORKDIR /workspace
47
+
48
+ # Default entrypoint runs the sandbox entrypoint module
49
+ ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,33 @@
1
+ """
2
+ Tactus Documentation Generator.
3
+
4
+ Extract documentation from .tac files and generate HTML output.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from tactus.docs.extractor import DirectoryExtractor
9
+ from tactus.docs.html_renderer import HTMLRenderer
10
+
11
+
12
+ def generate_docs(input_path: Path, output_path: Path):
13
+ """
14
+ Generate HTML documentation from Tactus .tac files.
15
+
16
+ Args:
17
+ input_path: Path to directory containing .tac files
18
+ output_path: Path where HTML files will be written
19
+ """
20
+ # Extract documentation
21
+ extractor = DirectoryExtractor(input_path)
22
+ tree = extractor.extract_all()
23
+
24
+ print(f"Found {len(tree.modules)} module(s) to document")
25
+
26
+ # Render HTML
27
+ renderer = HTMLRenderer()
28
+ renderer.render_all(tree, output_path)
29
+
30
+ print(f"✓ Documentation generated at {output_path}/index.html")
31
+
32
+
33
+ __all__ = ["generate_docs", "DirectoryExtractor", "HTMLRenderer"]