agentshim 0.1.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.
@@ -0,0 +1,196 @@
1
+ import json
2
+ import subprocess
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from agentshim.trajectory import TrajectoryRecorderProtocol
7
+
8
+ from ..base import register_provider
9
+ from ..cli_agent import CLICodingAgent, CLIGenerationSession
10
+ from ..events import AgentEventHandler
11
+ from ..sandbox import SandboxConfig
12
+ from ..usage import ProviderUsage, TokenUsage
13
+ from .events import OpencodeEvent, StepFinishEvent, TextEvent, ToolUseEvent
14
+
15
+ OPENCODE_DEFAULT_MODEL = "google-vertex/gemini-3-pro-preview"
16
+
17
+
18
+ def _to_args_dict(input_data: Any) -> dict[str, Any]:
19
+ """Convert input_data to a dict[str, Any] for tool call recording."""
20
+ if isinstance(input_data, dict):
21
+ return {str(k): v for k, v in input_data.items()} # type: ignore[reportUnknownVariableType]
22
+ return {"input": input_data}
23
+
24
+
25
+ class OpencodeGenerationSession(CLIGenerationSession):
26
+ def __init__(self, **kwargs: Any):
27
+ super().__init__(**kwargs)
28
+ # Accumulates usage across step_finish events.
29
+ self._accumulated_tokens = TokenUsage()
30
+ self._accumulated_cost_usd: float = 0.0
31
+ self._saw_cost: bool = False
32
+
33
+ def _process_stdout(self, line: str) -> None:
34
+ """Process a line from stdout."""
35
+ if not line:
36
+ return
37
+ try:
38
+ data = json.loads(line)
39
+ if self.session_id is None:
40
+ sid = data.get("sessionID")
41
+ if isinstance(sid, str) and sid:
42
+ self.session_id = sid
43
+ event = OpencodeEvent.from_dict(data)
44
+ if event:
45
+ self._handle_event(event)
46
+ except json.JSONDecodeError:
47
+ if not self.silent:
48
+ if self._at_line_start:
49
+ self._log_raw(f"{self.log_prefix} ")
50
+ self._log_raw(line.rstrip() + "\n")
51
+ self._at_line_start = True
52
+
53
+ def _handle_event(self, event: OpencodeEvent):
54
+ """Handle a single parsed Opencode event."""
55
+ if isinstance(event, TextEvent):
56
+ self.stdout_lines.append(event.text)
57
+ if self.event_handler:
58
+ self.event_handler.on_thinking(event.text)
59
+
60
+ elif isinstance(event, StepFinishEvent):
61
+ self._update_usage_from_step(event)
62
+
63
+ elif isinstance(event, ToolUseEvent):
64
+ if event.status in ("success", "error"):
65
+ args = _to_args_dict(event.input_data)
66
+ stdout = str(event.output_data) if event.output_data is not None else ""
67
+
68
+ self.recorder.add_tool_call(
69
+ tool=event.tool_name,
70
+ args=args,
71
+ stdout=stdout,
72
+ )
73
+
74
+ if self.event_handler:
75
+ self.event_handler.on_tool_call(event.tool_name, args)
76
+ self.event_handler.on_tool_result(
77
+ tool=event.tool_name,
78
+ stdout=stdout,
79
+ )
80
+
81
+ if not self.silent:
82
+ self._render_event(event)
83
+
84
+ def _update_usage_from_step(self, event: StepFinishEvent) -> None:
85
+ """Fold a step_finish payload into the running usage totals."""
86
+ tokens: dict[str, Any] = event.tokens or {}
87
+ cache: dict[str, Any] = tokens.get("cache") or {}
88
+ cache_read = int(cache.get("read") or 0)
89
+ cache_write = int(cache.get("write") or 0)
90
+ cached = cache_read + cache_write
91
+ step_usage = TokenUsage(
92
+ input_tokens=int(tokens.get("input") or 0) + cached,
93
+ output_tokens=int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0),
94
+ cached_input_tokens=cached,
95
+ turns=1,
96
+ )
97
+ self._accumulated_tokens = self._accumulated_tokens + step_usage
98
+ if event.cost is not None:
99
+ self._accumulated_cost_usd += float(event.cost)
100
+ self._saw_cost = True
101
+ self.usage = ProviderUsage(
102
+ tokens=self._accumulated_tokens,
103
+ total_cost_usd=self._accumulated_cost_usd if self._saw_cost else None,
104
+ provider="opencode",
105
+ )
106
+
107
+ def _render_event(self, event: OpencodeEvent):
108
+ """Render the event to stdout."""
109
+ if isinstance(event, TextEvent):
110
+ self._print_stream_content(event.text)
111
+ return
112
+
113
+ if not self._at_line_start:
114
+ self._log_raw("\n")
115
+ self._at_line_start = True
116
+
117
+ output = event.render(self.log_prefix)
118
+ if output:
119
+ self._log_raw(output + "\n")
120
+
121
+
122
+ @register_provider("opencode")
123
+ class OpencodeCodingAgent(CLICodingAgent):
124
+ """Coding agent implementation using the Opencode CLI tool."""
125
+
126
+ def __init__(
127
+ self,
128
+ model: str | None = None,
129
+ recorder: TrajectoryRecorderProtocol | None = None,
130
+ event_handler: AgentEventHandler | None = None,
131
+ mcp_servers: list[object] | None = None,
132
+ sandbox: bool | SandboxConfig = False,
133
+ ):
134
+ """Initialize the Opencode coding agent.
135
+
136
+ Args:
137
+ model: Optional model name to use.
138
+ recorder: Trajectory recorder instance.
139
+ event_handler: Optional event handler for UI updates.
140
+ mcp_servers: Optional list of MCP server configurations.
141
+ sandbox: Not supported for Opencode; must be False.
142
+
143
+ Raises:
144
+ ValueError: If mcp_servers is non-empty (not supported).
145
+ NotImplementedError: If ``sandbox`` is truthy.
146
+ """
147
+ if mcp_servers:
148
+ raise ValueError("OpencodeCodingAgent does not support programmatic MCP server configuration via CLI flags")
149
+ if sandbox:
150
+ raise NotImplementedError("sandbox is not supported for OpencodeCodingAgent")
151
+ if not model:
152
+ model = OPENCODE_DEFAULT_MODEL
153
+ super().__init__("opencode", model, recorder, event_handler)
154
+
155
+ @property
156
+ def _log_prefix(self) -> str:
157
+ """Return the log prefix for this agent."""
158
+ return "[Opencode]"
159
+
160
+ def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
161
+ cmd = [self.binary_path, "run"]
162
+
163
+ if resume_session_id:
164
+ cmd.extend(["--session", resume_session_id])
165
+
166
+ cmd.append(f'"{prompt}"')
167
+
168
+ if self.model:
169
+ cmd.extend(["--model", self.model])
170
+
171
+ cmd.extend(["--format=json"])
172
+
173
+ return cmd
174
+
175
+ def _create_session(
176
+ self,
177
+ cmd: list[str],
178
+ cwd: str | None = None,
179
+ timeout: int = 300,
180
+ silent: bool = False,
181
+ recorder: TrajectoryRecorderProtocol | None = None,
182
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
183
+ ) -> OpencodeGenerationSession:
184
+ return OpencodeGenerationSession(
185
+ binary_name=self.binary_name,
186
+ env=self.env,
187
+ log_prefix=self._log_prefix,
188
+ cmd=cmd,
189
+ logger=self.logger,
190
+ cwd=cwd,
191
+ timeout=timeout,
192
+ silent=silent,
193
+ recorder=recorder,
194
+ event_handler=self.event_handler,
195
+ on_process_started=on_process_started,
196
+ )
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from ..utils import truncate_tool_params
7
+
8
+
9
+ class OpencodeEvent(ABC):
10
+ """Base class for Opencode stream events."""
11
+
12
+ @abstractmethod
13
+ def render(self, log_prefix: str) -> str | None:
14
+ """Render the event as a string for terminal output."""
15
+
16
+ @staticmethod
17
+ def from_dict(data: dict[str, Any]) -> OpencodeEvent | None:
18
+ """Factory method to create events from JSON data."""
19
+ msg_type = data.get("type")
20
+ part = data.get("part", {})
21
+
22
+ if msg_type == "text":
23
+ return TextEvent(text=part.get("text", ""))
24
+ if msg_type == "tool_use":
25
+ return ToolUseEvent(
26
+ tool_name=part.get("tool", "Tool"),
27
+ input_data=part.get("state", {}).get("input"),
28
+ output_data=part.get("state", {}).get("output"),
29
+ status=part.get("state", {}).get("status"),
30
+ )
31
+ if msg_type == "step_start":
32
+ return StepStartEvent()
33
+ if msg_type == "step_finish":
34
+ return StepFinishEvent(
35
+ reason=part.get("reason"),
36
+ cost=part.get("cost"),
37
+ tokens=part.get("tokens"),
38
+ )
39
+
40
+ return None
41
+
42
+
43
+ class TextEvent(OpencodeEvent):
44
+ def __init__(self, text: str):
45
+ self.text = text
46
+
47
+ def render(self, log_prefix: str) -> str | None:
48
+ # We will handle text printing in the agent loop to handle potential streaming
49
+ # or just print it as is.
50
+ # For now, let's return it.
51
+ return self.text
52
+
53
+
54
+ class ToolUseEvent(OpencodeEvent):
55
+ def __init__(self, tool_name: str, input_data: Any, output_data: Any, status: str):
56
+ self.tool_name = tool_name
57
+ self.input_data = input_data
58
+ self.output_data = output_data
59
+ self.status = status
60
+
61
+ def render(self, log_prefix: str) -> str:
62
+ # Render tool use and result
63
+ truncated_input = truncate_tool_params(self.tool_name, self.input_data)
64
+
65
+ output_str = ""
66
+ if self.output_data:
67
+ # Truncate output to first 5 lines
68
+ out_lines = str(self.output_data).splitlines()
69
+ truncated_output = "\n".join(out_lines[:5] + (["..."] if len(out_lines) > 5 else []))
70
+ output_str = f"\n{log_prefix} \033[32m[Tool Result] {truncated_output}\033[0m"
71
+
72
+ return f"{log_prefix} \033[34m[Tool Use] {self.tool_name} {truncated_input}\033[0m{output_str}"
73
+
74
+
75
+ class StepStartEvent(OpencodeEvent):
76
+ def render(self, log_prefix: str) -> str | None:
77
+ return None
78
+
79
+
80
+ class StepFinishEvent(OpencodeEvent):
81
+ def __init__(self, reason: str | None, cost: float | None, tokens: dict[str, Any] | None):
82
+ self.reason = reason
83
+ self.cost = cost
84
+ self.tokens = tokens
85
+
86
+ def render(self, log_prefix: str) -> str | None:
87
+ # Optional: Print cost info?
88
+ # For now, maybe just ignore or print verbose.
89
+ # Let's keep it clean.
90
+ return None
@@ -0,0 +1,11 @@
1
+ """Compatibility shim for older ``agentshim.opencode_events`` imports."""
2
+
3
+ from .opencode.events import OpencodeEvent, StepFinishEvent, StepStartEvent, TextEvent, ToolUseEvent
4
+
5
+ __all__ = [
6
+ "OpencodeEvent",
7
+ "StepFinishEvent",
8
+ "StepStartEvent",
9
+ "TextEvent",
10
+ "ToolUseEvent",
11
+ ]
agentshim/py.typed ADDED
File without changes
agentshim/sandbox.py ADDED
@@ -0,0 +1,133 @@
1
+ """Sandbox support for CLI coding agents.
2
+
3
+ Currently only ``ClaudeCodeCodingAgent`` supports sandboxing, via Claude
4
+ Code's native bubblewrap/Seatbelt sandbox (configured through its
5
+ ``settings.json`` schema). Other providers raise ``NotImplementedError``
6
+ if sandboxing is requested.
7
+
8
+ See https://code.claude.com/docs/en/sandboxing for how the native sandbox
9
+ scopes bash subprocesses at the OS level (filesystem + network), without
10
+ wrapping the Claude process itself.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ _CONFINE_READS_HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude", "hooks", "confine_reads.py")
20
+
21
+
22
+ @dataclass
23
+ class SandboxConfig:
24
+ """Claude Code sandbox configuration.
25
+
26
+ Fields mirror a subset of the ``sandbox`` key in Claude Code's
27
+ ``settings.json``. Only the knobs we actually need are exposed; pass
28
+ additional keys through ``extra_settings`` if you need something not
29
+ modeled here.
30
+
31
+ Attributes:
32
+ fail_if_unavailable: If True, Claude exits with an error when the
33
+ sandbox can't start (missing ``bwrap``, unsupported platform)
34
+ instead of silently falling back to unsandboxed execution.
35
+ Recommended to keep True so sandbox behavior is a hard gate.
36
+ auto_allow_bash: If True, sandboxed bash commands are auto-approved
37
+ without permission prompts (they're already constrained by the
38
+ sandbox).
39
+ allow_unsandboxed_commands: If False, disables the
40
+ ``dangerouslyDisableSandbox`` escape hatch entirely.
41
+ excluded_commands: Commands that should run *outside* the sandbox
42
+ (e.g. ``["docker *"]``).
43
+ allow_write: Extra paths where sandboxed commands may write (cwd
44
+ is always writable by default).
45
+ deny_write: Paths sandboxed commands must not write to.
46
+ allow_read: Paths to re-allow inside ``deny_read`` regions.
47
+ deny_read: Paths sandboxed commands must not read.
48
+ allowed_domains: Outbound network domains allowed for bash
49
+ subprocesses (e.g. ``["github.com", "*.npmjs.org"]``).
50
+ Does not affect Claude's own API calls.
51
+ extra_settings: Raw dict merged into the ``sandbox`` settings
52
+ block, for fields not covered above.
53
+ """
54
+
55
+ fail_if_unavailable: bool = True
56
+ auto_allow_bash: bool = True
57
+ allow_unsandboxed_commands: bool = False
58
+ excluded_commands: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
59
+ allow_write: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
60
+ deny_write: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
61
+ allow_read: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
62
+ deny_read: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
63
+ allowed_domains: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
64
+ extra_settings: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
65
+
66
+ # The OS-level sandbox only wraps bash subprocesses, not Claude's built-in
67
+ # Read/Glob/Grep/Edit/Write tools. When this list is non-empty we inject a
68
+ # PreToolUse hook that denies any tool call whose target path is outside
69
+ # the listed roots. Leave empty to allow Claude's native tools to read
70
+ # anywhere (the default; matches unsandboxed behavior).
71
+ confine_native_reads_to: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
72
+
73
+
74
+ def resolve_sandbox(value: bool | SandboxConfig | None) -> SandboxConfig | None:
75
+ """Normalize a user-supplied ``sandbox`` argument to a SandboxConfig or None."""
76
+ if value is None or value is False:
77
+ return None
78
+ if value is True:
79
+ return SandboxConfig()
80
+ if isinstance(value, SandboxConfig): # pyright: ignore[reportUnnecessaryIsInstance]
81
+ return value
82
+ raise TypeError(f"sandbox must be bool or SandboxConfig, got {type(value).__name__}")
83
+
84
+
85
+ def build_claude_sandbox_settings(config: SandboxConfig) -> dict[str, Any]:
86
+ """Build the Claude Code ``settings.json`` payload that enables the sandbox."""
87
+ sandbox: dict[str, Any] = {
88
+ "enabled": True,
89
+ "failIfUnavailable": config.fail_if_unavailable,
90
+ "autoAllowBashIfSandboxed": config.auto_allow_bash,
91
+ "allowUnsandboxedCommands": config.allow_unsandboxed_commands,
92
+ }
93
+
94
+ if config.excluded_commands:
95
+ sandbox["excludedCommands"] = list(config.excluded_commands)
96
+
97
+ filesystem: dict[str, Any] = {}
98
+ if config.allow_write:
99
+ filesystem["allowWrite"] = list(config.allow_write)
100
+ if config.deny_write:
101
+ filesystem["denyWrite"] = list(config.deny_write)
102
+ if config.allow_read:
103
+ filesystem["allowRead"] = list(config.allow_read)
104
+ if config.deny_read:
105
+ filesystem["denyRead"] = list(config.deny_read)
106
+ if filesystem:
107
+ sandbox["filesystem"] = filesystem
108
+
109
+ if config.allowed_domains:
110
+ sandbox["network"] = {"allowedDomains": list(config.allowed_domains)}
111
+
112
+ sandbox.update(config.extra_settings)
113
+
114
+ settings: dict[str, Any] = {"sandbox": sandbox}
115
+ if config.confine_native_reads_to:
116
+ settings["hooks"] = _build_confine_reads_hook(config.confine_native_reads_to)
117
+ return settings
118
+
119
+
120
+ def _build_confine_reads_hook(roots: list[str]) -> dict[str, Any]:
121
+ """Build the ``hooks`` block that denies native-tool reads outside ``roots``."""
122
+ resolved = [os.path.realpath(r) for r in roots]
123
+ # Quote each arg with double-quotes so paths with spaces survive shell parsing.
124
+ args = " ".join(f'"{r}"' for r in resolved)
125
+ command = f'"{_CONFINE_READS_HOOK}" {args}'
126
+ return {
127
+ "PreToolUse": [
128
+ {
129
+ "matcher": "Read|Glob|Grep|Edit|Write|NotebookEdit",
130
+ "hooks": [{"type": "command", "command": command}],
131
+ }
132
+ ]
133
+ }
agentshim/subagent.py ADDED
@@ -0,0 +1,93 @@
1
+ """Shared subagent primitive for isolated LLM calls.
2
+
3
+ Provides ``call_subagent()`` — a single fresh litellm call with its own
4
+ message list. Used by RLM (isolated recursive calls) and the
5
+ SubagentCodingAgent (fan-out analysis calls).
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ import time
11
+ from typing import Any
12
+
13
+ import litellm
14
+ from loguru import logger
15
+
16
+ _NETWORK_ERROR_MARKERS = (
17
+ "nameresolutionerror",
18
+ "name or service not known",
19
+ "transporterror",
20
+ "apiconnectionerror",
21
+ "connectionerror",
22
+ "max retries exceeded",
23
+ )
24
+
25
+
26
+ def litellm_call_with_retry(
27
+ kwargs: dict[str, Any],
28
+ label: str,
29
+ max_attempts: int = 3,
30
+ token_acc: dict[str, int] | None = None,
31
+ ) -> str:
32
+ """Call litellm.completion with retry on transient network errors."""
33
+ for attempt in range(max_attempts):
34
+ try:
35
+ response = litellm.completion(**kwargs) # type: ignore[reportUnknownMemberType]
36
+ if token_acc is not None:
37
+ usage = getattr(response, "usage", None)
38
+ if usage:
39
+ token_acc["prompt_tokens"] = token_acc.get("prompt_tokens", 0) + (
40
+ getattr(usage, "prompt_tokens", 0) or 0
41
+ )
42
+ token_acc["completion_tokens"] = token_acc.get("completion_tokens", 0) + (
43
+ getattr(usage, "completion_tokens", 0) or 0
44
+ )
45
+ token_acc["total_tokens"] = token_acc.get("total_tokens", 0) + (
46
+ getattr(usage, "total_tokens", 0) or 0
47
+ )
48
+ return response.choices[0].message.content or "" # type: ignore[reportAttributeAccessIssue]
49
+ except Exception as e:
50
+ if any(m in str(e).lower() for m in _NETWORK_ERROR_MARKERS) and attempt < max_attempts - 1:
51
+ delay = 15 * (2**attempt)
52
+ logger.warning(
53
+ f"[RLM] {label}: transient network error (attempt {attempt + 1}/{max_attempts}), "
54
+ f"retrying in {delay}s: {e}"
55
+ )
56
+ time.sleep(delay)
57
+ else:
58
+ raise
59
+
60
+ return "" # unreachable; satisfies type checker
61
+
62
+
63
+ def call_subagent(
64
+ model: str,
65
+ system_prompt: str,
66
+ user_prompt: str,
67
+ location: str | None = None,
68
+ token_acc: dict[str, int] | None = None,
69
+ ) -> str:
70
+ """Make a completely fresh, isolated litellm call.
71
+
72
+ Builds a new ``messages`` list from scratch (system + user), so there is
73
+ no shared conversation history with any other call.
74
+ """
75
+ kwargs: dict[str, Any] = {
76
+ "model": model,
77
+ "messages": [
78
+ {"role": "system", "content": system_prompt},
79
+ {"role": "user", "content": user_prompt},
80
+ ],
81
+ "cache": {"no-cache": True},
82
+ }
83
+ loc = location or os.environ.get("VERTEX_LOCATION")
84
+ if loc:
85
+ kwargs["vertex_location"] = loc
86
+
87
+ try:
88
+ return litellm_call_with_retry(kwargs, label="subagent call", token_acc=token_acc)
89
+ except KeyboardInterrupt:
90
+ raise
91
+ except (TimeoutError, ConnectionError, subprocess.SubprocessError, OSError) as e:
92
+ logger.error(f"[Subagent] LLM call failed: {type(e).__name__}: {e}")
93
+ return f"Subagent call failed: {type(e).__name__}: {e}"
@@ -0,0 +1,168 @@
1
+ """Trajectory protocol and null implementation for the agent_cli library.
2
+
3
+ This module provides the protocol interface and no-op implementation that
4
+ standalone library code can use without depending on app_operator.
5
+
6
+ The full TrajectoryRecorder implementation lives in app_operator/trajectory.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+
19
+ class TokenUsage(TypedDict):
20
+ """Token usage statistics from an LLM call."""
21
+
22
+ prompt_tokens: int
23
+ completion_tokens: int
24
+ total_tokens: int
25
+
26
+
27
+ class FaultInjectionMetadata(TypedDict):
28
+ """Metadata about fault injection for embedding in trajectory JSON."""
29
+
30
+ enabled: bool
31
+ num_faults_requested: int
32
+ num_faults_injected: int
33
+ faults: list[Any]
34
+ failed_injections: list[Any]
35
+ fault_ids: list[str]
36
+ categories: list[str]
37
+ severities: list[str]
38
+
39
+
40
+ @runtime_checkable
41
+ class TrajectoryRecorderProtocol(Protocol):
42
+ """Protocol for trajectory recorders."""
43
+
44
+ def start_phase(self, phase: Any, context: dict[str, Any] | None = None) -> None: ...
45
+ def end_phase(self, status: str | None = None) -> None: ...
46
+ def add_system_message(self, content: str) -> None: ...
47
+ def add_user_message(self, content: str) -> None: ...
48
+
49
+ def add_assistant_message(self, content: str, duration: float | None = None) -> None: ...
50
+
51
+ def add_tool_call(
52
+ self,
53
+ tool: str,
54
+ args: dict[str, Any],
55
+ stdout: str = "",
56
+ stderr: str = "",
57
+ exit_code: int | None = None,
58
+ duration: float | None = None,
59
+ ) -> None: ...
60
+ def set_phase_status(self, status: str) -> None: ...
61
+ def set_agent_name(self, agent_name: str) -> None: ...
62
+ def set_prompt_version(self, version: str) -> None: ...
63
+ def record_fallback(self) -> None: ...
64
+ def record_prompt_kwargs(self, kwargs: dict[str, Any]) -> None: ...
65
+ def record_rendered_prompt(self, rendered_prompt: str) -> None: ...
66
+ def record_fault_injection(self, metadata: FaultInjectionMetadata) -> None: ...
67
+ def record_token_usage(self, usage: TokenUsage) -> None: ...
68
+ def finalize(self, status: str = "completed") -> Path: ...
69
+
70
+ def phase(self, phase: Any, context: dict[str, Any] | None = None) -> Any: ...
71
+
72
+
73
+ class NullTrajectoryRecorder(TrajectoryRecorderProtocol):
74
+ """No-op recorder that formally implements TrajectoryRecorderProtocol.
75
+
76
+ Used as a default when no real recorder is needed (e.g. in tests).
77
+ Every method is a no-op, so callers never need to check for None.
78
+ """
79
+
80
+ def start_phase(self, phase: Any, context: dict[str, Any] | None = None) -> None:
81
+ pass
82
+
83
+ def end_phase(self, status: str | None = None) -> None:
84
+ pass
85
+
86
+ def add_system_message(self, content: str) -> None:
87
+ pass
88
+
89
+ def add_user_message(self, content: str) -> None:
90
+ pass
91
+
92
+ def add_assistant_message(self, content: str, duration: float | None = None) -> None:
93
+ pass
94
+
95
+ def add_tool_call(
96
+ self,
97
+ tool: str,
98
+ args: dict[str, Any],
99
+ stdout: str = "",
100
+ stderr: str = "",
101
+ exit_code: int | None = None,
102
+ duration: float | None = None,
103
+ ) -> None:
104
+ pass
105
+
106
+ def set_phase_status(self, status: str) -> None:
107
+ pass
108
+
109
+ def set_agent_name(self, agent_name: str) -> None:
110
+ pass
111
+
112
+ def set_prompt_version(self, version: str) -> None:
113
+ pass
114
+
115
+ def record_fallback(self) -> None:
116
+ pass
117
+
118
+ def record_prompt_kwargs(self, kwargs: dict[str, Any]) -> None:
119
+ pass
120
+
121
+ def record_rendered_prompt(self, rendered_prompt: str) -> None:
122
+ pass
123
+
124
+ def record_fault_injection(self, metadata: FaultInjectionMetadata) -> None:
125
+ pass
126
+
127
+ def record_token_usage(self, usage: TokenUsage) -> None:
128
+ pass
129
+
130
+ @contextmanager
131
+ def phase(self, phase: Any, context: dict[str, Any] | None = None):
132
+ yield self
133
+
134
+ def finalize(self, status: str = "completed") -> Path:
135
+ return Path("/dev/null")
136
+
137
+
138
+ def _call_id_provider() -> int | None:
139
+ return None
140
+
141
+
142
+ def _run_id_provider() -> str | None:
143
+ return None
144
+
145
+
146
+ def register_context_providers(
147
+ call_id_fn: Callable[[], int | None],
148
+ run_id_fn: Callable[[], str | None],
149
+ ) -> None:
150
+ """Register live context providers for trajectory correlation.
151
+
152
+ Called by app_operator.trajectory at import time to wire in the real
153
+ thread-local implementations. Standalone consumers get None from the
154
+ default def stubs, which is correct outside of app_operator.
155
+ """
156
+ global _call_id_provider, _run_id_provider
157
+ _call_id_provider = call_id_fn
158
+ _run_id_provider = run_id_fn
159
+
160
+
161
+ def get_current_call_id() -> int | None:
162
+ """Return the current call ID, or None if no trajectory is active."""
163
+ return _call_id_provider()
164
+
165
+
166
+ def get_run_id() -> str | None:
167
+ """Return the current run ID, or None if no trajectory is active."""
168
+ return _run_id_provider()