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 +12 -0
- tracectrl/_tui.py +44 -0
- tracectrl/agent_tagging.py +187 -0
- tracectrl/cli.py +836 -0
- tracectrl/config.py +146 -0
- tracectrl/context.py +69 -0
- tracectrl/exporter.py +8 -0
- tracectrl/guardrails/__init__.py +19 -0
- tracectrl/guardrails/guardrail.py +123 -0
- tracectrl/guardrails/judge.py +205 -0
- tracectrl/guardrails/strands_hook.py +277 -0
- tracectrl/inference.py +71 -0
- tracectrl/processor.py +153 -0
- tracectrl/schema.py +34 -0
- tracectrl/session.py +24 -0
- tracectrl-0.1.0.dist-info/METADATA +13 -0
- tracectrl-0.1.0.dist-info/RECORD +20 -0
- tracectrl-0.1.0.dist-info/WHEEL +4 -0
- tracectrl-0.1.0.dist-info/entry_points.txt +2 -0
- tracectrl-0.1.0.dist-info/licenses/LICENSE +190 -0
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)
|