tracectrl 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.
tracectrl/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """TraceCtrl SDK — security-enriched OpenTelemetry for agentic AI."""
2
+
3
+ # Enable namespace package merging so tracectrl.instrumentation.*
4
+ # sub-packages installed separately are discoverable.
5
+ from pkgutil import extend_path
6
+ __path__ = extend_path(__path__, __name__)
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from tracectrl.config import configure # noqa: F401
11
+ from tracectrl.context import ingress # noqa: F401
12
+ from tracectrl.agent_tagging import tag_agent, tag_agents # noqa: F401
tracectrl/_tui.py ADDED
@@ -0,0 +1,44 @@
1
+ """Re-export the TUI app for the CLI entry point.
2
+
3
+ When installed via pip, the setup TUI is accessible as:
4
+ from tracectrl._tui import TraceCtrlApp
5
+
6
+ The actual TUI code lives in setup/tui.py at the repo root.
7
+ This module imports and re-exports it for pip-installed usage.
8
+ """
9
+
10
+ # Try importing from the repo's setup directory first (dev mode),
11
+ # then fall back to a bundled copy if one exists.
12
+ import importlib.util
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def _load_from_repo():
18
+ """Try to load tui.py from the repo's setup/ directory."""
19
+ # Walk up from this file to find the repo root
20
+ current = Path(__file__).resolve().parent
21
+ for _ in range(10):
22
+ candidate = current / "setup" / "tui.py"
23
+ if candidate.exists():
24
+ spec = importlib.util.spec_from_file_location("_tui_impl", candidate)
25
+ if spec and spec.loader:
26
+ mod = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(mod)
28
+ return mod
29
+ current = current.parent
30
+ return None
31
+
32
+
33
+ _mod = _load_from_repo()
34
+ if _mod and hasattr(_mod, "TraceCtrlApp"):
35
+ TraceCtrlApp = _mod.TraceCtrlApp
36
+ elif _mod and hasattr(_mod, "TraceCtrlSetup"):
37
+ TraceCtrlApp = _mod.TraceCtrlSetup
38
+ else:
39
+ # Provide a helpful error if the TUI can't be found
40
+ class TraceCtrlApp:
41
+ def run(self):
42
+ print("Could not find the TUI setup wizard.")
43
+ print("If running from source, use: python setup/tui.py")
44
+ sys.exit(1)
@@ -0,0 +1,187 @@
1
+ """Framework-agnostic system-prompt tagging.
2
+
3
+ The TraceCtrl Agents page wants to show the system prompt of every agent it
4
+ discovers. Most popular agent frameworks (Strands, Agno, OpenClaw, raw OpenAI
5
+ agents) do not emit the system prompt as a span attribute — their OTEL
6
+ instrumentors only carry the user-facing message list. The system prompt is
7
+ held on the Agent object as a Python string and never leaves the process.
8
+
9
+ This module bridges that gap with a small registry + SpanProcessor pair:
10
+
11
+ 1. The user calls `tag_agent(agent, system_prompt=...)` once per agent.
12
+ If `system_prompt` is omitted, the function introspects common Agent
13
+ attributes (`system_prompt`, `system`, `instructions`).
14
+ 2. A `SystemPromptStamper` SpanProcessor is auto-installed by `configure()`.
15
+ On every span start, it looks at the span name; if the name matches one
16
+ of the known agent-run patterns (Strands `invoke_agent <Name>`, Agno
17
+ `Agent.run`, OpenClaw `openclaw.run`, etc.), it consults the registry
18
+ and stamps `tracectrl.agent.system_prompt` (and a 16-char SHA-256 hash)
19
+ on the span.
20
+
21
+ The engine reads `tracectrl.agent.system_prompt` directly into the agent
22
+ inventory. A separate engine-side fallback scans `llm.input_messages.N` for
23
+ role=`system` rows so frameworks that DO emit the system message (some
24
+ OpenInference instrumentors) work without needing this helper.
25
+
26
+ Idempotent: re-tagging an agent updates the registered prompt.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import logging
33
+ import threading
34
+ from typing import Any, Optional
35
+
36
+ from opentelemetry.sdk.trace import SpanProcessor
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # --- Module state -----------------------------------------------------------
41
+
42
+ # (agent_name, agent_id) -> system_prompt
43
+ _REGISTRY: dict[str, str] = {}
44
+ _REGISTRY_LOCK = threading.Lock()
45
+ _PROCESSOR_INSTALLED = False
46
+
47
+ # Span-name patterns that identify "this is the agent's run span". Strands +
48
+ # OpenClaw + Agno all follow stable naming conventions; for unknown frameworks
49
+ # the user can still benefit by passing a custom `span_name_match` to
50
+ # `tag_agent` (added in v2 if needed).
51
+ _KNOWN_PATTERNS: list[tuple[str, str]] = [
52
+ ("invoke_agent ", "strands"), # Strands: "invoke_agent <Name>"
53
+ ("openclaw.run", "openclaw"), # OpenClaw root
54
+ ]
55
+
56
+
57
+ def _hash_prompt(prompt: str) -> str:
58
+ return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16]
59
+
60
+
61
+ def _normalize_id(name: str) -> str:
62
+ return name.lower().replace(" ", "-").replace("_", "-")
63
+
64
+
65
+ # --- Public API -------------------------------------------------------------
66
+
67
+
68
+ def tag_agent(agent: Any, system_prompt: Optional[str] = None) -> Any:
69
+ """Register `agent`'s system prompt so it lands on the agent's run spans.
70
+
71
+ Parameters
72
+ ----------
73
+ agent
74
+ Any framework agent object exposing a `name` (or class name fallback).
75
+ system_prompt
76
+ The system prompt string. If omitted, the function tries to read it
77
+ from the agent: `system_prompt`, `system`, then `instructions`. If
78
+ none of those exist, the call is a no-op and a debug-log line is
79
+ written.
80
+
81
+ Returns
82
+ -------
83
+ The same agent (so tag_agent can be chained).
84
+ """
85
+ if system_prompt is None:
86
+ for attr in ("system_prompt", "system", "instructions"):
87
+ val = getattr(agent, attr, None)
88
+ if isinstance(val, str) and val.strip():
89
+ system_prompt = val
90
+ break
91
+ if not system_prompt:
92
+ logger.debug("tag_agent: no system_prompt found on %r; skipping", agent)
93
+ return agent
94
+
95
+ name = getattr(agent, "name", None) or type(agent).__name__
96
+ if not isinstance(name, str) or not name:
97
+ logger.debug("tag_agent: cannot resolve agent name on %r; skipping", agent)
98
+ return agent
99
+
100
+ agent_id = _normalize_id(name)
101
+ with _REGISTRY_LOCK:
102
+ # Map by both display name (Strands span pattern) and lower-cased id
103
+ # so the stamper can match either form without retrying every variant.
104
+ _REGISTRY[name] = system_prompt
105
+ _REGISTRY[agent_id] = system_prompt
106
+ logger.debug("tag_agent: registered prompt for %s (%d chars)", name, len(system_prompt))
107
+ return agent
108
+
109
+
110
+ def tag_agents(*agents: Any, **named: str) -> None:
111
+ """Bulk variant — tag a sequence of agents (auto-introspect) or a mapping.
112
+
113
+ Examples
114
+ --------
115
+ >>> tag_agents(orchestrator, docai, payment) # auto-introspect
116
+ >>> tag_agents(my_agent="You are a careful auditor.")
117
+ """
118
+ for a in agents:
119
+ tag_agent(a)
120
+ for name, prompt in named.items():
121
+ # Treat the kwarg key as the agent name; build a minimal stand-in.
122
+ with _REGISTRY_LOCK:
123
+ _REGISTRY[name] = prompt
124
+ _REGISTRY[_normalize_id(name)] = prompt
125
+
126
+
127
+ def _lookup(span_name: str) -> Optional[str]:
128
+ """Return the registered prompt for the given span, if any."""
129
+ if not span_name:
130
+ return None
131
+ # Strands: "invoke_agent <Name>"
132
+ if span_name.startswith("invoke_agent "):
133
+ agent_name = span_name[len("invoke_agent "):].strip()
134
+ return _REGISTRY.get(agent_name) or _REGISTRY.get(_normalize_id(agent_name))
135
+ # OpenClaw: single canonical gateway agent
136
+ if span_name == "openclaw.run":
137
+ return _REGISTRY.get("openclaw-gateway")
138
+ # Generic: span name matches a registered name directly
139
+ return _REGISTRY.get(span_name) or _REGISTRY.get(_normalize_id(span_name))
140
+
141
+
142
+ # --- SpanProcessor ----------------------------------------------------------
143
+
144
+
145
+ class SystemPromptStamper(SpanProcessor):
146
+ """Stamps `tracectrl.agent.system_prompt` onto agent-run spans.
147
+
148
+ Reads from the in-process registry populated by `tag_agent`. Runs in
149
+ `on_start` so the attribute exists on the span before any exporter sees
150
+ it. No-op for spans that don't match a known agent-run pattern.
151
+ """
152
+
153
+ def on_start(self, span, parent_context=None): # type: ignore[override]
154
+ try:
155
+ if not span.is_recording():
156
+ return
157
+ prompt = _lookup(span.name)
158
+ if not prompt:
159
+ return
160
+ span.set_attribute("tracectrl.agent.system_prompt", prompt)
161
+ span.set_attribute("tracectrl.agent.system_prompt_hash", _hash_prompt(prompt))
162
+ except Exception:
163
+ # Tagging is best-effort — never break a span over it.
164
+ logger.debug("SystemPromptStamper: failed to stamp %s", span.name, exc_info=True)
165
+
166
+ def on_end(self, span): # type: ignore[override]
167
+ # Read-only ReadableSpan at end; we already stamped on_start.
168
+ pass
169
+
170
+ def shutdown(self): # type: ignore[override]
171
+ pass
172
+
173
+ def force_flush(self, timeout_millis: int = 30000): # type: ignore[override]
174
+ return True
175
+
176
+
177
+ def _ensure_processor_installed(tracer_provider) -> None:
178
+ """Idempotent install of SystemPromptStamper on the given TracerProvider."""
179
+ global _PROCESSOR_INSTALLED
180
+ if _PROCESSOR_INSTALLED:
181
+ return
182
+ try:
183
+ tracer_provider.add_span_processor(SystemPromptStamper())
184
+ _PROCESSOR_INSTALLED = True
185
+ logger.debug("SystemPromptStamper installed on TracerProvider")
186
+ except Exception:
187
+ logger.warning("Could not install SystemPromptStamper", exc_info=True)