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.
- 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.0.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -20,7 +20,11 @@ from pathlib import Path
|
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional
|
|
21
21
|
|
|
22
22
|
from .config import SandboxConfig
|
|
23
|
-
from .docker_manager import
|
|
23
|
+
from .docker_manager import (
|
|
24
|
+
DockerManager,
|
|
25
|
+
calculate_source_hash,
|
|
26
|
+
resolve_dockerfile_path,
|
|
27
|
+
)
|
|
24
28
|
from .protocol import (
|
|
25
29
|
ExecutionRequest,
|
|
26
30
|
ExecutionResult,
|
|
@@ -146,14 +150,16 @@ class ContainerRunner:
|
|
|
146
150
|
# container_runner.py is in tactus/sandbox/, so root is 2 levels up
|
|
147
151
|
tactus_root = Path(__file__).parent.parent.parent
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
dockerfile_path, build_mode = resolve_dockerfile_path(tactus_root)
|
|
154
|
+
current_hash = None
|
|
155
|
+
if build_mode == "source":
|
|
156
|
+
current_hash = calculate_source_hash(tactus_root)
|
|
157
|
+
else:
|
|
158
|
+
logger.info("[SANDBOX] No source tree detected, using PyPI-based sandbox image build")
|
|
150
159
|
|
|
151
160
|
# Check if rebuild is needed
|
|
152
161
|
if self.docker_manager.needs_rebuild(__version__, current_hash):
|
|
153
|
-
logger.info("
|
|
154
|
-
|
|
155
|
-
# Get paths
|
|
156
|
-
dockerfile_path = tactus_root / "tactus" / "docker" / "Dockerfile"
|
|
162
|
+
logger.info("Sandbox image missing or outdated, rebuilding...")
|
|
157
163
|
|
|
158
164
|
# Build with source hash
|
|
159
165
|
success, msg = self.docker_manager.build_image(
|
|
@@ -253,8 +259,8 @@ class ContainerRunner:
|
|
|
253
259
|
if self.config.limits.cpus:
|
|
254
260
|
cmd.extend(["--cpus", self.config.limits.cpus])
|
|
255
261
|
|
|
256
|
-
#
|
|
257
|
-
|
|
262
|
+
# Working directory mount is handled by SandboxConfig.add_default_volumes()
|
|
263
|
+
# which adds ".:/workspace:rw" to config.volumes (unless mount_current_dir=False)
|
|
258
264
|
|
|
259
265
|
# Mount MCP servers if available
|
|
260
266
|
if mcp_servers_path and mcp_servers_path.exists():
|
|
@@ -321,7 +327,7 @@ class ContainerRunner:
|
|
|
321
327
|
container = parts[1]
|
|
322
328
|
mode = parts[2] if len(parts) > 2 else None
|
|
323
329
|
|
|
324
|
-
host_is_path = host.startswith(("/", "./", "../", "~"))
|
|
330
|
+
host_is_path = host.startswith(("/", "./", "../", "~")) or host == "." or host == ".."
|
|
325
331
|
if not host_is_path:
|
|
326
332
|
# Named volume (or other special form) - leave unchanged
|
|
327
333
|
return volume
|
|
@@ -344,6 +350,9 @@ class ContainerRunner:
|
|
|
344
350
|
format: str = "lua",
|
|
345
351
|
event_handler: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
346
352
|
callback_url: Optional[str] = None,
|
|
353
|
+
run_id: Optional[str] = None,
|
|
354
|
+
control_handler: Optional[Callable[[dict], Any]] = None,
|
|
355
|
+
llm_backend_config: Optional[Dict[str, Any]] = None,
|
|
347
356
|
) -> ExecutionResult:
|
|
348
357
|
"""
|
|
349
358
|
Execute a procedure in a sandboxed container.
|
|
@@ -355,6 +364,10 @@ class ContainerRunner:
|
|
|
355
364
|
working_dir: Working directory to use (default: temp directory)
|
|
356
365
|
format: Source format ("lua" for .tac files, "yaml" for legacy)
|
|
357
366
|
event_handler: Optional host callback for streaming events from the container
|
|
367
|
+
callback_url: Optional HTTP callback URL for streaming events
|
|
368
|
+
run_id: Optional run ID for checkpoint isolation across executions
|
|
369
|
+
control_handler: Optional callback for handling container HITL requests
|
|
370
|
+
llm_backend_config: Optional config for broker's LLM backend (provider-agnostic)
|
|
358
371
|
|
|
359
372
|
Returns:
|
|
360
373
|
ExecutionResult with status, result/error, and metadata.
|
|
@@ -427,12 +440,18 @@ class ContainerRunner:
|
|
|
427
440
|
keyfile=self.config.broker_tls_key_file,
|
|
428
441
|
)
|
|
429
442
|
|
|
443
|
+
# Extract OpenAI-specific config if provided
|
|
444
|
+
openai_key = None
|
|
445
|
+
if llm_backend_config:
|
|
446
|
+
openai_key = llm_backend_config.get("openai_api_key")
|
|
447
|
+
|
|
430
448
|
broker_server = TcpBrokerServer(
|
|
431
449
|
host=self.config.broker_bind_host,
|
|
432
450
|
port=self.config.broker_port,
|
|
433
451
|
ssl_context=ssl_context,
|
|
434
|
-
openai_backend=OpenAIChatBackend(),
|
|
452
|
+
openai_backend=OpenAIChatBackend(api_key=openai_key),
|
|
435
453
|
event_handler=event_handler,
|
|
454
|
+
control_handler=control_handler,
|
|
436
455
|
)
|
|
437
456
|
await broker_server.start()
|
|
438
457
|
if broker_server.bound_port is None:
|
|
@@ -463,6 +482,7 @@ class ContainerRunner:
|
|
|
463
482
|
working_dir="/workspace",
|
|
464
483
|
params=params or {},
|
|
465
484
|
execution_id=execution_id,
|
|
485
|
+
run_id=run_id,
|
|
466
486
|
source_file_path=source_file_path,
|
|
467
487
|
format=format,
|
|
468
488
|
)
|
|
@@ -487,6 +507,8 @@ class ContainerRunner:
|
|
|
487
507
|
request,
|
|
488
508
|
timeout=self.config.timeout,
|
|
489
509
|
event_handler=event_handler,
|
|
510
|
+
control_handler=control_handler,
|
|
511
|
+
llm_backend_config=llm_backend_config,
|
|
490
512
|
)
|
|
491
513
|
finally:
|
|
492
514
|
# Cancel broker task when container finishes
|
|
@@ -501,6 +523,8 @@ class ContainerRunner:
|
|
|
501
523
|
request,
|
|
502
524
|
timeout=self.config.timeout,
|
|
503
525
|
event_handler=event_handler,
|
|
526
|
+
control_handler=control_handler,
|
|
527
|
+
llm_backend_config=llm_backend_config,
|
|
504
528
|
)
|
|
505
529
|
|
|
506
530
|
result.duration_seconds = time.time() - start_time
|
|
@@ -537,6 +561,8 @@ class ContainerRunner:
|
|
|
537
561
|
request: ExecutionRequest,
|
|
538
562
|
timeout: int,
|
|
539
563
|
event_handler: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
564
|
+
control_handler: Optional[Callable[[dict], Any]] = None,
|
|
565
|
+
llm_backend_config: Optional[Dict[str, Any]] = None,
|
|
540
566
|
) -> ExecutionResult:
|
|
541
567
|
"""
|
|
542
568
|
Run the container and communicate via stdio.
|
|
@@ -558,7 +584,13 @@ class ContainerRunner:
|
|
|
558
584
|
from tactus.broker.stdio import STDIO_REQUEST_PREFIX
|
|
559
585
|
|
|
560
586
|
stdio_request_prefix = STDIO_REQUEST_PREFIX
|
|
561
|
-
|
|
587
|
+
|
|
588
|
+
# Extract OpenAI-specific config if provided
|
|
589
|
+
openai_key = None
|
|
590
|
+
if llm_backend_config:
|
|
591
|
+
openai_key = llm_backend_config.get("openai_api_key")
|
|
592
|
+
|
|
593
|
+
openai_backend = OpenAIChatBackend(api_key=openai_key)
|
|
562
594
|
tool_registry = HostToolRegistry.default()
|
|
563
595
|
|
|
564
596
|
async def send_event(writer: asyncio.StreamWriter, event: dict[str, Any]) -> None:
|
|
@@ -602,6 +634,42 @@ class ContainerRunner:
|
|
|
602
634
|
await send_event(writer, {"id": req_id, "event": "done", "data": {"ok": True}})
|
|
603
635
|
return
|
|
604
636
|
|
|
637
|
+
if method == "control.request":
|
|
638
|
+
request_data = params.get("request") if isinstance(params, dict) else None
|
|
639
|
+
if control_handler is not None:
|
|
640
|
+
try:
|
|
641
|
+
# Send delivered event
|
|
642
|
+
await send_event(writer, {"id": req_id, "event": "delivered"})
|
|
643
|
+
|
|
644
|
+
# Call control handler and await response
|
|
645
|
+
response_data = await control_handler(request_data)
|
|
646
|
+
|
|
647
|
+
# Send response event
|
|
648
|
+
await send_event(
|
|
649
|
+
writer, {"id": req_id, "event": "response", "data": response_data}
|
|
650
|
+
)
|
|
651
|
+
except asyncio.TimeoutError:
|
|
652
|
+
await send_event(
|
|
653
|
+
writer,
|
|
654
|
+
{"id": req_id, "event": "timeout", "data": {"timed_out": True}},
|
|
655
|
+
)
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.debug("[BROKER] control.request handler raised", exc_info=True)
|
|
658
|
+
await send_event(
|
|
659
|
+
writer,
|
|
660
|
+
{"id": req_id, "event": "error", "error": {"message": str(e)}},
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
await send_event(
|
|
664
|
+
writer,
|
|
665
|
+
{
|
|
666
|
+
"id": req_id,
|
|
667
|
+
"event": "error",
|
|
668
|
+
"error": {"message": "No control handler configured"},
|
|
669
|
+
},
|
|
670
|
+
)
|
|
671
|
+
return
|
|
672
|
+
|
|
605
673
|
if method == "tool.call":
|
|
606
674
|
name = params.get("name") if isinstance(params, dict) else None
|
|
607
675
|
args = params.get("args") if isinstance(params, dict) else None
|
tactus/sandbox/docker_manager.py
CHANGED
|
@@ -18,6 +18,26 @@ DEFAULT_IMAGE_NAME = "tactus-sandbox"
|
|
|
18
18
|
DEFAULT_IMAGE_TAG = "local"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def resolve_dockerfile_path(tactus_root: Path) -> Tuple[Path, str]:
|
|
22
|
+
"""
|
|
23
|
+
Choose the appropriate Dockerfile for the sandbox build.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (dockerfile_path, build_mode) where build_mode is "source" or "pypi".
|
|
27
|
+
"""
|
|
28
|
+
docker_dir = tactus_root / "tactus" / "docker"
|
|
29
|
+
source_dockerfile = docker_dir / "Dockerfile"
|
|
30
|
+
pypi_dockerfile = docker_dir / "Dockerfile.pypi"
|
|
31
|
+
has_source_tree = (tactus_root / "pyproject.toml").exists() and (
|
|
32
|
+
tactus_root / "README.md"
|
|
33
|
+
).exists()
|
|
34
|
+
|
|
35
|
+
if has_source_tree or not pypi_dockerfile.exists():
|
|
36
|
+
return source_dockerfile, "source"
|
|
37
|
+
|
|
38
|
+
return pypi_dockerfile, "pypi"
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
def calculate_source_hash(tactus_root: Path) -> str:
|
|
22
42
|
"""
|
|
23
43
|
Calculate hash of Tactus source files for change detection.
|
|
@@ -35,6 +55,7 @@ def calculate_source_hash(tactus_root: Path) -> str:
|
|
|
35
55
|
paths_to_hash = [
|
|
36
56
|
tactus_root / "tactus" / "dspy",
|
|
37
57
|
tactus_root / "tactus" / "adapters",
|
|
58
|
+
tactus_root / "tactus" / "broker", # Broker client used by sandbox
|
|
38
59
|
tactus_root / "tactus" / "core",
|
|
39
60
|
tactus_root / "tactus" / "primitives",
|
|
40
61
|
tactus_root / "tactus" / "sandbox",
|
|
@@ -283,6 +304,8 @@ class DockerManager:
|
|
|
283
304
|
self.full_image_name,
|
|
284
305
|
"-f",
|
|
285
306
|
str(dockerfile_path),
|
|
307
|
+
"--build-arg",
|
|
308
|
+
f"TACTUS_VERSION={version}",
|
|
286
309
|
"--label",
|
|
287
310
|
f"tactus.version={version}",
|
|
288
311
|
]
|
tactus/sandbox/entrypoint.py
CHANGED
|
@@ -85,6 +85,7 @@ async def execute_procedure(
|
|
|
85
85
|
params: Dict[str, Any],
|
|
86
86
|
source_file_path: Optional[str] = None,
|
|
87
87
|
format: str = "lua",
|
|
88
|
+
run_id: Optional[str] = None,
|
|
88
89
|
) -> Any:
|
|
89
90
|
"""
|
|
90
91
|
Execute a procedure using TactusRuntime.
|
|
@@ -96,6 +97,7 @@ async def execute_procedure(
|
|
|
96
97
|
mcp_servers: MCP server configurations
|
|
97
98
|
source_file_path: Original source file path
|
|
98
99
|
format: Source format ("lua" or "yaml")
|
|
100
|
+
run_id: Run ID for checkpoint isolation
|
|
99
101
|
|
|
100
102
|
Returns:
|
|
101
103
|
Procedure execution result
|
|
@@ -105,6 +107,7 @@ async def execute_procedure(
|
|
|
105
107
|
from tactus.adapters.broker_log import BrokerLogHandler
|
|
106
108
|
from tactus.adapters.http_callback_log import HTTPCallbackLogHandler
|
|
107
109
|
from tactus.adapters.cost_collector_log import CostCollectorLogHandler
|
|
110
|
+
from tactus.adapters.channels.broker import BrokerControlChannel
|
|
108
111
|
|
|
109
112
|
# Create a unique procedure ID
|
|
110
113
|
import uuid
|
|
@@ -129,6 +132,18 @@ async def execute_procedure(
|
|
|
129
132
|
log_handler = CostCollectorLogHandler()
|
|
130
133
|
logger.info("[SANDBOX] No callback configured; using CostCollectorLogHandler")
|
|
131
134
|
|
|
135
|
+
# Set up HITL control channel (broker-based in container mode)
|
|
136
|
+
broker_channel = BrokerControlChannel.from_environment()
|
|
137
|
+
hitl_handler = None
|
|
138
|
+
if broker_channel:
|
|
139
|
+
from tactus.adapters.control_loop import ControlLoopHandler, ControlLoopHITLAdapter
|
|
140
|
+
|
|
141
|
+
control_handler = ControlLoopHandler(channels=[broker_channel])
|
|
142
|
+
hitl_handler = ControlLoopHITLAdapter(control_handler)
|
|
143
|
+
logger.info("[SANDBOX] Using broker control channel for HITL")
|
|
144
|
+
else:
|
|
145
|
+
logger.debug("[SANDBOX] No broker control channel available, HITL disabled")
|
|
146
|
+
|
|
132
147
|
# Create runtime with log handler for event streaming
|
|
133
148
|
runtime = TactusRuntime(
|
|
134
149
|
procedure_id=procedure_id,
|
|
@@ -137,6 +152,8 @@ async def execute_procedure(
|
|
|
137
152
|
external_config={},
|
|
138
153
|
source_file_path=source_file_path,
|
|
139
154
|
log_handler=log_handler, # Enable event streaming to IDE
|
|
155
|
+
hitl_handler=hitl_handler, # Enable HITL control channel
|
|
156
|
+
run_id=run_id, # Pass run_id for checkpoint isolation
|
|
140
157
|
)
|
|
141
158
|
|
|
142
159
|
# Execute procedure
|
|
@@ -146,6 +163,14 @@ async def execute_procedure(
|
|
|
146
163
|
format=format,
|
|
147
164
|
)
|
|
148
165
|
|
|
166
|
+
# CRITICAL: Flush pending log events before returning
|
|
167
|
+
# This ensures all streaming events reach the broker before container exits.
|
|
168
|
+
# Without this, fire-and-forget async tasks may be discarded.
|
|
169
|
+
if hasattr(log_handler, "flush"):
|
|
170
|
+
logger.info("[SANDBOX] Flushing pending log events...")
|
|
171
|
+
await log_handler.flush()
|
|
172
|
+
logger.info("[SANDBOX] Log events flushed")
|
|
173
|
+
|
|
149
174
|
return result
|
|
150
175
|
|
|
151
176
|
|
|
@@ -179,6 +204,7 @@ async def main_async() -> int:
|
|
|
179
204
|
params=request.params,
|
|
180
205
|
source_file_path=request.source_file_path,
|
|
181
206
|
format=request.format,
|
|
207
|
+
run_id=request.run_id,
|
|
182
208
|
)
|
|
183
209
|
|
|
184
210
|
# Create success result
|
tactus/sandbox/protocol.py
CHANGED
|
@@ -51,6 +51,9 @@ class ExecutionRequest:
|
|
|
51
51
|
# Unique execution ID for tracking
|
|
52
52
|
execution_id: Optional[str] = None
|
|
53
53
|
|
|
54
|
+
# Run ID for checkpoint isolation across multiple executions
|
|
55
|
+
run_id: Optional[str] = None
|
|
56
|
+
|
|
54
57
|
# Source file path (for error messages)
|
|
55
58
|
source_file_path: Optional[str] = None
|
|
56
59
|
|
tactus/stdlib/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Tactus Standard Library
|
|
2
|
+
|
|
3
|
+
The Tactus standard library provides reusable primitives for building AI agents and classification workflows.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
The stdlib follows the **Dogfooding with BDD Specs as Contract** principle:
|
|
8
|
+
|
|
9
|
+
1. **BDD specs define behavior** - Each primitive has comprehensive `.spec.tac` files
|
|
10
|
+
2. **Implementation is secondary** - Python, Tactus, or mix - doesn't matter if specs pass
|
|
11
|
+
3. **Specs serve triple duty** - Tests, documentation, and contract
|
|
12
|
+
|
|
13
|
+
## Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
tactus/stdlib/
|
|
17
|
+
├── classify/
|
|
18
|
+
│ ├── classify.tac # Tactus implementation (reference)
|
|
19
|
+
│ ├── classify.spec.tac # BDD specifications (THE CONTRACT)
|
|
20
|
+
│ ├── primitive.py # Current Python implementation
|
|
21
|
+
│ ├── llm.py # LLM-based classifier
|
|
22
|
+
│ └── fuzzy.py # Fuzzy string matching
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Testing
|
|
26
|
+
|
|
27
|
+
Run all stdlib specs:
|
|
28
|
+
```bash
|
|
29
|
+
tactus stdlib test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run specific primitive specs:
|
|
33
|
+
```bash
|
|
34
|
+
tactus test tactus/stdlib/classify/classify.spec.tac
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
Each `.spec.tac` file contains:
|
|
40
|
+
- `--[[doc]]` blocks with usage documentation
|
|
41
|
+
- `--[[doc:parameter name]]` blocks with parameter documentation
|
|
42
|
+
- BDD scenarios showing expected behavior
|
|
43
|
+
- Custom step definitions for the tests
|
|
44
|
+
|
|
45
|
+
## Example: Classify
|
|
46
|
+
|
|
47
|
+
The Classify primitive demonstrates the stdlib pattern:
|
|
48
|
+
|
|
49
|
+
**Specifications** ([classify.spec.tac](classify/classify.spec.tac)):
|
|
50
|
+
- 7 BDD scenarios covering LLM and fuzzy matching
|
|
51
|
+
- Documentation blocks explaining usage and parameters
|
|
52
|
+
- Custom steps for testing classification behavior
|
|
53
|
+
|
|
54
|
+
**Current Status**:
|
|
55
|
+
- ✅ Specs pass with Python implementation
|
|
56
|
+
- ✅ Tactus reference implementation exists
|
|
57
|
+
- 🔜 Module loading system needed to use Tactus impl
|
|
58
|
+
|
|
59
|
+
## Adding New Primitives
|
|
60
|
+
|
|
61
|
+
1. Create `primitive-name/` directory
|
|
62
|
+
2. Write `primitive-name.spec.tac` with:
|
|
63
|
+
- `--[[doc]]` documentation blocks
|
|
64
|
+
- Custom step definitions
|
|
65
|
+
- Comprehensive BDD scenarios
|
|
66
|
+
3. Implement in Python (for now) or Tactus (when module loading ready)
|
|
67
|
+
4. Ensure `tactus test` passes
|
|
68
|
+
|
|
69
|
+
## CI Integration
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
# .github/workflows/stdlib.yml
|
|
73
|
+
- name: Test Standard Library
|
|
74
|
+
run: tactus stdlib test --verbose
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
All stdlib specs must pass before merge.
|
tactus/stdlib/__init__.py
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
"""Tactus Standard Library.
|
|
2
2
|
|
|
3
|
-
The standard library
|
|
3
|
+
The standard library provides high-level primitives for common AI tasks:
|
|
4
|
+
|
|
5
|
+
## Primitives (injected into Lua)
|
|
6
|
+
|
|
7
|
+
### Classify - Smart classification with retry logic
|
|
8
|
+
result = Classify {
|
|
9
|
+
classes = {"Yes", "No"},
|
|
10
|
+
prompt = "Did the agent greet the customer?",
|
|
11
|
+
input = transcript
|
|
12
|
+
}
|
|
13
|
+
-- result.value = "Yes"
|
|
14
|
+
-- result.confidence = 0.92
|
|
15
|
+
-- result.explanation = "The agent said 'Hello'..."
|
|
16
|
+
|
|
17
|
+
### Coming Soon
|
|
18
|
+
- Extract: Schema-based information extraction with validation
|
|
19
|
+
- Match: Fuzzy matching verification
|
|
20
|
+
- Generate: Constrained generation with validation
|
|
21
|
+
|
|
22
|
+
## Utility Modules (via require)
|
|
23
|
+
|
|
24
|
+
The standard library also includes .tac files in the tac/ subdirectory.
|
|
4
25
|
These are loaded via Lua's require() function:
|
|
5
26
|
|
|
6
27
|
local done = require("tactus.tools.done")
|
|
@@ -8,3 +29,8 @@ These are loaded via Lua's require() function:
|
|
|
8
29
|
|
|
9
30
|
See tactus/stdlib/tac/ for available modules.
|
|
10
31
|
"""
|
|
32
|
+
|
|
33
|
+
from .classify import ClassifyPrimitive
|
|
34
|
+
from .extract import ExtractPrimitive
|
|
35
|
+
|
|
36
|
+
__all__ = ["ClassifyPrimitive", "ExtractPrimitive"]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tactus Standard Library - Classify Primitive
|
|
3
|
+
|
|
4
|
+
Provides smart classification with built-in retry logic, validation,
|
|
5
|
+
and confidence extraction.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
-- Simple binary classification
|
|
10
|
+
result = Classify {
|
|
11
|
+
classes = {"Yes", "No"},
|
|
12
|
+
prompt = "Did the agent greet the customer?",
|
|
13
|
+
input = transcript
|
|
14
|
+
}
|
|
15
|
+
-- result.value = "Yes"
|
|
16
|
+
-- result.confidence = 0.92
|
|
17
|
+
-- result.explanation = "The agent said 'Hello'..."
|
|
18
|
+
|
|
19
|
+
## Reusable Classifier
|
|
20
|
+
|
|
21
|
+
Create a classifier once and use it multiple times:
|
|
22
|
+
|
|
23
|
+
sentiment = Classify {
|
|
24
|
+
classes = {"positive", "negative", "neutral"},
|
|
25
|
+
prompt = "What is the sentiment of this text?"
|
|
26
|
+
}
|
|
27
|
+
result1 = sentiment(text1)
|
|
28
|
+
result2 = sentiment(text2)
|
|
29
|
+
|
|
30
|
+
## Configuration Options
|
|
31
|
+
|
|
32
|
+
| Option | Type | Default | Description |
|
|
33
|
+
|-----------------|----------|------------|--------------------------------------|
|
|
34
|
+
| classes | table | (required) | Valid classification values |
|
|
35
|
+
| prompt | string | (required) | Classification instruction |
|
|
36
|
+
| input | string | nil | Input for one-shot classification |
|
|
37
|
+
| max_retries | number | 3 | Max retry attempts on invalid output |
|
|
38
|
+
| temperature | number | 0.3 | LLM temperature for classification |
|
|
39
|
+
| model | string | nil | Override default model |
|
|
40
|
+
| confidence_mode | string | "heuristic"| "heuristic" or "none" |
|
|
41
|
+
|
|
42
|
+
## Return Value
|
|
43
|
+
|
|
44
|
+
The Classify primitive returns a result with:
|
|
45
|
+
|
|
46
|
+
| Field | Type | Description |
|
|
47
|
+
|-------------|---------|------------------------------------------|
|
|
48
|
+
| value | string | The classification (e.g., "Yes", "No") |
|
|
49
|
+
| confidence | number | Confidence score (0.0 - 1.0) or nil |
|
|
50
|
+
| explanation | string | LLM's reasoning for the classification |
|
|
51
|
+
| retry_count | number | Number of retries needed |
|
|
52
|
+
| error | string | Error message if classification failed |
|
|
53
|
+
|
|
54
|
+
## Retry Logic
|
|
55
|
+
|
|
56
|
+
When the LLM returns an invalid classification (not in the `classes` list),
|
|
57
|
+
Classify automatically retries with conversational feedback:
|
|
58
|
+
|
|
59
|
+
1. First attempt: Send classification request
|
|
60
|
+
2. If invalid: Send feedback message with valid options
|
|
61
|
+
3. Repeat until valid classification or max_retries exceeded
|
|
62
|
+
|
|
63
|
+
This mimics the LangGraphScore retry pattern where the LLM sees its
|
|
64
|
+
previous mistake and can self-correct.
|
|
65
|
+
|
|
66
|
+
## Confidence Extraction
|
|
67
|
+
|
|
68
|
+
In "heuristic" mode (default), confidence is extracted from response text:
|
|
69
|
+
|
|
70
|
+
- High (0.95): "definitely", "certainly", "clearly", "absolutely"
|
|
71
|
+
- Medium-high (0.80): "likely", "probably", "appears to be"
|
|
72
|
+
- Low (0.50): "possibly", "might be", "uncertain"
|
|
73
|
+
- Default (0.75): When no indicators found
|
|
74
|
+
|
|
75
|
+
Set `confidence_mode = "none"` to disable confidence extraction.
|
|
76
|
+
|
|
77
|
+
## Examples
|
|
78
|
+
|
|
79
|
+
### Binary Classification with NA
|
|
80
|
+
|
|
81
|
+
result = Classify {
|
|
82
|
+
classes = {"Yes", "No", "NA"},
|
|
83
|
+
prompt = "Did the agent provide the required information?",
|
|
84
|
+
input = transcript
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
### Multi-class with Custom Temperature
|
|
88
|
+
|
|
89
|
+
urgency = Classify {
|
|
90
|
+
classes = {"critical", "high", "medium", "low"},
|
|
91
|
+
prompt = "What is the urgency level of this support ticket?",
|
|
92
|
+
temperature = 0.1, -- More deterministic
|
|
93
|
+
max_retries = 5 -- More attempts for complex classification
|
|
94
|
+
}
|
|
95
|
+
result = urgency(ticket_text)
|
|
96
|
+
|
|
97
|
+
### Sentiment Analysis
|
|
98
|
+
|
|
99
|
+
sentiment = Classify {
|
|
100
|
+
classes = {"positive", "negative", "neutral", "mixed"},
|
|
101
|
+
prompt = [[
|
|
102
|
+
Analyze the overall sentiment of the customer feedback.
|
|
103
|
+
Consider both explicit statements and implicit tone.
|
|
104
|
+
]],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for _, review in ipairs(reviews) do
|
|
108
|
+
local result = sentiment(review.text)
|
|
109
|
+
Log.info("Review sentiment: " .. result.value)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
### Fuzzy String Matching
|
|
113
|
+
|
|
114
|
+
For string similarity matching (finding the best match from a list):
|
|
115
|
+
|
|
116
|
+
-- Match school names with variations
|
|
117
|
+
local FuzzyClassifier = require("tactus.stdlib.classify.fuzzy")
|
|
118
|
+
|
|
119
|
+
local school_matcher = FuzzyClassifier {
|
|
120
|
+
classes = {
|
|
121
|
+
"United Education Institute",
|
|
122
|
+
"Abilene Christian University",
|
|
123
|
+
"Arizona School of Integrative Studies"
|
|
124
|
+
},
|
|
125
|
+
threshold = 0.75,
|
|
126
|
+
algorithm = "token_set_ratio" -- Handles reordered tokens
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
local result = school_matcher("Institute Education United Dallas")
|
|
130
|
+
-- result.value = "United Education Institute"
|
|
131
|
+
-- result.matched_text = "United Education Institute"
|
|
132
|
+
-- result.confidence = 0.82
|
|
133
|
+
|
|
134
|
+
Available algorithms:
|
|
135
|
+
- `ratio`: Character-level similarity (default, best for exact matches)
|
|
136
|
+
- `token_set_ratio`: Tokenizes and compares unique words (handles reordering)
|
|
137
|
+
- `token_sort_ratio`: Sorts tokens before comparing (handles reordering)
|
|
138
|
+
- `partial_ratio`: Best substring match (good for partial text)
|
|
139
|
+
|
|
140
|
+
Binary mode (Yes/No matching):
|
|
141
|
+
|
|
142
|
+
local matcher = FuzzyClassifier {
|
|
143
|
+
expected = "Customer Service",
|
|
144
|
+
threshold = 0.8,
|
|
145
|
+
algorithm = "token_set_ratio"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
local result = matcher("Service for Customers")
|
|
149
|
+
-- result.value = "Yes" (matched)
|
|
150
|
+
-- result.matched_text = "Customer Service" (what it matched against)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
from .primitive import ClassifyPrimitive, ClassifyHandle
|
|
154
|
+
from .llm import LLMClassifier
|
|
155
|
+
from .fuzzy import FuzzyMatchClassifier, FuzzyClassifier
|
|
156
|
+
from ..core.models import ClassifierResult
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"ClassifyPrimitive",
|
|
160
|
+
"ClassifyHandle",
|
|
161
|
+
"ClassifierResult",
|
|
162
|
+
"LLMClassifier",
|
|
163
|
+
"FuzzyMatchClassifier",
|
|
164
|
+
"FuzzyClassifier",
|
|
165
|
+
]
|