tactus 0.31.2__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 +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.2.dist-info/METADATA +1809 -0
- tactus-0.31.2.dist-info/RECORD +160 -0
- tactus-0.31.2.dist-info/WHEEL +4 -0
- tactus-0.31.2.dist-info/entry_points.txt +2 -0
- tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
tactus/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tactus: Lua-based DSL for agentic workflows.
|
|
3
|
+
|
|
4
|
+
Tactus provides a declarative workflow engine for AI agents with pluggable
|
|
5
|
+
backends for storage, HITL, and chat recording.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.31.2"
|
|
9
|
+
|
|
10
|
+
# Core exports
|
|
11
|
+
from tactus.core.runtime import TactusRuntime
|
|
12
|
+
from tactus.core.exceptions import (
|
|
13
|
+
TactusRuntimeError,
|
|
14
|
+
ProcedureWaitingForHuman,
|
|
15
|
+
ProcedureConfigError,
|
|
16
|
+
LuaSandboxError,
|
|
17
|
+
OutputValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Protocol exports
|
|
21
|
+
from tactus.protocols.storage import StorageBackend, ProcedureMetadata
|
|
22
|
+
from tactus.protocols.models import CheckpointEntry
|
|
23
|
+
from tactus.protocols.hitl import HITLHandler, HITLRequest, HITLResponse
|
|
24
|
+
from tactus.protocols.chat_recorder import ChatRecorder, ChatMessage
|
|
25
|
+
from tactus.protocols.config import TactusConfig, ProcedureConfig
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Version
|
|
29
|
+
"__version__",
|
|
30
|
+
# Runtime
|
|
31
|
+
"TactusRuntime",
|
|
32
|
+
# Exceptions
|
|
33
|
+
"TactusRuntimeError",
|
|
34
|
+
"ProcedureWaitingForHuman",
|
|
35
|
+
"ProcedureConfigError",
|
|
36
|
+
"LuaSandboxError",
|
|
37
|
+
"OutputValidationError",
|
|
38
|
+
# Protocols
|
|
39
|
+
"StorageBackend",
|
|
40
|
+
"ProcedureMetadata",
|
|
41
|
+
"CheckpointEntry",
|
|
42
|
+
"HITLHandler",
|
|
43
|
+
"HITLRequest",
|
|
44
|
+
"HITLResponse",
|
|
45
|
+
"ChatRecorder",
|
|
46
|
+
"ChatMessage",
|
|
47
|
+
"TactusConfig",
|
|
48
|
+
"ProcedureConfig",
|
|
49
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tactus adapters - Built-in implementations of Tactus protocols.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from tactus.adapters.memory import MemoryStorage
|
|
6
|
+
from tactus.adapters.file_storage import FileStorage
|
|
7
|
+
from tactus.adapters.cli_hitl import CLIHITLHandler
|
|
8
|
+
|
|
9
|
+
__all__ = ["MemoryStorage", "FileStorage", "CLIHITLHandler"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broker log handler for container event streaming over UDS.
|
|
3
|
+
|
|
4
|
+
Used inside the runtime container to forward structured log events to the
|
|
5
|
+
host-side broker without requiring container networking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from tactus.broker.client import BrokerClient
|
|
15
|
+
from tactus.protocols.models import LogEvent, CostEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BrokerLogHandler:
|
|
19
|
+
"""
|
|
20
|
+
Log handler that forwards events to the broker via Unix domain socket.
|
|
21
|
+
|
|
22
|
+
The broker socket path is read from `TACTUS_BROKER_SOCKET`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: BrokerClient):
|
|
26
|
+
self._client = client
|
|
27
|
+
self.cost_events: list[CostEvent] = []
|
|
28
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
29
|
+
self._loop_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
def _get_or_create_loop(self) -> asyncio.AbstractEventLoop:
|
|
32
|
+
"""Get the event loop, creating one if needed for cross-thread calls."""
|
|
33
|
+
with self._loop_lock:
|
|
34
|
+
if self._loop is None or self._loop.is_closed():
|
|
35
|
+
try:
|
|
36
|
+
self._loop = asyncio.get_running_loop()
|
|
37
|
+
except RuntimeError:
|
|
38
|
+
# No running loop - create a new one
|
|
39
|
+
self._loop = asyncio.new_event_loop()
|
|
40
|
+
return self._loop
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_environment(cls) -> Optional["BrokerLogHandler"]:
|
|
44
|
+
client = BrokerClient.from_environment()
|
|
45
|
+
if client is None:
|
|
46
|
+
return None
|
|
47
|
+
return cls(client)
|
|
48
|
+
|
|
49
|
+
def log(self, event: LogEvent) -> None:
|
|
50
|
+
# Track cost events for aggregation (mirrors IDELogHandler behavior)
|
|
51
|
+
if isinstance(event, CostEvent):
|
|
52
|
+
self.cost_events.append(event)
|
|
53
|
+
|
|
54
|
+
# Serialize to JSON-friendly dict
|
|
55
|
+
event_dict = event.model_dump(mode="json")
|
|
56
|
+
|
|
57
|
+
# Normalize timestamp formatting for downstream consumers.
|
|
58
|
+
iso_string = event.timestamp.isoformat()
|
|
59
|
+
if not (iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2):
|
|
60
|
+
iso_string += "Z"
|
|
61
|
+
event_dict["timestamp"] = iso_string
|
|
62
|
+
|
|
63
|
+
# Best-effort forwarding; never crash the procedure due to streaming.
|
|
64
|
+
try:
|
|
65
|
+
# Try to get the running loop first
|
|
66
|
+
try:
|
|
67
|
+
loop = asyncio.get_running_loop()
|
|
68
|
+
# We're in an async context - schedule and don't wait
|
|
69
|
+
loop.create_task(self._client.emit_event(event_dict))
|
|
70
|
+
except RuntimeError:
|
|
71
|
+
# No running loop - we're being called from a sync thread.
|
|
72
|
+
# Use asyncio.run() which creates a new event loop for this call.
|
|
73
|
+
asyncio.run(self._client.emit_event(event_dict))
|
|
74
|
+
except Exception:
|
|
75
|
+
# Swallow errors; container remains networkless and secretless even if streaming fails.
|
|
76
|
+
pass
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI HITL Handler for interactive human-in-the-loop interactions.
|
|
3
|
+
|
|
4
|
+
Provides interactive prompts for approval, input, review, and escalation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.prompt import Prompt, Confirm
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from tactus.protocols.models import HITLRequest, HITLResponse
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CLIHITLHandler:
|
|
21
|
+
"""
|
|
22
|
+
CLI-based HITL handler using rich prompts.
|
|
23
|
+
|
|
24
|
+
Provides interactive command-line prompts for human-in-the-loop interactions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, console: Optional[Console] = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize CLI HITL handler.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
console: Rich Console instance (creates new one if not provided)
|
|
33
|
+
"""
|
|
34
|
+
self.console = console or Console()
|
|
35
|
+
logger.debug("CLIHITLHandler initialized")
|
|
36
|
+
|
|
37
|
+
def request_interaction(self, procedure_id: str, request: HITLRequest) -> HITLResponse:
|
|
38
|
+
"""
|
|
39
|
+
Request human interaction via CLI prompt.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
procedure_id: Procedure ID
|
|
43
|
+
request: HITLRequest with interaction details
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
HITLResponse with user's response
|
|
47
|
+
"""
|
|
48
|
+
logger.debug(f"HITL request: {request.request_type} - {request.message}")
|
|
49
|
+
|
|
50
|
+
# Display the request in a panel
|
|
51
|
+
self.console.print()
|
|
52
|
+
self.console.print(
|
|
53
|
+
Panel(
|
|
54
|
+
request.message,
|
|
55
|
+
title=f"[bold]{request.request_type.upper()}[/bold]",
|
|
56
|
+
style="yellow",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Handle based on request type
|
|
61
|
+
if request.request_type == "approval":
|
|
62
|
+
return self._handle_approval(request)
|
|
63
|
+
elif request.request_type == "input":
|
|
64
|
+
return self._handle_input(request)
|
|
65
|
+
elif request.request_type == "review":
|
|
66
|
+
return self._handle_review(request)
|
|
67
|
+
elif request.request_type == "escalation":
|
|
68
|
+
return self._handle_escalation(request)
|
|
69
|
+
else:
|
|
70
|
+
# Default: treat as input
|
|
71
|
+
return self._handle_input(request)
|
|
72
|
+
|
|
73
|
+
def _handle_approval(self, request: HITLRequest) -> HITLResponse:
|
|
74
|
+
"""Handle approval request."""
|
|
75
|
+
default = request.default_value if request.default_value is not None else False
|
|
76
|
+
|
|
77
|
+
# Use rich Confirm for yes/no
|
|
78
|
+
approved = Confirm.ask("Approve?", default=default, console=self.console)
|
|
79
|
+
|
|
80
|
+
return HITLResponse(
|
|
81
|
+
value=approved, responded_at=datetime.now(timezone.utc), timed_out=False
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _handle_input(self, request: HITLRequest) -> HITLResponse:
|
|
85
|
+
"""Handle input request."""
|
|
86
|
+
default = str(request.default_value) if request.default_value is not None else None
|
|
87
|
+
|
|
88
|
+
# Check if there are options
|
|
89
|
+
if request.options:
|
|
90
|
+
# Display options
|
|
91
|
+
self.console.print("\n[bold]Options:[/bold]")
|
|
92
|
+
for i, option in enumerate(request.options, 1):
|
|
93
|
+
label = option.get("label", f"Option {i}")
|
|
94
|
+
description = option.get("description", "")
|
|
95
|
+
self.console.print(f" {i}. [cyan]{label}[/cyan]")
|
|
96
|
+
if description:
|
|
97
|
+
self.console.print(f" [dim]{description}[/dim]")
|
|
98
|
+
|
|
99
|
+
# Get choice
|
|
100
|
+
while True:
|
|
101
|
+
choice_str = Prompt.ask(
|
|
102
|
+
"Select option (number)", default=default, console=self.console
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
choice = int(choice_str)
|
|
107
|
+
if 1 <= choice <= len(request.options):
|
|
108
|
+
selected = request.options[choice - 1]
|
|
109
|
+
value = selected.get("value", selected.get("label"))
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
self.console.print(
|
|
113
|
+
f"[red]Invalid choice. Enter 1-{len(request.options)}[/red]"
|
|
114
|
+
)
|
|
115
|
+
except ValueError:
|
|
116
|
+
self.console.print("[red]Invalid input. Enter a number[/red]")
|
|
117
|
+
|
|
118
|
+
else:
|
|
119
|
+
# Free-form input
|
|
120
|
+
value = Prompt.ask("Enter value", default=default, console=self.console)
|
|
121
|
+
|
|
122
|
+
return HITLResponse(value=value, responded_at=datetime.now(timezone.utc), timed_out=False)
|
|
123
|
+
|
|
124
|
+
def _handle_review(self, request: HITLRequest) -> HITLResponse:
|
|
125
|
+
"""Handle review request."""
|
|
126
|
+
self.console.print("\n[bold]Review Options:[/bold]")
|
|
127
|
+
self.console.print(" 1. [green]Approve[/green] - Accept as-is")
|
|
128
|
+
self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
|
|
129
|
+
self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
|
|
130
|
+
|
|
131
|
+
while True:
|
|
132
|
+
choice = Prompt.ask(
|
|
133
|
+
"Your decision",
|
|
134
|
+
choices=["1", "2", "3", "approve", "edit", "reject"],
|
|
135
|
+
default="1",
|
|
136
|
+
console=self.console,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if choice in ["1", "approve"]:
|
|
140
|
+
decision = "approved"
|
|
141
|
+
feedback = None
|
|
142
|
+
edited_artifact = None
|
|
143
|
+
break
|
|
144
|
+
elif choice in ["2", "edit"]:
|
|
145
|
+
decision = "approved"
|
|
146
|
+
feedback = Prompt.ask("What changes would you like?", console=self.console)
|
|
147
|
+
# In CLI, we can't easily edit artifacts, so just provide feedback
|
|
148
|
+
edited_artifact = None
|
|
149
|
+
break
|
|
150
|
+
elif choice in ["3", "reject"]:
|
|
151
|
+
decision = "rejected"
|
|
152
|
+
feedback = Prompt.ask("Why are you rejecting?", console=self.console)
|
|
153
|
+
edited_artifact = None
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
value = {"decision": decision, "feedback": feedback, "edited_artifact": edited_artifact}
|
|
157
|
+
|
|
158
|
+
return HITLResponse(value=value, responded_at=datetime.now(timezone.utc), timed_out=False)
|
|
159
|
+
|
|
160
|
+
def _handle_escalation(self, request: HITLRequest) -> HITLResponse:
|
|
161
|
+
"""Handle escalation request."""
|
|
162
|
+
self.console.print("\n[yellow bold]⚠ This issue requires escalation[/yellow bold]")
|
|
163
|
+
|
|
164
|
+
# Wait for acknowledgment
|
|
165
|
+
Confirm.ask(
|
|
166
|
+
"Press Enter to acknowledge and continue",
|
|
167
|
+
default=True,
|
|
168
|
+
show_default=False,
|
|
169
|
+
console=self.console,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Escalation doesn't need a specific value
|
|
173
|
+
return HITLResponse(value=None, responded_at=datetime.now(timezone.utc), timed_out=False)
|
|
174
|
+
|
|
175
|
+
def check_pending_response(self, procedure_id: str, message_id: str) -> Optional[HITLResponse]:
|
|
176
|
+
"""
|
|
177
|
+
Check for pending response (not used in CLI mode).
|
|
178
|
+
|
|
179
|
+
In CLI mode, interactions are synchronous, so this always returns None.
|
|
180
|
+
"""
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def cancel_pending_request(self, procedure_id: str, message_id: str) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Cancel pending request (not used in CLI mode).
|
|
186
|
+
|
|
187
|
+
In CLI mode, interactions are synchronous, so this is a no-op.
|
|
188
|
+
"""
|
|
189
|
+
pass
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Log Handler for Rich-formatted logging.
|
|
3
|
+
|
|
4
|
+
Renders log events using Rich console for beautiful CLI output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from tactus.protocols.models import LogEvent, CostEvent
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CLILogHandler:
|
|
17
|
+
"""
|
|
18
|
+
CLI log handler using Rich formatting.
|
|
19
|
+
|
|
20
|
+
Receives structured log events and renders them with Rich
|
|
21
|
+
for beautiful console output.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, console: Optional[Console] = None):
|
|
25
|
+
"""
|
|
26
|
+
Initialize CLI log handler.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
console: Rich Console instance (creates new one if not provided)
|
|
30
|
+
"""
|
|
31
|
+
self.console = console or Console()
|
|
32
|
+
self.cost_events = [] # Track cost events for aggregation
|
|
33
|
+
logger.debug("CLILogHandler initialized")
|
|
34
|
+
|
|
35
|
+
def log(self, event: LogEvent) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Render log event with Rich formatting.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
event: Structured log event
|
|
41
|
+
"""
|
|
42
|
+
# Handle stream chunks specially
|
|
43
|
+
from tactus.protocols.models import AgentStreamChunkEvent
|
|
44
|
+
|
|
45
|
+
if isinstance(event, AgentStreamChunkEvent):
|
|
46
|
+
self._display_stream_chunk(event)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
# Handle cost events specially
|
|
50
|
+
if isinstance(event, CostEvent):
|
|
51
|
+
self._display_cost_event(event)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Handle agent turn events
|
|
55
|
+
from tactus.protocols.models import AgentTurnEvent
|
|
56
|
+
|
|
57
|
+
if isinstance(event, AgentTurnEvent):
|
|
58
|
+
self._display_agent_turn_event(event)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Handle tool call events
|
|
62
|
+
from tactus.protocols.models import ToolCallEvent
|
|
63
|
+
|
|
64
|
+
if isinstance(event, ToolCallEvent):
|
|
65
|
+
self._display_tool_call_event(event)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Handle checkpoint created events
|
|
69
|
+
from tactus.protocols.models import CheckpointCreatedEvent
|
|
70
|
+
|
|
71
|
+
if isinstance(event, CheckpointCreatedEvent):
|
|
72
|
+
self._display_checkpoint_event(event)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Handle ExecutionSummaryEvent specially
|
|
76
|
+
if event.event_type == "execution_summary":
|
|
77
|
+
self._display_execution_summary(event)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Use Rich to format nicely for other events
|
|
81
|
+
if hasattr(event, "context") and event.context:
|
|
82
|
+
# Log with context formatted as part of the message
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
context_str = json.dumps(event.context, indent=2)
|
|
86
|
+
self.console.log(f"{event.message}\n{context_str}")
|
|
87
|
+
else:
|
|
88
|
+
# Simple log message
|
|
89
|
+
self.console.log(event.message)
|
|
90
|
+
|
|
91
|
+
def _display_stream_chunk(self, event) -> None:
|
|
92
|
+
"""Display streaming text chunk in real-time."""
|
|
93
|
+
# Print chunk without newline so text flows naturally
|
|
94
|
+
# Use markup=False to avoid interpreting Rich markup in the text
|
|
95
|
+
self.console.print(event.chunk_text, end="", markup=False)
|
|
96
|
+
|
|
97
|
+
def _display_agent_turn_event(self, event) -> None:
|
|
98
|
+
"""Display agent turn start/complete event."""
|
|
99
|
+
|
|
100
|
+
if event.stage == "started":
|
|
101
|
+
self.console.print(
|
|
102
|
+
f"[blue]→ Agent[/blue] [bold]{event.agent_name}[/bold]: [blue]Waiting for response...[/blue]"
|
|
103
|
+
)
|
|
104
|
+
elif event.stage == "completed":
|
|
105
|
+
# Add newline after streaming completes to separate from next output
|
|
106
|
+
self.console.print() # Newline after streamed text
|
|
107
|
+
duration_str = f"{event.duration_ms:.0f}ms" if event.duration_ms else ""
|
|
108
|
+
self.console.print(
|
|
109
|
+
f"[green]✓ Agent[/green] [bold]{event.agent_name}[/bold]: [green]Completed[/green] {duration_str}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _display_tool_call_event(self, event) -> None:
|
|
113
|
+
"""Display tool call event."""
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
# Format arguments compactly if they're simple
|
|
117
|
+
args_str = ""
|
|
118
|
+
if event.tool_args:
|
|
119
|
+
# For simple args, show inline
|
|
120
|
+
if len(event.tool_args) == 1 and len(str(event.tool_args)) < 60:
|
|
121
|
+
args_str = f" {json.dumps(event.tool_args, default=str)}"
|
|
122
|
+
else:
|
|
123
|
+
# For complex args, show on next line
|
|
124
|
+
args_str = f"\n Args: {json.dumps(event.tool_args, indent=2, default=str)}"
|
|
125
|
+
|
|
126
|
+
# Format result if available
|
|
127
|
+
result_str = ""
|
|
128
|
+
if event.tool_result is not None:
|
|
129
|
+
result_value = str(event.tool_result)
|
|
130
|
+
if len(result_value) < 60:
|
|
131
|
+
result_str = f"\n Result: {result_value}"
|
|
132
|
+
else:
|
|
133
|
+
# Truncate long results
|
|
134
|
+
result_str = f"\n Result: {result_value[:57]}..."
|
|
135
|
+
|
|
136
|
+
duration_str = f" ({event.duration_ms:.0f}ms)" if event.duration_ms else ""
|
|
137
|
+
|
|
138
|
+
self.console.print(
|
|
139
|
+
f"[cyan]→ Tool[/cyan] [bold]{event.tool_name}[/bold]{args_str}{result_str}{duration_str}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _display_checkpoint_event(self, event) -> None:
|
|
143
|
+
"""Display checkpoint created event."""
|
|
144
|
+
# Format checkpoint type (e.g., "agent_turn" -> "Agent Turn")
|
|
145
|
+
type_display = event.checkpoint_type.replace("_", " ").title()
|
|
146
|
+
|
|
147
|
+
# Format duration if available
|
|
148
|
+
duration_str = f" ({event.duration_ms:.0f}ms)" if event.duration_ms else ""
|
|
149
|
+
|
|
150
|
+
# Format source location if available
|
|
151
|
+
location_str = ""
|
|
152
|
+
if event.source_location:
|
|
153
|
+
location_str = (
|
|
154
|
+
f"\n Location: {event.source_location.file}:{event.source_location.line}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self.console.print(
|
|
158
|
+
f"[yellow]• Checkpoint[/yellow] [bold]{event.checkpoint_position}[/bold]: {type_display}{duration_str}{location_str}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _display_cost_event(self, event: CostEvent) -> None:
|
|
162
|
+
"""Display cost event with comprehensive metrics."""
|
|
163
|
+
# Track cost event for aggregation
|
|
164
|
+
self.cost_events.append(event)
|
|
165
|
+
|
|
166
|
+
# Primary metrics - always show
|
|
167
|
+
self.console.print(
|
|
168
|
+
f"[green]$ Cost[/green] [bold]{event.agent_name}[/bold]: "
|
|
169
|
+
f"[green bold]${event.total_cost:.6f}[/green bold] "
|
|
170
|
+
f"({event.total_tokens:,} tokens, {event.model}"
|
|
171
|
+
f"{f', {event.duration_ms:.0f}ms' if event.duration_ms else ''})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Show retry warning if applicable
|
|
175
|
+
if event.retry_count > 0:
|
|
176
|
+
self.console.print(
|
|
177
|
+
f" [yellow]⚠ Retried {event.retry_count} time(s) due to validation[/yellow]"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Show cache hit if applicable
|
|
181
|
+
if event.cache_hit and event.cache_tokens:
|
|
182
|
+
self.console.print(
|
|
183
|
+
f" [green]✓ Cache hit: {event.cache_tokens:,} tokens"
|
|
184
|
+
f"{f' (saved ${event.cache_cost:.6f})' if event.cache_cost else ''}[/green]"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _display_execution_summary(self, event) -> None:
|
|
188
|
+
"""Display execution summary with cost breakdown."""
|
|
189
|
+
self.console.print(
|
|
190
|
+
f"\n[green bold]✓ Procedure completed[/green bold]: "
|
|
191
|
+
f"{event.iterations} iterations, {len(event.tools_used)} tools used"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Display cost summary if costs were incurred
|
|
195
|
+
if hasattr(event, "total_cost") and event.total_cost > 0:
|
|
196
|
+
self.console.print("\n[green bold]$ Cost Summary[/green bold]")
|
|
197
|
+
self.console.print(f" Total Cost: [green bold]${event.total_cost:.6f}[/green bold]")
|
|
198
|
+
self.console.print(f" Total Tokens: {event.total_tokens:,}")
|
|
199
|
+
|
|
200
|
+
if hasattr(event, "cost_breakdown") and event.cost_breakdown:
|
|
201
|
+
self.console.print("\n [bold]Per-call breakdown:[/bold]")
|
|
202
|
+
for cost in event.cost_breakdown:
|
|
203
|
+
self.console.print(
|
|
204
|
+
f" {cost.agent_name}: ${cost.total_cost:.6f} "
|
|
205
|
+
f"({cost.total_tokens:,} tokens, {cost.duration_ms:.0f}ms)"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Display checkpoint summary if checkpoints were created
|
|
209
|
+
if hasattr(event, "checkpoint_count") and event.checkpoint_count > 0:
|
|
210
|
+
self.console.print("\n[yellow bold]• Checkpoint Summary[/yellow bold]")
|
|
211
|
+
self.console.print(f" Total Checkpoints: {event.checkpoint_count}")
|
|
212
|
+
|
|
213
|
+
if hasattr(event, "checkpoint_types") and event.checkpoint_types:
|
|
214
|
+
self.console.print(" Types:")
|
|
215
|
+
for checkpoint_type, count in sorted(event.checkpoint_types.items()):
|
|
216
|
+
type_display = checkpoint_type.replace("_", " ").title()
|
|
217
|
+
self.console.print(f" {type_display}: {count}")
|
|
218
|
+
|
|
219
|
+
if hasattr(event, "checkpoint_duration_ms") and event.checkpoint_duration_ms:
|
|
220
|
+
avg_duration = event.checkpoint_duration_ms / event.checkpoint_count
|
|
221
|
+
total_seconds = event.checkpoint_duration_ms / 1000
|
|
222
|
+
self.console.print(f" Average Duration: {avg_duration:.0f}ms")
|
|
223
|
+
self.console.print(f" Total Duration: {total_seconds:.1f}s")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost-only log handler for headless/sandbox runs.
|
|
3
|
+
|
|
4
|
+
Collects CostEvent instances so the runtime can report total_cost/total_tokens,
|
|
5
|
+
without enabling streaming UI behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import json
|
|
12
|
+
from typing import List
|
|
13
|
+
|
|
14
|
+
from tactus.protocols.models import CostEvent, LogEvent
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CostCollectorLogHandler:
|
|
20
|
+
"""
|
|
21
|
+
Minimal LogHandler for sandbox runs.
|
|
22
|
+
|
|
23
|
+
This is useful in environments like Docker sandboxes where stdout is reserved
|
|
24
|
+
for protocol output, but we still want:
|
|
25
|
+
- accurate cost accounting (CostEvent)
|
|
26
|
+
- basic procedure logging (LogEvent) via stderr/Python logging
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
supports_streaming = False
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.cost_events: List[CostEvent] = []
|
|
33
|
+
logger.debug("CostCollectorLogHandler initialized")
|
|
34
|
+
|
|
35
|
+
def log(self, event: LogEvent) -> None:
|
|
36
|
+
if isinstance(event, CostEvent):
|
|
37
|
+
self.cost_events.append(event)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Preserve useful procedure logs even when no IDE callback is present.
|
|
41
|
+
if isinstance(event, LogEvent):
|
|
42
|
+
event_logger = logging.getLogger(event.logger_name or "procedure")
|
|
43
|
+
|
|
44
|
+
msg = event.message
|
|
45
|
+
if event.context:
|
|
46
|
+
msg = f"{msg}\nContext: {json.dumps(event.context, indent=2, default=str)}"
|
|
47
|
+
|
|
48
|
+
level = (event.level or "INFO").upper()
|
|
49
|
+
if level == "DEBUG":
|
|
50
|
+
event_logger.debug(msg)
|
|
51
|
+
elif level in ("WARN", "WARNING"):
|
|
52
|
+
event_logger.warning(msg)
|
|
53
|
+
elif level == "ERROR":
|
|
54
|
+
event_logger.error(msg)
|
|
55
|
+
else:
|
|
56
|
+
event_logger.info(msg)
|