tactus 0.31.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 (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.0.dist-info/METADATA +1809 -0
  157. tactus-0.31.0.dist-info/RECORD +160 -0
  158. tactus-0.31.0.dist-info/WHEEL +4 -0
  159. tactus-0.31.0.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.0.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.0"
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)